From f4722a0bf7cf833f799c8f915c265f04304e61ba Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 03:55:18 +0000
Subject: [PATCH 01/39] feat: add Loom import job tables
Co-authored-by: Richie McIlroy
---
.../0050_productive_christian_walker.sql | 42 +
.../migrations/meta/0050_snapshot.json | 5734 +++++++++++++++++
.../database/migrations/meta/_journal.json | 717 ++-
packages/database/schema.ts | 82 +
4 files changed, 6220 insertions(+), 355 deletions(-)
create mode 100644 packages/database/migrations/0050_productive_christian_walker.sql
create mode 100644 packages/database/migrations/meta/0050_snapshot.json
diff --git a/packages/database/migrations/0050_productive_christian_walker.sql b/packages/database/migrations/0050_productive_christian_walker.sql
new file mode 100644
index 00000000000..4d37995be58
--- /dev/null
+++ b/packages/database/migrations/0050_productive_christian_walker.sql
@@ -0,0 +1,42 @@
+CREATE TABLE `loom_import_job_items` (
+ `id` varchar(15) NOT NULL,
+ `job_id` varchar(15) NOT NULL,
+ `csv_row` int NOT NULL,
+ `loom_url` varchar(1024) NOT NULL,
+ `loom_video_id` varchar(64),
+ `owner_email` varchar(255),
+ `space_name` varchar(255),
+ `status` varchar(16) NOT NULL DEFAULT 'pending',
+ `owner_id` varchar(15),
+ `space_id` varchar(15),
+ `video_id` varchar(15),
+ `title` varchar(255),
+ `loom_created_at` datetime(3),
+ `duration_seconds` float,
+ `width` int,
+ `height` int,
+ `thumbnail_url` varchar(1024),
+ `error` varchar(512),
+ `updated_at` datetime(3) NOT NULL,
+ CONSTRAINT `loom_import_job_items_id` PRIMARY KEY(`id`),
+ CONSTRAINT `job_row_idx` UNIQUE(`job_id`,`csv_row`)
+);
+--> statement-breakpoint
+CREATE TABLE `loom_import_jobs` (
+ `id` varchar(15) NOT NULL,
+ `org_id` varchar(15) NOT NULL,
+ `created_by_id` varchar(15) NOT NULL,
+ `file_name` varchar(255) NOT NULL,
+ `status` varchar(32) NOT NULL DEFAULT 'checking',
+ `total_count` int NOT NULL,
+ `created_at` datetime(3) NOT NULL,
+ `updated_at` datetime(3) NOT NULL,
+ `started_at` datetime(3),
+ `completed_at` datetime(3),
+ CONSTRAINT `loom_import_jobs_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE INDEX `job_status_row_idx` ON `loom_import_job_items` (`job_id`,`status`,`csv_row`);--> statement-breakpoint
+CREATE INDEX `video_id_idx` ON `loom_import_job_items` (`video_id`);--> statement-breakpoint
+CREATE INDEX `org_creator_created_idx` ON `loom_import_jobs` (`org_id`,`created_by_id`,`created_at`);--> statement-breakpoint
+CREATE INDEX `status_updated_idx` ON `loom_import_jobs` (`status`,`updated_at`);
\ No newline at end of file
diff --git a/packages/database/migrations/meta/0050_snapshot.json b/packages/database/migrations/meta/0050_snapshot.json
new file mode 100644
index 00000000000..76994f3a1b6
--- /dev/null
+++ b/packages/database/migrations/meta/0050_snapshot.json
@@ -0,0 +1,5734 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "b3eb8041-9563-45df-99f3-649946dfd1e6",
+ "prevId": "7db9e8b2-ba9e-416d-a243-4b79b5d4ec5d",
+ "tables": {
+ "accounts": {
+ "name": "accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_in": {
+ "name": "expires_in",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_in": {
+ "name": "refresh_token_expires_in",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "tempColumn": {
+ "name": "tempColumn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "provider_account_id_idx": {
+ "name": "provider_account_id_idx",
+ "columns": [
+ "providerAccountId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "accounts_id": {
+ "name": "accounts_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_authorization_codes": {
+ "name": "agent_api_authorization_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": [
+ "codeHash"
+ ],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": [
+ "expiresAt"
+ ],
+ "isUnique": false
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": [
+ "userId",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_authorization_codes_id": {
+ "name": "agent_api_authorization_codes_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_idempotency": {
+ "name": "agent_api_idempotency",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestHash": {
+ "name": "requestHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "response": {
+ "name": "response",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_operation_key_idx": {
+ "name": "user_operation_key_idx",
+ "columns": [
+ "userId",
+ "operation",
+ "keyHash"
+ ],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": [
+ "expiresAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_idempotency_id": {
+ "name": "agent_api_idempotency_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_keys": {
+ "name": "agent_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tokenHash": {
+ "name": "tokenHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Cap CLI'"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "token_hash_idx": {
+ "name": "token_hash_idx",
+ "columns": [
+ "tokenHash"
+ ],
+ "isUnique": true
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": [
+ "userId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": [
+ "expiresAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_keys_id": {
+ "name": "agent_api_keys_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_operations": {
+ "name": "agent_api_operations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resourceId": {
+ "name": "resourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resultResourceId": {
+ "name": "resultResourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'queued'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorCode": {
+ "name": "errorCode",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": [
+ "userId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "state_updated_at_idx": {
+ "name": "state_updated_at_idx",
+ "columns": [
+ "state",
+ "updatedAt"
+ ],
+ "isUnique": false
+ },
+ "resource_id_idx": {
+ "name": "resource_id_idx",
+ "columns": [
+ "resourceId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_operations_id": {
+ "name": "agent_api_operations_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "auth_api_keys": {
+ "name": "auth_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_created_at_idx": {
+ "name": "user_id_created_at_idx",
+ "columns": [
+ "userId",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "auth_api_keys_id": {
+ "name": "auth_api_keys_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "parentCommentId": {
+ "name": "parentCommentId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaKey": {
+ "name": "mediaKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaDuration": {
+ "name": "mediaDuration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaMeta": {
+ "name": "mediaMeta",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_type_created_idx": {
+ "name": "video_type_created_idx",
+ "columns": [
+ "videoId",
+ "type",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "author_id_idx": {
+ "name": "author_id_idx",
+ "columns": [
+ "authorId"
+ ],
+ "isUnique": false
+ },
+ "parent_comment_id_idx": {
+ "name": "parent_comment_id_idx",
+ "columns": [
+ "parentCommentId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "comments_id": {
+ "name": "comments_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_api_keys": {
+ "name": "developer_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyType": {
+ "name": "keyType",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyPrefix": {
+ "name": "keyPrefix",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedKey": {
+ "name": "encryptedKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "key_hash_idx": {
+ "name": "key_hash_idx",
+ "columns": [
+ "keyHash"
+ ],
+ "isUnique": true
+ },
+ "app_key_type_idx": {
+ "name": "app_key_type_idx",
+ "columns": [
+ "appId",
+ "keyType"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "developer_api_keys_appId_developer_apps_id_fk": {
+ "name": "developer_api_keys_appId_developer_apps_id_fk",
+ "tableFrom": "developer_api_keys",
+ "tableTo": "developer_apps",
+ "columnsFrom": [
+ "appId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_api_keys_id": {
+ "name": "developer_api_keys_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_app_domains": {
+ "name": "developer_app_domains",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "varchar(253)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "developer_app_domains_appId_developer_apps_id_fk": {
+ "name": "developer_app_domains_appId_developer_apps_id_fk",
+ "tableFrom": "developer_app_domains",
+ "tableTo": "developer_apps",
+ "columnsFrom": [
+ "appId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_app_domains_id": {
+ "name": "developer_app_domains_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "app_domain_unique": {
+ "name": "app_domain_unique",
+ "columns": [
+ "appId",
+ "domain"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "developer_apps": {
+ "name": "developer_apps",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment": {
+ "name": "environment",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logoUrl": {
+ "name": "logoUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletedAt": {
+ "name": "deletedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_deleted_idx": {
+ "name": "owner_deleted_idx",
+ "columns": [
+ "ownerId",
+ "deletedAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "developer_apps_id": {
+ "name": "developer_apps_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_credit_accounts": {
+ "name": "developer_credit_accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "balanceMicroCredits": {
+ "name": "balanceMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripePaymentMethodId": {
+ "name": "stripePaymentMethodId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoTopUpEnabled": {
+ "name": "autoTopUpEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "autoTopUpThresholdMicroCredits": {
+ "name": "autoTopUpThresholdMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "autoTopUpAmountCents": {
+ "name": "autoTopUpAmountCents",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "app_id_unique": {
+ "name": "app_id_unique",
+ "columns": [
+ "appId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "developer_credit_accounts_appId_developer_apps_id_fk": {
+ "name": "developer_credit_accounts_appId_developer_apps_id_fk",
+ "tableFrom": "developer_credit_accounts",
+ "tableTo": "developer_apps",
+ "columnsFrom": [
+ "appId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_credit_accounts_id": {
+ "name": "developer_credit_accounts_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_credit_transactions": {
+ "name": "developer_credit_transactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accountId": {
+ "name": "accountId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amountMicroCredits": {
+ "name": "amountMicroCredits",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "balanceAfterMicroCredits": {
+ "name": "balanceAfterMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referenceId": {
+ "name": "referenceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referenceType": {
+ "name": "referenceType",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "account_type_created_idx": {
+ "name": "account_type_created_idx",
+ "columns": [
+ "accountId",
+ "type",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "account_ref_dedup_idx": {
+ "name": "account_ref_dedup_idx",
+ "columns": [
+ "accountId",
+ "referenceId",
+ "referenceType"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "dev_credit_txn_account_fk": {
+ "name": "dev_credit_txn_account_fk",
+ "tableFrom": "developer_credit_transactions",
+ "tableTo": "developer_credit_accounts",
+ "columnsFrom": [
+ "accountId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_credit_transactions_id": {
+ "name": "developer_credit_transactions_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_daily_storage_snapshots": {
+ "name": "developer_daily_storage_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshotDate": {
+ "name": "snapshotDate",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totalDurationMinutes": {
+ "name": "totalDurationMinutes",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "videoCount": {
+ "name": "videoCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "microCreditsCharged": {
+ "name": "microCreditsCharged",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processedAt": {
+ "name": "processedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "developer_daily_storage_snapshots_appId_developer_apps_id_fk": {
+ "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk",
+ "tableFrom": "developer_daily_storage_snapshots",
+ "tableTo": "developer_apps",
+ "columnsFrom": [
+ "appId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_daily_storage_snapshots_id": {
+ "name": "developer_daily_storage_snapshots_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "app_date_unique": {
+ "name": "app_date_unique",
+ "columns": [
+ "appId",
+ "snapshotDate"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "developer_videos": {
+ "name": "developer_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalUserId": {
+ "name": "externalUserId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Untitled'"
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "s3Key": {
+ "name": "s3Key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletedAt": {
+ "name": "deletedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "app_created_idx": {
+ "name": "app_created_idx",
+ "columns": [
+ "appId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "app_user_idx": {
+ "name": "app_user_idx",
+ "columns": [
+ "appId",
+ "externalUserId"
+ ],
+ "isUnique": false
+ },
+ "app_deleted_idx": {
+ "name": "app_deleted_idx",
+ "columns": [
+ "appId",
+ "deletedAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "developer_videos_appId_developer_apps_id_fk": {
+ "name": "developer_videos_appId_developer_apps_id_fk",
+ "tableFrom": "developer_videos",
+ "tableTo": "developer_apps",
+ "columnsFrom": [
+ "appId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_videos_id": {
+ "name": "developer_videos_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "folders": {
+ "name": "folders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'normal'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "created_by_id_idx": {
+ "name": "created_by_id_idx",
+ "columns": [
+ "createdById"
+ ],
+ "isUnique": false
+ },
+ "parent_id_idx": {
+ "name": "parent_id_idx",
+ "columns": [
+ "parentId"
+ ],
+ "isUnique": false
+ },
+ "space_id_idx": {
+ "name": "space_id_idx",
+ "columns": [
+ "spaceId"
+ ],
+ "isUnique": false
+ },
+ "public_parent_id_idx": {
+ "name": "public_parent_id_idx",
+ "columns": [
+ "public",
+ "parentId"
+ ],
+ "isUnique": false
+ },
+ "public_space_parent_id_idx": {
+ "name": "public_space_parent_id_idx",
+ "columns": [
+ "public",
+ "spaceId",
+ "parentId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "folders_id": {
+ "name": "folders_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "imported_videos": {
+ "name": "imported_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "id_idx": {
+ "name": "id_idx",
+ "columns": [
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "imported_videos_orgId_source_source_id_pk": {
+ "name": "imported_videos_orgId_source_source_id_pk",
+ "columns": [
+ "orgId",
+ "source",
+ "source_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_installations": {
+ "name": "integration_installations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalId": {
+ "name": "externalId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "installedByUserId": {
+ "name": "installedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedCredentials": {
+ "name": "encryptedCredentials",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "provider_external_id_idx": {
+ "name": "provider_external_id_idx",
+ "columns": [
+ "provider",
+ "externalId"
+ ],
+ "isUnique": true
+ },
+ "organization_provider_display_name_idx": {
+ "name": "organization_provider_display_name_idx",
+ "columns": [
+ "organizationId",
+ "provider",
+ "displayName"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "integration_installations_id": {
+ "name": "integration_installations_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_job_items": {
+ "name": "loom_import_job_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "csv_row": {
+ "name": "csv_row",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "loom_url": {
+ "name": "loom_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "loom_video_id": {
+ "name": "loom_video_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "owner_email": {
+ "name": "owner_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "space_name": {
+ "name": "space_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "space_id": {
+ "name": "space_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loom_created_at": {
+ "name": "loom_created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_url": {
+ "name": "thumbnail_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "job_row_idx": {
+ "name": "job_row_idx",
+ "columns": [
+ "job_id",
+ "csv_row"
+ ],
+ "isUnique": true
+ },
+ "job_status_row_idx": {
+ "name": "job_status_row_idx",
+ "columns": [
+ "job_id",
+ "status",
+ "csv_row"
+ ],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": [
+ "video_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_job_items_id": {
+ "name": "loom_import_job_items_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_jobs": {
+ "name": "loom_import_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'checking'"
+ },
+ "total_count": {
+ "name": "total_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_creator_created_idx": {
+ "name": "org_creator_created_idx",
+ "columns": [
+ "org_id",
+ "created_by_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "status_updated_idx": {
+ "name": "status_updated_idx",
+ "columns": [
+ "status",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_jobs_id": {
+ "name": "loom_import_jobs_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loops_sync_jobs": {
+ "name": "loops_sync_jobs",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "nextAttemptAt": {
+ "name": "nextAttemptAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "leaseToken": {
+ "name": "leaseToken",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "leaseUntil": {
+ "name": "leaseUntil",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failures": {
+ "name": "failures",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "profileHash": {
+ "name": "profileHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teammateJoinedAt": {
+ "name": "teammateJoinedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "syncedEmail": {
+ "name": "syncedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastSyncedAt": {
+ "name": "lastSyncedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "loops_sync_due_idx": {
+ "name": "loops_sync_due_idx",
+ "columns": [
+ "nextAttemptAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loops_sync_jobs_userId": {
+ "name": "loops_sync_jobs_userId",
+ "columns": [
+ "userId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_clients": {
+ "name": "mcp_oauth_clients",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientName": {
+ "name": "clientName",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUris": {
+ "name": "redirectUris",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activatedAt": {
+ "name": "activatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "client_id_idx": {
+ "name": "client_id_idx",
+ "columns": [
+ "clientId"
+ ],
+ "isUnique": true
+ },
+ "inactive_created_at_idx": {
+ "name": "inactive_created_at_idx",
+ "columns": [
+ "activatedAt",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_clients_id": {
+ "name": "mcp_oauth_clients_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_codes": {
+ "name": "mcp_oauth_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": [
+ "codeHash"
+ ],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": [
+ "expiresAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_codes_id": {
+ "name": "mcp_oauth_codes_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_registration_quotas": {
+ "name": "mcp_oauth_registration_quotas",
+ "columns": {
+ "windowId": {
+ "name": "windowId",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "registrations": {
+ "name": "registrations",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": [
+ "expiresAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_registration_quotas_windowId": {
+ "name": "mcp_oauth_registration_quotas_windowId",
+ "columns": [
+ "windowId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_tokens": {
+ "name": "mcp_oauth_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "familyId": {
+ "name": "familyId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessHash": {
+ "name": "accessHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshHash": {
+ "name": "refreshHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessExpiresAt": {
+ "name": "accessExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshExpiresAt": {
+ "name": "refreshExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "access_hash_idx": {
+ "name": "access_hash_idx",
+ "columns": [
+ "accessHash"
+ ],
+ "isUnique": true
+ },
+ "refresh_hash_idx": {
+ "name": "refresh_hash_idx",
+ "columns": [
+ "refreshHash"
+ ],
+ "isUnique": true
+ },
+ "family_id_idx": {
+ "name": "family_id_idx",
+ "columns": [
+ "familyId"
+ ],
+ "isUnique": false
+ },
+ "client_active_idx": {
+ "name": "client_active_idx",
+ "columns": [
+ "clientId",
+ "revokedAt",
+ "refreshExpiresAt"
+ ],
+ "isUnique": false
+ },
+ "refresh_expires_at_idx": {
+ "name": "refresh_expires_at_idx",
+ "columns": [
+ "refreshExpiresAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_tokens_id": {
+ "name": "mcp_oauth_tokens_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media_processing_budgets": {
+ "name": "media_processing_budgets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reserved_bytes": {
+ "name": "reserved_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "limit_bytes": {
+ "name": "limit_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "media_budget_expiry_idx": {
+ "name": "media_budget_expiry_idx",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "media_processing_budgets_id": {
+ "name": "media_processing_budgets_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_conversations": {
+ "name": "messenger_conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent": {
+ "name": "agent",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'agent'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "anonymousId": {
+ "name": "anonymousId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "takeoverByUserId": {
+ "name": "takeoverByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "takeoverAt": {
+ "name": "takeoverAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastMessageAt": {
+ "name": "lastMessageAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_last_message_idx": {
+ "name": "user_last_message_idx",
+ "columns": [
+ "userId",
+ "lastMessageAt"
+ ],
+ "isUnique": false
+ },
+ "anonymous_last_message_idx": {
+ "name": "anonymous_last_message_idx",
+ "columns": [
+ "anonymousId",
+ "lastMessageAt"
+ ],
+ "isUnique": false
+ },
+ "mode_last_message_idx": {
+ "name": "mode_last_message_idx",
+ "columns": [
+ "mode",
+ "lastMessageAt"
+ ],
+ "isUnique": false
+ },
+ "updated_at_idx": {
+ "name": "updated_at_idx",
+ "columns": [
+ "updatedAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "messenger_conversations_id": {
+ "name": "messenger_conversations_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_messages": {
+ "name": "messenger_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversationId": {
+ "name": "conversationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "anonymousId": {
+ "name": "anonymousId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "conversation_created_at_idx": {
+ "name": "conversation_created_at_idx",
+ "columns": [
+ "conversationId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "role_created_at_idx": {
+ "name": "role_created_at_idx",
+ "columns": [
+ "role",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messenger_messages_conversationId_messenger_conversations_id_fk": {
+ "name": "messenger_messages_conversationId_messenger_conversations_id_fk",
+ "tableFrom": "messenger_messages",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": [
+ "conversationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_messages_id": {
+ "name": "messenger_messages_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_support_emails": {
+ "name": "messenger_support_emails",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversationId": {
+ "name": "conversationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userEmail": {
+ "name": "userEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "support_email_user_created_at_idx": {
+ "name": "support_email_user_created_at_idx",
+ "columns": [
+ "userId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "support_email_conversation_created_at_idx": {
+ "name": "support_email_conversation_created_at_idx",
+ "columns": [
+ "conversationId",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "support_email_conversation_fk": {
+ "name": "support_email_conversation_fk",
+ "tableFrom": "messenger_support_emails",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": [
+ "conversationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_support_emails_id": {
+ "name": "messenger_support_emails_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipientId": {
+ "name": "recipientId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dedupKey": {
+ "name": "dedupKey",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readAt": {
+ "name": "readAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "org_id_idx": {
+ "name": "org_id_idx",
+ "columns": [
+ "orgId"
+ ],
+ "isUnique": false
+ },
+ "type_idx": {
+ "name": "type_idx",
+ "columns": [
+ "type"
+ ],
+ "isUnique": false
+ },
+ "read_at_idx": {
+ "name": "read_at_idx",
+ "columns": [
+ "readAt"
+ ],
+ "isUnique": false
+ },
+ "created_at_idx": {
+ "name": "created_at_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "recipient_read_idx": {
+ "name": "recipient_read_idx",
+ "columns": [
+ "recipientId",
+ "readAt"
+ ],
+ "isUnique": false
+ },
+ "recipient_created_idx": {
+ "name": "recipient_created_idx",
+ "columns": [
+ "recipientId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "dedup_key_idx": {
+ "name": "dedup_key_idx",
+ "columns": [
+ "dedupKey"
+ ],
+ "isUnique": true
+ },
+ "type_recipient_created_idx": {
+ "name": "type_recipient_created_idx",
+ "columns": [
+ "type",
+ "recipientId",
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "type_recipient_video_created_idx": {
+ "name": "type_recipient_video_created_idx",
+ "columns": [
+ "type",
+ "recipientId",
+ "videoId",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "notifications_id": {
+ "name": "notifications_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_invites": {
+ "name": "organization_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "invited_email_idx": {
+ "name": "invited_email_idx",
+ "columns": [
+ "invitedEmail"
+ ],
+ "isUnique": false
+ },
+ "invited_by_user_id_idx": {
+ "name": "invited_by_user_id_idx",
+ "columns": [
+ "invitedByUserId"
+ ],
+ "isUnique": false
+ },
+ "status_idx": {
+ "name": "status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_invites_id": {
+ "name": "organization_invites_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_members": {
+ "name": "organization_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "hasProSeat": {
+ "name": "hasProSeat",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "user_id_organization_id_idx": {
+ "name": "user_id_organization_id_idx",
+ "columns": [
+ "userId",
+ "organizationId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_members_id": {
+ "name": "organization_members_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_sso": {
+ "name": "organization_sso",
+ "columns": {
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "purchasedByUserId": {
+ "name": "purchasedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripePriceId": {
+ "name": "stripePriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "paidThrough": {
+ "name": "paidThrough",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "currentPeriodEnd": {
+ "name": "currentPeriodEnd",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "checkoutAttemptId": {
+ "name": "checkoutAttemptId",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutCurrency": {
+ "name": "checkoutCurrency",
+ "type": "varchar(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutPriceId": {
+ "name": "checkoutPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutSessionId": {
+ "name": "checkoutSessionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutStartedAt": {
+ "name": "checkoutStartedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "sso_stripe_subscription_id_idx": {
+ "name": "sso_stripe_subscription_id_idx",
+ "columns": [
+ "stripeSubscriptionId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_sso_organizationId": {
+ "name": "organization_sso_organizationId",
+ "columns": [
+ "organizationId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organizations": {
+ "name": "organizations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tombstoneAt": {
+ "name": "tombstoneAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "allowedEmailDomain": {
+ "name": "allowedEmailDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "defaultVideoVisibility": {
+ "name": "defaultVideoVisibility",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customDomain": {
+ "name": "customDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "domainVerified": {
+ "name": "domainVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shareableLinkIconUrl": {
+ "name": "shareableLinkIconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "workosOrganizationId": {
+ "name": "workosOrganizationId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workosConnectionId": {
+ "name": "workosConnectionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "owner_id_tombstone_idx": {
+ "name": "owner_id_tombstone_idx",
+ "columns": [
+ "ownerId",
+ "tombstoneAt"
+ ],
+ "isUnique": false
+ },
+ "custom_domain_idx": {
+ "name": "custom_domain_idx",
+ "columns": [
+ "customDomain"
+ ],
+ "isUnique": false
+ },
+ "workos_organization_id_idx": {
+ "name": "workos_organization_id_idx",
+ "columns": [
+ "workosOrganizationId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organizations_id": {
+ "name": "organizations_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "s3_buckets": {
+ "name": "s3_buckets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bucketName": {
+ "name": "bucketName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessKeyId": {
+ "name": "accessKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secretAccessKey": {
+ "name": "secretAccessKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('aws')"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_organization_idx": {
+ "name": "owner_organization_idx",
+ "columns": [
+ "ownerId",
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": [
+ "organizationId",
+ "active",
+ "updatedAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "s3_buckets_id": {
+ "name": "s3_buckets_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "session_token_idx": {
+ "name": "session_token_idx",
+ "columns": [
+ "sessionToken"
+ ],
+ "isUnique": true
+ },
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sessions_id": {
+ "name": "sessions_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "shared_videos": {
+ "name": "shared_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedByUserId": {
+ "name": "sharedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedAt": {
+ "name": "sharedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": [
+ "folderId"
+ ],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "shared_by_user_id_idx": {
+ "name": "shared_by_user_id_idx",
+ "columns": [
+ "sharedByUserId"
+ ],
+ "isUnique": false
+ },
+ "video_id_organization_id_idx": {
+ "name": "video_id_organization_id_idx",
+ "columns": [
+ "videoId",
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "video_id_folder_id_idx": {
+ "name": "video_id_folder_id_idx",
+ "columns": [
+ "videoId",
+ "folderId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "shared_videos_id": {
+ "name": "shared_videos_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "signed_baas": {
+ "name": "signed_baas",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entityName": {
+ "name": "entityName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityType": {
+ "name": "entityType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityAddress": {
+ "name": "entityAddress",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerName": {
+ "name": "signerName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerTitle": {
+ "name": "signerTitle",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "noticesEmail": {
+ "name": "noticesEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signatureData": {
+ "name": "signatureData",
+ "type": "longtext",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signedAt": {
+ "name": "signedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "emailSentAt": {
+ "name": "emailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "signed_baa_organization_id_idx": {
+ "name": "signed_baa_organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": true
+ },
+ "signed_baa_stripe_subscription_idx": {
+ "name": "signed_baa_stripe_subscription_idx",
+ "columns": [
+ "stripeSubscriptionId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "signed_baas_id": {
+ "name": "signed_baas_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "space_members": {
+ "name": "space_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_members_id": {
+ "name": "space_members_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "space_id_user_id_unique": {
+ "name": "space_id_user_id_unique",
+ "columns": [
+ "spaceId",
+ "userId"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "space_videos": {
+ "name": "space_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedById": {
+ "name": "addedById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": [
+ "folderId"
+ ],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": [
+ "videoId"
+ ],
+ "isUnique": false
+ },
+ "added_by_id_idx": {
+ "name": "added_by_id_idx",
+ "columns": [
+ "addedById"
+ ],
+ "isUnique": false
+ },
+ "space_id_video_id_idx": {
+ "name": "space_id_video_id_idx",
+ "columns": [
+ "spaceId",
+ "videoId"
+ ],
+ "isUnique": false
+ },
+ "space_id_folder_id_idx": {
+ "name": "space_id_folder_id_idx",
+ "columns": [
+ "spaceId",
+ "folderId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_videos_id": {
+ "name": "space_videos_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "spaces": {
+ "name": "spaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "primary": {
+ "name": "primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "privacy": {
+ "name": "privacy",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Private'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": [
+ "organizationId"
+ ],
+ "isUnique": false
+ },
+ "created_by_id_idx": {
+ "name": "created_by_id_idx",
+ "columns": [
+ "createdById"
+ ],
+ "isUnique": false
+ },
+ "public_organization_id_idx": {
+ "name": "public_organization_id_idx",
+ "columns": [
+ "public",
+ "organizationId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "spaces_id": {
+ "name": "spaces_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_integrations": {
+ "name": "storage_integrations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "encryptedConfig": {
+ "name": "encryptedConfig",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "googleDriveAccessToken": {
+ "name": "googleDriveAccessToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveAccessTokenExpiresAt": {
+ "name": "googleDriveAccessTokenExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseId": {
+ "name": "googleDriveTokenRefreshLeaseId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseExpiresAt": {
+ "name": "googleDriveTokenRefreshLeaseExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveStorageQuotaCache": {
+ "name": "googleDriveStorageQuotaCache",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_provider_idx": {
+ "name": "owner_provider_idx",
+ "columns": [
+ "ownerId",
+ "provider"
+ ],
+ "isUnique": false
+ },
+ "owner_active_idx": {
+ "name": "owner_active_idx",
+ "columns": [
+ "ownerId",
+ "active"
+ ],
+ "isUnique": false
+ },
+ "organization_provider_idx": {
+ "name": "organization_provider_idx",
+ "columns": [
+ "organizationId",
+ "provider"
+ ],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": [
+ "organizationId",
+ "active",
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "storage_integrations_id": {
+ "name": "storage_integrations_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_objects": {
+ "name": "storage_objects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrationId": {
+ "name": "integrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "objectKey": {
+ "name": "objectKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "objectKeyHash": {
+ "name": "objectKeyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerObjectId": {
+ "name": "providerObjectId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploadSessionUrl": {
+ "name": "uploadSessionUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uploadStatus": {
+ "name": "uploadStatus",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentLength": {
+ "name": "contentLength",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "integration_key_hash_idx": {
+ "name": "integration_key_hash_idx",
+ "columns": [
+ "integrationId",
+ "objectKeyHash"
+ ],
+ "isUnique": true
+ },
+ "integration_status_idx": {
+ "name": "integration_status_idx",
+ "columns": [
+ "integrationId",
+ "uploadStatus"
+ ],
+ "isUnique": false
+ },
+ "integration_object_key_prefix_idx": {
+ "name": "integration_object_key_prefix_idx",
+ "columns": [
+ "integrationId",
+ "`objectKey`(191)"
+ ],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": [
+ "videoId"
+ ],
+ "isUnique": false
+ },
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": [
+ "ownerId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "storage_objects_integrationId_storage_integrations_id_fk": {
+ "name": "storage_objects_integrationId_storage_integrations_id_fk",
+ "tableFrom": "storage_objects",
+ "tableTo": "storage_integrations",
+ "columnsFrom": [
+ "integrationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "storage_objects_id": {
+ "name": "storage_objects_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastName": {
+ "name": "lastName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thirdPartyStripeSubscriptionId": {
+ "name": "thirdPartyStripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionStatus": {
+ "name": "stripeSubscriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionPriceId": {
+ "name": "stripeSubscriptionPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferences": {
+ "name": "preferences",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('null')"
+ },
+ "activeOrganizationId": {
+ "name": "activeOrganizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "onboardingSteps": {
+ "name": "onboardingSteps",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customBucket": {
+ "name": "customBucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inviteQuota": {
+ "name": "inviteQuota",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "defaultOrgId": {
+ "name": "defaultOrgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authSessionVersion": {
+ "name": "authSessionVersion",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "marketingOrigin": {
+ "name": "marketingOrigin",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {
+ "email_idx": {
+ "name": "email_idx",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verification_tokens": {
+ "name": "verification_tokens",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verification_tokens_identifier": {
+ "name": "verification_tokens_identifier",
+ "columns": [
+ "identifier"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "verification_tokens_token_unique": {
+ "name": "verification_tokens_token_unique",
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "video_edits": {
+ "name": "video_edits",
+ "columns": {
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceKey": {
+ "name": "sourceKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "editSpec": {
+ "name": "editSpec",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "video_edits_videoId_videos_id_fk": {
+ "name": "video_edits_videoId_videos_id_fk",
+ "tableFrom": "video_edits",
+ "tableTo": "videos",
+ "columnsFrom": [
+ "videoId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_edits_videoId": {
+ "name": "video_edits_videoId",
+ "columns": [
+ "videoId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_processing_jobs": {
+ "name": "video_processing_jobs",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manifest_sha256": {
+ "name": "manifest_sha256",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'committing'"
+ },
+ "attempt_id": {
+ "name": "attempt_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_retry_at": {
+ "name": "next_retry_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workflow_run_id": {
+ "name": "workflow_run_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_job_id": {
+ "name": "remote_job_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification": {
+ "name": "verification",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "output": {
+ "name": "output",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "processing_state_retry_video_idx": {
+ "name": "processing_state_retry_video_idx",
+ "columns": [
+ "state",
+ "next_retry_at",
+ "video_id"
+ ],
+ "isUnique": false
+ },
+ "processing_state_lease_video_idx": {
+ "name": "processing_state_lease_video_idx",
+ "columns": [
+ "state",
+ "lease_expires_at",
+ "video_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_processing_jobs_video_id": {
+ "name": "video_processing_jobs_video_id",
+ "columns": [
+ "video_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_uploads": {
+ "name": "video_uploads",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total": {
+ "name": "total",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'uploading'"
+ },
+ "processing_progress": {
+ "name": "processing_progress",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processing_message": {
+ "name": "processing_message",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processing_error": {
+ "name": "processing_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "raw_file_key": {
+ "name": "raw_file_key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "phase_updated_at_video_id_idx": {
+ "name": "phase_updated_at_video_id_idx",
+ "columns": [
+ "phase",
+ "updated_at",
+ "video_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_uploads_video_id": {
+ "name": "video_uploads_video_id",
+ "columns": [
+ "video_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_viewer_grants": {
+ "name": "video_viewer_grants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_email_idx": {
+ "name": "video_email_idx",
+ "columns": [
+ "videoId",
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "video_viewer_grants_videoId_videos_id_fk": {
+ "name": "video_viewer_grants_videoId_videos_id_fk",
+ "tableFrom": "video_viewer_grants",
+ "tableTo": "videos",
+ "columnsFrom": [
+ "videoId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_viewer_grants_id": {
+ "name": "video_viewer_grants_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "videos": {
+ "name": "videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'My Video'"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageIntegrationId": {
+ "name": "storageIntegrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"type\":\"MediaConvert\"}')"
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "effectiveCreatedAt": {
+ "name": "effectiveCreatedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "generated": {
+ "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )",
+ "type": "stored"
+ }
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "xStreamInfo": {
+ "name": "xStreamInfo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "firstViewEmailSentAt": {
+ "name": "firstViewEmailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "isScreenshot": {
+ "name": "isScreenshot",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "awsRegion": {
+ "name": "awsRegion",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "awsBucket": {
+ "name": "awsBucket",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoStartTime": {
+ "name": "videoStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "audioStartTime": {
+ "name": "audioStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobId": {
+ "name": "jobId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobStatus": {
+ "name": "jobStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "skipProcessing": {
+ "name": "skipProcessing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": [
+ "ownerId"
+ ],
+ "isUnique": false
+ },
+ "owner_updated_id_idx": {
+ "name": "owner_updated_id_idx",
+ "columns": [
+ "ownerId",
+ "updatedAt",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "is_public_idx": {
+ "name": "is_public_idx",
+ "columns": [
+ "public"
+ ],
+ "isUnique": false
+ },
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": [
+ "folderId"
+ ],
+ "isUnique": false
+ },
+ "storage_integration_id_idx": {
+ "name": "storage_integration_id_idx",
+ "columns": [
+ "storageIntegrationId"
+ ],
+ "isUnique": false
+ },
+ "org_owner_folder_idx": {
+ "name": "org_owner_folder_idx",
+ "columns": [
+ "orgId",
+ "ownerId",
+ "folderId"
+ ],
+ "isUnique": false
+ },
+ "org_effective_created_idx": {
+ "name": "org_effective_created_idx",
+ "columns": [
+ "orgId",
+ "effectiveCreatedAt"
+ ],
+ "isUnique": false
+ },
+ "screenshot_transcription_created_idx": {
+ "name": "screenshot_transcription_created_idx",
+ "columns": [
+ "isScreenshot",
+ "transcriptionStatus",
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "videos_storageIntegrationId_storage_integrations_id_fk": {
+ "name": "videos_storageIntegrationId_storage_integrations_id_fk",
+ "tableFrom": "videos",
+ "tableTo": "storage_integrations",
+ "columnsFrom": [
+ "storageIntegrationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "videos_id": {
+ "name": "videos_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {
+ "integration_object_key_prefix_idx": {
+ "columns": {
+ "`objectKey`(191)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json
index d09298a78fb..b72ca9d21f3 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -1,356 +1,363 @@
{
- "version": "5",
- "dialect": "mysql",
- "entries": [
- {
- "idx": 0,
- "version": "5",
- "when": 1743020179593,
- "tag": "0000_brown_sunfire",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "5",
- "when": 1749268354138,
- "tag": "0001_white_young_avengers",
- "breakpoints": true
- },
- {
- "idx": 2,
- "version": "5",
- "when": 1750935538683,
- "tag": "0002_dusty_maginty",
- "breakpoints": true
- },
- {
- "idx": 3,
- "version": "5",
- "when": 1761710697286,
- "tag": "0003_thin_gressill",
- "breakpoints": true
- },
- {
- "idx": 4,
- "version": "5",
- "when": 1761711378574,
- "tag": "0004_video-org-id",
- "breakpoints": true
- },
- {
- "idx": 5,
- "version": "5",
- "when": 1761711605408,
- "tag": "0005_video-org-id-required",
- "breakpoints": true
- },
- {
- "idx": 6,
- "version": "5",
- "when": 1762410865419,
- "tag": "0006_hesitant_stone_men",
- "breakpoints": true
- },
- {
- "idx": 7,
- "version": "5",
- "when": 1762428551824,
- "tag": "0007_public_toxin",
- "breakpoints": true
- },
- {
- "idx": 8,
- "version": "5",
- "when": 1762428905323,
- "tag": "0008_fat_ender_wiggin",
- "breakpoints": true
- },
- {
- "idx": 9,
- "version": "5",
- "when": 1768139432782,
- "tag": "0009_easy_ulik",
- "breakpoints": true
- },
- {
- "idx": 10,
- "version": "5",
- "when": 1738505600000,
- "tag": "0010_video_uploads_bigint",
- "breakpoints": true
- },
- {
- "idx": 11,
- "version": "5",
- "when": 1771325011010,
- "tag": "0011_public_inhumans",
- "breakpoints": true
- },
- {
- "idx": 12,
- "version": "5",
- "when": 1772534950328,
- "tag": "0012_lethal_lilith",
- "breakpoints": true
- },
- {
- "idx": 13,
- "version": "5",
- "when": 1772573402457,
- "tag": "0013_faithful_marvex",
- "breakpoints": true
- },
- {
- "idx": 14,
- "version": "5",
- "when": 1772576220593,
- "tag": "0014_modern_triton",
- "breakpoints": true
- },
- {
- "idx": 15,
- "version": "5",
- "when": 1772582468257,
- "tag": "0015_closed_tigra",
- "breakpoints": true
- },
- {
- "idx": 16,
- "version": "5",
- "when": 1772641549840,
- "tag": "0016_bouncy_hellcat",
- "breakpoints": true
- },
- {
- "idx": 17,
- "version": "5",
- "when": 1778099532336,
- "tag": "0017_productive_betty_brant",
- "breakpoints": true
- },
- {
- "idx": 18,
- "version": "5",
- "when": 1778153157657,
- "tag": "0018_loud_mongu",
- "breakpoints": true
- },
- {
- "idx": 19,
- "version": "5",
- "when": 1778154209547,
- "tag": "0019_solid_paibok",
- "breakpoints": true
- },
- {
- "idx": 20,
- "version": "5",
- "when": 1778157016497,
- "tag": "0020_orange_talkback",
- "breakpoints": true
- },
- {
- "idx": 21,
- "version": "5",
- "when": 1778249922872,
- "tag": "0021_glorious_khan",
- "breakpoints": true
- },
- {
- "idx": 22,
- "version": "5",
- "when": 1778252207674,
- "tag": "0022_dazzling_namor",
- "breakpoints": true
- },
- {
- "idx": 23,
- "version": "5",
- "when": 1778434170430,
- "tag": "0023_misty_luckman",
- "breakpoints": true
- },
- {
- "idx": 24,
- "version": "5",
- "when": 1778776694053,
- "tag": "0024_many_speed",
- "breakpoints": true
- },
- {
- "idx": 25,
- "version": "5",
- "when": 1778858762935,
- "tag": "0025_demonic_mother_askani",
- "breakpoints": true
- },
- {
- "idx": 26,
- "version": "5",
- "when": 1779283712737,
- "tag": "0026_pretty_the_professor",
- "breakpoints": true
- },
- {
- "idx": 27,
- "version": "5",
- "when": 1780932760351,
- "tag": "0027_giant_shinko_yamashiro",
- "breakpoints": true
- },
- {
- "idx": 28,
- "version": "5",
- "when": 1780937790068,
- "tag": "0028_nebulous_madame_masque",
- "breakpoints": true
- },
- {
- "idx": 29,
- "version": "5",
- "when": 1780937848351,
- "tag": "0029_blushing_pretty_boy",
- "breakpoints": true
- },
- {
- "idx": 30,
- "version": "5",
- "when": 1780940100362,
- "tag": "0030_add_folder_settings",
- "breakpoints": true
- },
- {
- "idx": 31,
- "version": "5",
- "when": 1781271269353,
- "tag": "0031_add_auth_api_keys_user_created_index",
- "breakpoints": true
- },
- {
- "idx": 32,
- "version": "5",
- "when": 1782850635482,
- "tag": "0032_past_lester",
- "breakpoints": true
- },
- {
- "idx": 33,
- "version": "5",
- "when": 1782853232527,
- "tag": "0033_fluffy_gamora",
- "breakpoints": true
- },
- {
- "idx": 34,
- "version": "5",
- "when": 1782854064505,
- "tag": "0034_fluffy_falcon",
- "breakpoints": true
- },
- {
- "idx": 35,
- "version": "5",
- "when": 1784393810560,
- "tag": "0035_agent_api",
- "breakpoints": true
- },
- {
- "idx": 36,
- "version": "5",
- "when": 1784402487715,
- "tag": "0036_premium_master_chief",
- "breakpoints": true
- },
- {
- "idx": 37,
- "version": "5",
- "when": 1785452585612,
- "tag": "0037_integration_installations",
- "breakpoints": true
- },
- {
- "idx": 38,
- "version": "5",
- "when": 1785892430217,
- "tag": "0038_mean_ozymandias",
- "breakpoints": true
- },
- {
- "idx": 39,
- "version": "5",
- "when": 1787096888361,
- "tag": "0039_magical_vermin",
- "breakpoints": true
- },
- {
- "idx": 40,
- "version": "5",
- "when": 1788366318415,
- "tag": "0040_recording_jobs",
- "breakpoints": true
- },
- {
- "idx": 41,
- "version": "5",
- "when": 1788384021541,
- "tag": "0041_saml_sso",
- "breakpoints": true
- },
- {
- "idx": 42,
- "version": "5",
- "when": 1788437051131,
- "tag": "0042_perf_indexes",
- "breakpoints": true
- },
- {
- "idx": 43,
- "version": "5",
- "when": 1788695529193,
- "tag": "0043_flimsy_caretaker",
- "breakpoints": true
- },
- {
- "idx": 44,
- "version": "5",
- "when": 1789135858521,
- "tag": "0044_awesome_sugar_man",
- "breakpoints": true
- },
- {
- "idx": 45,
- "version": "5",
- "when": 1789136779085,
- "tag": "0045_steep_forge",
- "breakpoints": true
- },
- {
- "idx": 46,
- "version": "5",
- "when": 1790071320615,
- "tag": "0046_mcp_oauth",
- "breakpoints": true
- },
- {
- "idx": 47,
- "version": "5",
- "when": 1790073320452,
- "tag": "0047_mcp_client_lifecycle",
- "breakpoints": true
- },
- {
- "idx": 48,
- "version": "5",
- "when": 1790073970527,
- "tag": "0048_mcp_registration_quota",
- "breakpoints": true
- },
- {
- "idx": 49,
- "version": "5",
- "when": 1790203121737,
- "tag": "0049_jittery_professor_monster",
- "breakpoints": true
- }
- ]
-}
+ "version": "5",
+ "dialect": "mysql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1743020179593,
+ "tag": "0000_brown_sunfire",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "5",
+ "when": 1749268354138,
+ "tag": "0001_white_young_avengers",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "5",
+ "when": 1750935538683,
+ "tag": "0002_dusty_maginty",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "5",
+ "when": 1761710697286,
+ "tag": "0003_thin_gressill",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "5",
+ "when": 1761711378574,
+ "tag": "0004_video-org-id",
+ "breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "5",
+ "when": 1761711605408,
+ "tag": "0005_video-org-id-required",
+ "breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "5",
+ "when": 1762410865419,
+ "tag": "0006_hesitant_stone_men",
+ "breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "5",
+ "when": 1762428551824,
+ "tag": "0007_public_toxin",
+ "breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "5",
+ "when": 1762428905323,
+ "tag": "0008_fat_ender_wiggin",
+ "breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "5",
+ "when": 1768139432782,
+ "tag": "0009_easy_ulik",
+ "breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "5",
+ "when": 1738505600000,
+ "tag": "0010_video_uploads_bigint",
+ "breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "5",
+ "when": 1771325011010,
+ "tag": "0011_public_inhumans",
+ "breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "5",
+ "when": 1772534950328,
+ "tag": "0012_lethal_lilith",
+ "breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "5",
+ "when": 1772573402457,
+ "tag": "0013_faithful_marvex",
+ "breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "5",
+ "when": 1772576220593,
+ "tag": "0014_modern_triton",
+ "breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "5",
+ "when": 1772582468257,
+ "tag": "0015_closed_tigra",
+ "breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "5",
+ "when": 1772641549840,
+ "tag": "0016_bouncy_hellcat",
+ "breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "5",
+ "when": 1778099532336,
+ "tag": "0017_productive_betty_brant",
+ "breakpoints": true
+ },
+ {
+ "idx": 18,
+ "version": "5",
+ "when": 1778153157657,
+ "tag": "0018_loud_mongu",
+ "breakpoints": true
+ },
+ {
+ "idx": 19,
+ "version": "5",
+ "when": 1778154209547,
+ "tag": "0019_solid_paibok",
+ "breakpoints": true
+ },
+ {
+ "idx": 20,
+ "version": "5",
+ "when": 1778157016497,
+ "tag": "0020_orange_talkback",
+ "breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "5",
+ "when": 1778249922872,
+ "tag": "0021_glorious_khan",
+ "breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "5",
+ "when": 1778252207674,
+ "tag": "0022_dazzling_namor",
+ "breakpoints": true
+ },
+ {
+ "idx": 23,
+ "version": "5",
+ "when": 1778434170430,
+ "tag": "0023_misty_luckman",
+ "breakpoints": true
+ },
+ {
+ "idx": 24,
+ "version": "5",
+ "when": 1778776694053,
+ "tag": "0024_many_speed",
+ "breakpoints": true
+ },
+ {
+ "idx": 25,
+ "version": "5",
+ "when": 1778858762935,
+ "tag": "0025_demonic_mother_askani",
+ "breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "5",
+ "when": 1779283712737,
+ "tag": "0026_pretty_the_professor",
+ "breakpoints": true
+ },
+ {
+ "idx": 27,
+ "version": "5",
+ "when": 1780932760351,
+ "tag": "0027_giant_shinko_yamashiro",
+ "breakpoints": true
+ },
+ {
+ "idx": 28,
+ "version": "5",
+ "when": 1780937790068,
+ "tag": "0028_nebulous_madame_masque",
+ "breakpoints": true
+ },
+ {
+ "idx": 29,
+ "version": "5",
+ "when": 1780937848351,
+ "tag": "0029_blushing_pretty_boy",
+ "breakpoints": true
+ },
+ {
+ "idx": 30,
+ "version": "5",
+ "when": 1780940100362,
+ "tag": "0030_add_folder_settings",
+ "breakpoints": true
+ },
+ {
+ "idx": 31,
+ "version": "5",
+ "when": 1781271269353,
+ "tag": "0031_add_auth_api_keys_user_created_index",
+ "breakpoints": true
+ },
+ {
+ "idx": 32,
+ "version": "5",
+ "when": 1782850635482,
+ "tag": "0032_past_lester",
+ "breakpoints": true
+ },
+ {
+ "idx": 33,
+ "version": "5",
+ "when": 1782853232527,
+ "tag": "0033_fluffy_gamora",
+ "breakpoints": true
+ },
+ {
+ "idx": 34,
+ "version": "5",
+ "when": 1782854064505,
+ "tag": "0034_fluffy_falcon",
+ "breakpoints": true
+ },
+ {
+ "idx": 35,
+ "version": "5",
+ "when": 1784393810560,
+ "tag": "0035_agent_api",
+ "breakpoints": true
+ },
+ {
+ "idx": 36,
+ "version": "5",
+ "when": 1784402487715,
+ "tag": "0036_premium_master_chief",
+ "breakpoints": true
+ },
+ {
+ "idx": 37,
+ "version": "5",
+ "when": 1785452585612,
+ "tag": "0037_integration_installations",
+ "breakpoints": true
+ },
+ {
+ "idx": 38,
+ "version": "5",
+ "when": 1785892430217,
+ "tag": "0038_mean_ozymandias",
+ "breakpoints": true
+ },
+ {
+ "idx": 39,
+ "version": "5",
+ "when": 1787096888361,
+ "tag": "0039_magical_vermin",
+ "breakpoints": true
+ },
+ {
+ "idx": 40,
+ "version": "5",
+ "when": 1788366318415,
+ "tag": "0040_recording_jobs",
+ "breakpoints": true
+ },
+ {
+ "idx": 41,
+ "version": "5",
+ "when": 1788384021541,
+ "tag": "0041_saml_sso",
+ "breakpoints": true
+ },
+ {
+ "idx": 42,
+ "version": "5",
+ "when": 1788437051131,
+ "tag": "0042_perf_indexes",
+ "breakpoints": true
+ },
+ {
+ "idx": 43,
+ "version": "5",
+ "when": 1788695529193,
+ "tag": "0043_flimsy_caretaker",
+ "breakpoints": true
+ },
+ {
+ "idx": 44,
+ "version": "5",
+ "when": 1789135858521,
+ "tag": "0044_awesome_sugar_man",
+ "breakpoints": true
+ },
+ {
+ "idx": 45,
+ "version": "5",
+ "when": 1789136779085,
+ "tag": "0045_steep_forge",
+ "breakpoints": true
+ },
+ {
+ "idx": 46,
+ "version": "5",
+ "when": 1790071320615,
+ "tag": "0046_mcp_oauth",
+ "breakpoints": true
+ },
+ {
+ "idx": 47,
+ "version": "5",
+ "when": 1790073320452,
+ "tag": "0047_mcp_client_lifecycle",
+ "breakpoints": true
+ },
+ {
+ "idx": 48,
+ "version": "5",
+ "when": 1790073970527,
+ "tag": "0048_mcp_registration_quota",
+ "breakpoints": true
+ },
+ {
+ "idx": 49,
+ "version": "5",
+ "when": 1790203121737,
+ "tag": "0049_jittery_professor_monster",
+ "breakpoints": true
+ },
+ {
+ "idx": 50,
+ "version": "5",
+ "when": 1791345311370,
+ "tag": "0050_productive_christian_walker",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index 8c19b1fd20f..736e66c6518 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1652,6 +1652,88 @@ export const importedVideos = mysqlTable(
],
);
+export type LoomImportJobStatus =
+ | "checking"
+ | "awaiting_upgrade"
+ | "importing"
+ | "completed"
+ | "cancelled";
+
+export type LoomImportItemStatus =
+ | "pending"
+ | "ready"
+ | "importing"
+ | "complete"
+ | "failed"
+ | "skipped"
+ | "cancelled";
+
+export const loomImportJobs = mysqlTable(
+ "loom_import_jobs",
+ {
+ id: nanoId("id").notNull().primaryKey(),
+ orgId: nanoIdRequired("org_id").$type(),
+ createdById: nanoIdRequired("created_by_id").$type(),
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ status: varchar("status", { length: 32 })
+ .$type()
+ .notNull()
+ .default("checking"),
+ totalCount: int("total_count").notNull(),
+ createdAt: datetime("created_at", { fsp: 3 })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: datetime("updated_at", { fsp: 3 })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ startedAt: datetime("started_at", { fsp: 3 }),
+ completedAt: datetime("completed_at", { fsp: 3 }),
+ },
+ (table) => [
+ index("org_creator_created_idx").on(
+ table.orgId,
+ table.createdById,
+ table.createdAt,
+ ),
+ index("status_updated_idx").on(table.status, table.updatedAt),
+ ],
+);
+
+export const loomImportJobItems = mysqlTable(
+ "loom_import_job_items",
+ {
+ id: nanoId("id").notNull().primaryKey(),
+ jobId: nanoIdRequired("job_id"),
+ rowNumber: int("csv_row").notNull(),
+ loomUrl: varchar("loom_url", { length: 1024 }).notNull(),
+ loomVideoId: varchar("loom_video_id", { length: 64 }),
+ ownerEmail: varchar("owner_email", { length: 255 }),
+ spaceName: varchar("space_name", { length: 255 }),
+ status: varchar("status", { length: 16 })
+ .$type()
+ .notNull()
+ .default("pending"),
+ ownerId: nanoIdNullable("owner_id").$type(),
+ spaceId: nanoIdNullable("space_id").$type(),
+ videoId: nanoIdNullable("video_id").$type(),
+ title: varchar("title", { length: 255 }),
+ loomCreatedAt: datetime("loom_created_at", { fsp: 3 }),
+ durationSeconds: float("duration_seconds"),
+ width: int("width"),
+ height: int("height"),
+ thumbnailUrl: varchar("thumbnail_url", { length: 1024 }),
+ error: varchar("error", { length: 512 }),
+ updatedAt: datetime("updated_at", { fsp: 3 })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ },
+ (table) => [
+ uniqueIndex("job_row_idx").on(table.jobId, table.rowNumber),
+ index("job_status_row_idx").on(table.jobId, table.status, table.rowNumber),
+ index("video_id_idx").on(table.videoId),
+ ],
+);
+
export const developerApps = mysqlTable(
"developer_apps",
{
From 591137b8289b7bd5c16083ccbc46a285afeb6d22 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 04:49:07 +0000
Subject: [PATCH 02/39] feat: run Loom CSV imports as durable server-side jobs
Imports are created in one request (up to 2,000 rows), checked against
Loom with aliased GraphQL batches, then dispatched a few videos at a time
from a durable workflow. Each finished video refills the window, so the
browser no longer drives batches and the tab can be closed.
Imported Caps keep their Loom title, length, size and original recording
date, and the upload row now records the raw file key so retries and
stalled-pipeline recovery work for Loom imports.
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 589 +++++++++++
.../__tests__/unit/loom-import-csv.test.ts | 228 +++++
apps/web/__tests__/unit/loom-import.test.ts | 450 ---------
.../unit/media-processing-workflows.test.ts | 20 +
apps/web/actions/loom-import.ts | 101 ++
apps/web/actions/loom.ts | 457 +--------
.../recover-failed-video-processing/route.ts | 23 +-
apps/web/app/api/import/loom/jobs/route.ts | 133 +++
apps/web/lib/loom-import/csv.ts | 462 +++++++++
apps/web/lib/loom-import/dispatch.ts | 496 +++++++++
apps/web/lib/loom-import/jobs.ts | 954 ++++++++++++++++++
apps/web/lib/loom-import/loom-api.ts | 189 ++++
apps/web/lib/loom-import/recovery.ts | 110 ++
apps/web/lib/loom-import/report.ts | 47 +
apps/web/lib/loom-import/snapshot.ts | 161 +++
apps/web/lib/loom-import/status.ts | 219 ++++
apps/web/workflows/import-loom-video.ts | 16 +
apps/web/workflows/loom-import-job.ts | 41 +
packages/env/server.ts | 6 +
19 files changed, 3815 insertions(+), 887 deletions(-)
create mode 100644 apps/web/__tests__/integration/loom-import-jobs.test.ts
create mode 100644 apps/web/__tests__/unit/loom-import-csv.test.ts
create mode 100644 apps/web/actions/loom-import.ts
create mode 100644 apps/web/app/api/import/loom/jobs/route.ts
create mode 100644 apps/web/lib/loom-import/csv.ts
create mode 100644 apps/web/lib/loom-import/dispatch.ts
create mode 100644 apps/web/lib/loom-import/jobs.ts
create mode 100644 apps/web/lib/loom-import/loom-api.ts
create mode 100644 apps/web/lib/loom-import/recovery.ts
create mode 100644 apps/web/lib/loom-import/report.ts
create mode 100644 apps/web/lib/loom-import/snapshot.ts
create mode 100644 apps/web/lib/loom-import/status.ts
create mode 100644 apps/web/workflows/loom-import-job.ts
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
new file mode 100644
index 00000000000..f950919c4d0
--- /dev/null
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -0,0 +1,589 @@
+import { randomUUID } from "node:crypto";
+import {
+ importedVideos,
+ loomImportJobItems,
+ loomImportJobs,
+ organizationMembers,
+ organizations,
+ spaceMembers,
+ spaces,
+ spaceVideos,
+ users,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import { Organisation, User, type Video } from "@cap/web-domain";
+import { and, asc, eq } from "drizzle-orm";
+import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
+import { Effect, Option } from "effect";
+import { createPool, type Pool } from "mysql2/promise";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+const fixture = vi.hoisted(() => ({
+ database: undefined as MySql2Database | undefined,
+ start: vi.fn(),
+ concurrency: "2",
+}));
+
+vi.mock("server-only", () => ({}));
+vi.mock("@cap/database", () => ({
+ db: () => {
+ if (!fixture.database) throw new Error("Test database is not connected.");
+ return fixture.database;
+ },
+}));
+vi.mock("@cap/env", () => ({
+ buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
+ NODE_ENV: "test",
+ serverEnv: () => ({
+ CAP_VIDEOS_DEFAULT_PUBLIC: true,
+ LOOM_IMPORT_CONCURRENCY: fixture.concurrency,
+ WEB_URL: "https://cap.test",
+ }),
+}));
+vi.mock("@cap/database/loops/queue", () => ({
+ enqueueLoopsSync: vi.fn(async () => undefined),
+}));
+vi.mock("@cap/web-backend/src/Storage/index", () => ({
+ Storage: {
+ getWritableAccessForUser: () =>
+ Effect.succeed({
+ bucketId: Option.none(),
+ storageIntegrationId: Option.none(),
+ }),
+ },
+}));
+vi.mock("@/lib/workflow-runtime", () => ({
+ runWorkflowPromise: (effect: Effect.Effect ) =>
+ Effect.runPromise(effect),
+}));
+vi.mock("workflow/api", () => ({ start: fixture.start }));
+vi.mock("@/workflows/import-loom-video", () => ({
+ importLoomVideoWorkflow: "importLoomVideoWorkflow",
+}));
+vi.mock("@/workflows/loom-import-job", () => ({
+ loomImportJobWorkflow: "loomImportJobWorkflow",
+}));
+
+import {
+ dispatchLoomImportForVideo,
+ dispatchLoomImportJob,
+} from "@/lib/loom-import/dispatch";
+import {
+ cancelLoomImportJob,
+ createLoomImportJob,
+ listLoomImportJobs,
+ markLoomImportJobStarting,
+ prepareLoomImportJob,
+ resetFailedLoomImportItems,
+ resolveLoomImportJob,
+} from "@/lib/loom-import/jobs";
+import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot";
+
+const databaseUrl = process.env.CAP_LOOM_IMPORT_TEST_DATABASE_URL;
+
+const LOOM = {
+ ok1: "0dd0a01e10c742b28dbea75082c08635",
+ ok2: "31f430c1a1e744b8a7b6c18a26982c71",
+ ok3: "2bff5385f32643ea9d065227ebfadd0d",
+ ok4: "41f6aad19fb74048a8e9dbf3105b5a6a",
+ private: "43e16ce2319c4171ba9d09f7999eca16",
+ missing: "ffffffffffffffffffffffffffffffff",
+};
+
+const RECORDED = {
+ [LOOM.ok1]: "2022-02-15T20:02:03.132Z",
+ [LOOM.ok2]: "2022-06-22T19:17:16.020Z",
+ [LOOM.ok3]: "2022-06-22T19:17:34.669Z",
+ [LOOM.ok4]: "2023-08-18T20:00:46.121Z",
+};
+
+function database() {
+ if (!fixture.database) throw new Error("Test database is not connected.");
+ return fixture.database;
+}
+
+function id() {
+ return randomUUID().replaceAll("-", "").slice(0, 15);
+}
+
+function share(loomId: string) {
+ return `https://www.loom.com/share/${loomId}`;
+}
+
+const loomFetch = vi.fn(
+ async (_url: string | URL | Request, init?: RequestInit) => {
+ const body = JSON.parse(String(init?.body)) as {
+ variables: Record;
+ };
+ const data: Record = {};
+ for (const [key, loomId] of Object.entries(body.variables)) {
+ const alias = key.replace("id", "v");
+ if (loomId === LOOM.private) {
+ data[alias] = { __typename: "PrivateVideo", id: loomId };
+ } else if (RECORDED[loomId]) {
+ data[alias] = {
+ __typename: "RegularUserVideo",
+ name: `Video ${loomId.slice(0, 4)}`,
+ createdAt: RECORDED[loomId],
+ thumbnails: {
+ default: `https://cdn.loom.com/sessions/thumbnails/${loomId}-00001.jpg`,
+ },
+ video_properties: { duration: 120.5, width: 1920, height: 1080 },
+ };
+ } else {
+ data[alias] = null;
+ }
+ }
+ return Response.json({ data });
+ },
+) as unknown as typeof fetch;
+
+async function makeOrganization({ pro }: { pro: boolean }) {
+ const ownerId = User.UserId.make(id());
+ const orgId = Organisation.OrganisationId.make(id());
+ await database()
+ .insert(users)
+ .values({
+ id: ownerId,
+ email: `${ownerId}@example.com`,
+ name: "Owner",
+ activeOrganizationId: orgId,
+ defaultOrgId: orgId,
+ stripeSubscriptionStatus: pro ? "active" : null,
+ });
+ await database().insert(organizations).values({
+ id: orgId,
+ name: "Acme",
+ ownerId,
+ });
+ await database().insert(organizationMembers).values({
+ id: id(),
+ organizationId: orgId,
+ userId: ownerId,
+ role: "owner",
+ });
+ return { ownerId, orgId };
+}
+
+async function items(jobId: string) {
+ return database()
+ .select()
+ .from(loomImportJobItems)
+ .where(eq(loomImportJobItems.jobId, jobId))
+ .orderBy(asc(loomImportJobItems.rowNumber));
+}
+
+async function jobStatus(jobId: string) {
+ const [job] = await database()
+ .select({ status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId));
+ return job?.status;
+}
+
+async function finishVideo(videoId: Video.VideoId) {
+ await database()
+ .delete(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ return dispatchLoomImportForVideo(videoId);
+}
+
+async function failVideo(videoId: Video.VideoId, error: string) {
+ await database()
+ .update(videoUploads)
+ .set({ phase: "error", processingError: error })
+ .where(eq(videoUploads.videoId, videoId));
+ return dispatchLoomImportForVideo(videoId);
+}
+
+describe.runIf(Boolean(databaseUrl))(
+ "Loom CSV imports with an isolated MySQL database",
+ () => {
+ let pool: Pool | undefined;
+
+ beforeAll(async () => {
+ if (!databaseUrl) throw new Error("Missing isolated test database URL.");
+ const url = new URL(databaseUrl);
+ if (
+ url.protocol !== "mysql:" ||
+ !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) ||
+ !/^\/cap_loom_import_[a-z0-9_]+$/.test(url.pathname)
+ ) {
+ throw new Error("Loom import tests require a local test database.");
+ }
+ pool = createPool(databaseUrl);
+ fixture.database = drizzle(pool);
+ await database().select().from(loomImportJobs).limit(1);
+ });
+
+ afterAll(async () => {
+ await pool?.end();
+ fixture.database = undefined;
+ });
+
+ beforeEach(() => {
+ fixture.start.mockReset().mockResolvedValue({ runId: "run" });
+ fixture.concurrency = "2";
+ loomFetch.mockClear();
+ });
+
+ it("checks every link, waits for Pro on the free plan, then imports with original dates in a bounded window", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: false });
+ const teammate = `teammate-${id()}@example.com`;
+ const { jobId, totalCount } = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "loom-export.csv",
+ rows: [
+ {
+ rowNumber: 2,
+ loomUrl: share(LOOM.ok1),
+ ownerEmail: teammate,
+ spaceName: "Sales",
+ },
+ {
+ rowNumber: 3,
+ loomUrl: `https://www.loom.com/share/My-Demo-${LOOM.ok2}?sid=1`,
+ },
+ { rowNumber: 4, loomUrl: share(LOOM.ok3), spaceName: "sales" },
+ { rowNumber: 5, loomUrl: share(LOOM.private) },
+ { rowNumber: 6, loomUrl: share(LOOM.missing) },
+ { rowNumber: 7, loomUrl: "https://example.com/not-loom" },
+ { rowNumber: 8, loomUrl: share(LOOM.ok1) },
+ { rowNumber: 9, loomUrl: share(LOOM.ok4) },
+ ],
+ });
+ expect(totalCount).toBe(8);
+
+ await resolveLoomImportJob(jobId, { fetchImpl: loomFetch });
+ expect(loomFetch).toHaveBeenCalledTimes(1);
+ const resolved = await items(jobId);
+ expect(resolved.map((item) => [item.rowNumber, item.status])).toEqual([
+ [2, "ready"],
+ [3, "ready"],
+ [4, "ready"],
+ [5, "failed"],
+ [6, "failed"],
+ [7, "failed"],
+ [8, "skipped"],
+ [9, "ready"],
+ ]);
+ expect(resolved[0]).toMatchObject({
+ title: `Video ${LOOM.ok1.slice(0, 4)}`,
+ durationSeconds: 120.5,
+ width: 1920,
+ thumbnailUrl: `https://cdn.loom.com/sessions/thumbnails/${LOOM.ok1}-00001.jpg`,
+ });
+ expect(resolved[0]?.loomCreatedAt?.toISOString()).toBe(
+ RECORDED[LOOM.ok1],
+ );
+ expect(resolved[1]?.loomVideoId).toBe(LOOM.ok2);
+ expect(resolved[3]?.error).toContain("private");
+ expect(resolved[6]?.error).toBe("Same video as row 2.");
+
+ expect(await prepareLoomImportJob(jobId)).toBe("awaiting_upgrade");
+ const waiting = await getLoomImportSnapshot({ jobId, userId: ownerId });
+ expect(waiting?.job).toMatchObject({
+ status: "awaiting_upgrade",
+ canStart: false,
+ isPro: false,
+ });
+ expect(waiting?.counts).toMatchObject({
+ ready: 4,
+ failed: 3,
+ skipped: 1,
+ });
+ expect(waiting?.totalDuration).toBeCloseTo(482);
+ expect(fixture.start).not.toHaveBeenCalled();
+ expect(
+ await database().select().from(videos).where(eq(videos.orgId, orgId)),
+ ).toEqual([]);
+
+ await database()
+ .update(users)
+ .set({ stripeSubscriptionStatus: "active" })
+ .where(eq(users.id, ownerId));
+ expect(
+ (await getLoomImportSnapshot({ jobId, userId: ownerId }))?.job.canStart,
+ ).toBe(true);
+ expect(await markLoomImportJobStarting(jobId)).toBe(true);
+ expect(await markLoomImportJobStarting(jobId)).toBe(false);
+ await resolveLoomImportJob(jobId, { fetchImpl: loomFetch });
+ expect(await prepareLoomImportJob(jobId)).toBe("importing");
+
+ const [teammateUser] = await database()
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, teammate));
+ expect(teammateUser).toBeDefined();
+ const [salesSpace, ...otherSpaces] = await database()
+ .select({ id: spaces.id, name: spaces.name })
+ .from(spaces)
+ .where(eq(spaces.organizationId, orgId));
+ expect(otherSpaces).toEqual([]);
+ expect(salesSpace?.name).toBe("Sales");
+ const members = await database()
+ .select({ userId: spaceMembers.userId, role: spaceMembers.role })
+ .from(spaceMembers)
+ .where(eq(spaceMembers.spaceId, salesSpace?.id ?? ""));
+ expect(members).toEqual(
+ expect.arrayContaining([
+ { userId: ownerId, role: "admin" },
+ { userId: teammateUser?.id, role: "member" },
+ ]),
+ );
+
+ expect(await dispatchLoomImportJob(jobId)).toEqual({
+ started: 2,
+ completed: false,
+ });
+ let state = await items(jobId);
+ expect(
+ state
+ .filter((item) => item.status === "importing")
+ .map((item) => item.rowNumber),
+ ).toEqual([2, 3]);
+ expect(fixture.start).toHaveBeenCalledTimes(2);
+ const first = state[0];
+ const firstVideoId = first?.videoId as Video.VideoId;
+ const [firstVideo] = await database()
+ .select()
+ .from(videos)
+ .where(eq(videos.id, firstVideoId));
+ expect(firstVideo).toMatchObject({
+ ownerId: teammateUser?.id,
+ orgId,
+ name: `Video ${LOOM.ok1.slice(0, 4)}`,
+ duration: 120.5,
+ transcriptionStatus: null,
+ });
+ expect(firstVideo?.metadata).toEqual({
+ customCreatedAt: RECORDED[LOOM.ok1],
+ });
+ expect(firstVideo?.effectiveCreatedAt?.toISOString().slice(0, 19)).toBe(
+ RECORDED[LOOM.ok1]?.slice(0, 19),
+ );
+ const [upload] = await database()
+ .select()
+ .from(videoUploads)
+ .where(eq(videoUploads.videoId, firstVideoId));
+ expect(upload?.rawFileKey).toBe(
+ `${teammateUser?.id}/${firstVideoId}/raw-upload.mp4`,
+ );
+ expect(
+ await database()
+ .select()
+ .from(spaceVideos)
+ .where(eq(spaceVideos.videoId, firstVideoId)),
+ ).toHaveLength(1);
+ expect(fixture.start.mock.calls[0]?.[1]?.[0]).toMatchObject({
+ videoId: firstVideoId,
+ userId: teammateUser?.id,
+ loomVideoId: LOOM.ok1,
+ });
+
+ expect(await dispatchLoomImportJob(jobId)).toEqual({
+ started: 0,
+ completed: false,
+ });
+
+ await finishVideo(firstVideoId);
+ state = await items(jobId);
+ expect(state[0]?.status).toBe("complete");
+ expect(
+ state
+ .filter((item) => item.status === "importing")
+ .map((item) => item.rowNumber),
+ ).toEqual([3, 4]);
+
+ await failVideo(
+ state[1]?.videoId as Video.VideoId,
+ "Media server unavailable",
+ );
+ state = await items(jobId);
+ expect(state[1]).toMatchObject({
+ status: "failed",
+ error: "Media server unavailable",
+ });
+ expect(state[7]?.status).toBe("importing");
+
+ const live = await getLoomImportSnapshot({ jobId, userId: ownerId });
+ expect(live?.counts).toMatchObject({
+ imported: 1,
+ importing: 2,
+ failed: 4,
+ skipped: 1,
+ });
+ const earlier = new Date(Date.now() - 60_000);
+ await database()
+ .update(loomImportJobItems)
+ .set({ updatedAt: earlier })
+ .where(eq(loomImportJobItems.jobId, jobId));
+ for (const item of state) {
+ if (!item.videoId) continue;
+ await database()
+ .update(videos)
+ .set({ updatedAt: earlier })
+ .where(eq(videos.id, item.videoId));
+ await database()
+ .update(videoUploads)
+ .set({ updatedAt: earlier })
+ .where(eq(videoUploads.videoId, item.videoId));
+ }
+ const settledView = await getLoomImportSnapshot({
+ jobId,
+ userId: ownerId,
+ });
+ expect(settledView?.cursor).toBeLessThan(Date.now() - 50_000);
+ const lastPoll = Date.now() - 1_000;
+ await database()
+ .update(videoUploads)
+ .set({ processingProgress: 40, updatedAt: new Date() })
+ .where(eq(videoUploads.videoId, state[7]?.videoId as Video.VideoId));
+ const delta = await getLoomImportSnapshot({
+ jobId,
+ userId: ownerId,
+ since: lastPoll + 5_000,
+ });
+ expect(delta?.full).toBe(false);
+ expect(
+ delta?.items.map((item) => [item.row, item.status, item.progress]),
+ ).toEqual([[9, "importing", 40]]);
+ expect(delta?.counts.total).toBe(8);
+
+ await finishVideo(state[2]?.videoId as Video.VideoId);
+ await finishVideo(state[7]?.videoId as Video.VideoId);
+ expect(await jobStatus(jobId)).toBe("completed");
+
+ const retried = await resetFailedLoomImportItems(jobId);
+ expect(retried).toBe(3);
+ expect(await jobStatus(jobId)).toBe("checking");
+ state = await items(jobId);
+ expect(state[1]).toMatchObject({ status: "ready", error: null });
+ expect(state[4]?.status).toBe("pending");
+ await resolveLoomImportJob(jobId, { fetchImpl: loomFetch });
+ expect(await prepareLoomImportJob(jobId)).toBe("importing");
+ fixture.start.mockClear();
+ expect((await dispatchLoomImportJob(jobId)).started).toBe(1);
+ expect(fixture.start.mock.calls[0]?.[1]?.[0]).toMatchObject({
+ videoId: state[1]?.videoId,
+ reuseExistingRawUpload: true,
+ });
+
+ const [summary] = await listLoomImportJobs({ userId: ownerId, orgId });
+ expect(summary).toMatchObject({ id: jobId, imported: 3, totalCount: 8 });
+ });
+
+ it("skips videos already in Cap and stops queued work when cancelled", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: true });
+ fixture.concurrency = "1";
+ const first = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "first.csv",
+ rows: [{ rowNumber: 2, loomUrl: share(LOOM.ok1) }],
+ });
+ await resolveLoomImportJob(first.jobId, { fetchImpl: loomFetch });
+ await prepareLoomImportJob(first.jobId);
+ await dispatchLoomImportJob(first.jobId);
+
+ const second = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "second.csv",
+ rows: [
+ { rowNumber: 2, loomUrl: share(LOOM.ok1) },
+ { rowNumber: 3, loomUrl: share(LOOM.ok2) },
+ { rowNumber: 4, loomUrl: share(LOOM.ok3) },
+ ],
+ });
+ await resolveLoomImportJob(second.jobId, { fetchImpl: loomFetch });
+ const [duplicate] = await items(second.jobId);
+ const [existing] = await database()
+ .select({ id: importedVideos.id })
+ .from(importedVideos)
+ .where(
+ and(
+ eq(importedVideos.orgId, orgId),
+ eq(importedVideos.sourceId, LOOM.ok1),
+ ),
+ );
+ expect(duplicate).toMatchObject({
+ status: "skipped",
+ error: "Already in Cap.",
+ videoId: existing?.id,
+ });
+
+ await prepareLoomImportJob(second.jobId);
+ await dispatchLoomImportJob(second.jobId);
+ await cancelLoomImportJob(second.jobId);
+ const state = await items(second.jobId);
+ expect(state.map((item) => item.status)).toEqual([
+ "skipped",
+ "importing",
+ "cancelled",
+ ]);
+ expect(await jobStatus(second.jobId)).toBe("cancelled");
+ expect(await dispatchLoomImportJob(second.jobId)).toEqual({
+ started: 0,
+ completed: false,
+ });
+ });
+
+ it("rejects files over the 2,000 video limit without creating anything", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: true });
+ const rows = Array.from({ length: 2001 }, (_, index) => ({
+ rowNumber: index + 2,
+ loomUrl: share(`a${index.toString(16).padStart(31, "0")}`),
+ }));
+ await expect(
+ createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "big.csv",
+ rows,
+ }),
+ ).rejects.toThrow("Split your list");
+ expect(
+ await database()
+ .select()
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.orgId, orgId)),
+ ).toEqual([]);
+ });
+
+ it("creates a 2,000 video import in one request", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: true });
+ const rows = Array.from({ length: 2000 }, (_, index) => ({
+ rowNumber: index + 2,
+ loomUrl: share(`b${index.toString(16).padStart(31, "0")}`),
+ }));
+ const startedAt = performance.now();
+ const { jobId, totalCount } = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "big.csv",
+ rows,
+ });
+ const elapsed = performance.now() - startedAt;
+ expect(totalCount).toBe(2000);
+ expect(await items(jobId)).toHaveLength(2000);
+ const snapshotStartedAt = performance.now();
+ const snapshot = await getLoomImportSnapshot({ jobId, userId: ownerId });
+ const snapshotElapsed = performance.now() - snapshotStartedAt;
+ expect(snapshot?.counts.checking).toBe(2000);
+ console.info(
+ `[loom-import benchmark] create 2000 rows: ${elapsed.toFixed(0)}ms, full snapshot: ${snapshotElapsed.toFixed(0)}ms`,
+ );
+ });
+ },
+);
diff --git a/apps/web/__tests__/unit/loom-import-csv.test.ts b/apps/web/__tests__/unit/loom-import-csv.test.ts
new file mode 100644
index 00000000000..5d416237ad0
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-import-csv.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildLoomImportPlan,
+ detectLoomImportMapping,
+ extractLoomVideoId,
+ LOOM_IMPORT_MAX_ROWS,
+ parseCsv,
+ parseCsvRecords,
+ tableFromPastedLinks,
+} from "@/lib/loom-import/csv";
+
+const ID_A = "0dd0a01e10c742b28dbea75082c08635";
+const ID_B = "31f430c1a1e744b8a7b6c18a26982c71";
+
+describe("parseCsvRecords", () => {
+ it("handles quotes, escaped quotes, embedded newlines and CRLF", () => {
+ const text =
+ '\uFEFFtitle,link\r\n"Quarterly ""all hands""",https://www.loom.com/share/a\r\n"Line one\nline two",https://www.loom.com/share/b\r\n';
+ expect(parseCsvRecords(text)).toEqual([
+ ["title", "link"],
+ ['Quarterly "all hands"', "https://www.loom.com/share/a"],
+ ["Line one\nline two", "https://www.loom.com/share/b"],
+ ]);
+ });
+
+ it("keeps blank lines so row numbers match the spreadsheet, and rejects unclosed quotes", () => {
+ expect(parseCsvRecords("a,,c\n\n,,\nd,e,\n\n,,\n")).toEqual([
+ ["a", "", "c"],
+ [""],
+ ["", "", ""],
+ ["d", "e", ""],
+ ]);
+ expect(() => parseCsvRecords('a,"unfinished\n')).toThrow("unclosed");
+ });
+});
+
+describe("parseCsv", () => {
+ it("detects semicolon and tab separated exports", () => {
+ expect(
+ parseCsv("Video URL;Owner\nhttps://loom.com/share/x;a@b.co").rows,
+ ).toEqual([["https://loom.com/share/x", "a@b.co"]]);
+ expect(
+ parseCsv("Video URL\tOwner\nhttps://loom.com/share/x\ta@b.co").delimiter,
+ ).toBe("\t");
+ });
+
+ it("treats a plain list of Loom links as rows instead of a header", () => {
+ const table = parseCsv(
+ `https://www.loom.com/share/${ID_A}\nhttps://www.loom.com/share/${ID_B}\n`,
+ );
+ expect(table.headerless).toBe(true);
+ expect(table.rows).toHaveLength(2);
+ });
+});
+
+describe("extractLoomVideoId", () => {
+ it.each([
+ [`https://www.loom.com/share/${ID_A}`, ID_A],
+ [`https://www.loom.com/share/${ID_A}?sid=123#t=10`, ID_A],
+ [`https://www.loom.com/embed/${ID_A}`, ID_A],
+ [`https://loom.com/share/Weekly-update-for-the-team-${ID_A}`, ID_A],
+ [`www.loom.com/share/${ID_A.toUpperCase()}`, ID_A],
+ [ID_B, ID_B],
+ ])("reads %s", (value, expected) => {
+ expect(extractLoomVideoId(value)).toBe(expected);
+ });
+
+ it.each([
+ "",
+ "https://example.com/share/0dd0a01e10c742b28dbea75082c08635",
+ "https://www.loom.com/",
+ "https://notloom.com/share/0dd0a01e10c742b28dbea75082c08635",
+ "just some text",
+ ])("ignores %s", (value) => {
+ expect(extractLoomVideoId(value)).toBeNull();
+ });
+});
+
+describe("detectLoomImportMapping", () => {
+ it("finds columns by their values when headers are unfamiliar", () => {
+ const table = parseCsv(
+ [
+ "Name,Recorder,Where,Recording",
+ `Kickoff,ana@acme.com,Sales,https://www.loom.com/share/${ID_A}`,
+ `Demo,sam@acme.com,Sales,https://www.loom.com/share/${ID_B}`,
+ ].join("\n"),
+ );
+ expect(detectLoomImportMapping(table)).toEqual({
+ loomUrl: 3,
+ ownerEmail: 1,
+ spaceName: undefined,
+ });
+ });
+
+ it("maps the Cap template and Loom style exports", () => {
+ const template = parseCsv(
+ `loom_video_url,user_email,space_name\nhttps://www.loom.com/share/${ID_A},a@b.co,Sales`,
+ );
+ expect(detectLoomImportMapping(template)).toEqual({
+ loomUrl: 0,
+ ownerEmail: 1,
+ spaceName: 2,
+ });
+ const loomExport = parseCsv(
+ `Video ID,Video Title,Creator Email,Workspace\n${ID_A},Intro,a@b.co,Acme`,
+ );
+ expect(detectLoomImportMapping(loomExport)).toEqual({
+ loomUrl: 0,
+ ownerEmail: 2,
+ spaceName: undefined,
+ });
+ });
+});
+
+describe("buildLoomImportPlan", () => {
+ const table = parseCsv(
+ [
+ "loom_video_url,user_email,space_name",
+ `https://www.loom.com/share/${ID_A},Ana@Acme.com, Sales Team `,
+ "https://example.com/video,ana@acme.com,",
+ `https://www.loom.com/share/${ID_B},not-an-email,`,
+ `https://www.loom.com/share/${ID_A},sam@acme.com,`,
+ ",,",
+ `${ID_B},sam@acme.com,Design`,
+ ].join("\n"),
+ );
+
+ it("explains every row it leaves out and normalizes the rest", () => {
+ const plan = buildLoomImportPlan(table, detectLoomImportMapping(table), {
+ allowOwners: true,
+ });
+ expect(plan.rows).toEqual([
+ {
+ rowNumber: 2,
+ loomUrl: `https://www.loom.com/share/${ID_A}`,
+ ownerEmail: "ana@acme.com",
+ spaceName: "Sales Team",
+ },
+ {
+ rowNumber: 7,
+ loomUrl: `https://www.loom.com/share/${ID_B}`,
+ ownerEmail: "sam@acme.com",
+ spaceName: "Design",
+ },
+ ]);
+ expect(plan.issues.map((issue) => [issue.rowNumber, issue.reason])).toEqual(
+ [
+ [3, "not_loom"],
+ [4, "bad_email"],
+ [5, "duplicate"],
+ ],
+ );
+ expect(plan.issues[2]?.duplicateOf).toBe(2);
+ expect(plan).toMatchObject({ owners: 2, spaces: 2, overLimit: false });
+ });
+
+ it("ignores owners and spaces for people who can only import for themselves", () => {
+ const plan = buildLoomImportPlan(table, detectLoomImportMapping(table), {
+ allowOwners: false,
+ });
+ expect(plan.rows.map((row) => [row.rowNumber, row.ownerEmail])).toEqual([
+ [2, undefined],
+ [4, undefined],
+ ]);
+ expect(plan.issues.map((issue) => [issue.rowNumber, issue.reason])).toEqual(
+ [
+ [3, "not_loom"],
+ [5, "duplicate"],
+ [7, "duplicate"],
+ ],
+ );
+ });
+
+ it("flags files over the per-import limit", () => {
+ const rows = Array.from(
+ { length: LOOM_IMPORT_MAX_ROWS + 1 },
+ (_, index) => [
+ `https://www.loom.com/share/${index.toString(16).padStart(32, "0")}`,
+ ],
+ );
+ const plan = buildLoomImportPlan(
+ { headers: ["url"], rows, delimiter: ",", headerless: false },
+ { loomUrl: 0 },
+ { allowOwners: true },
+ );
+ expect(plan.overLimit).toBe(true);
+ });
+
+ it("parses and plans 2,000 rows quickly", () => {
+ const csv = [
+ "Video Title,Video URL,Creator Email,Space",
+ ...Array.from(
+ { length: 2000 },
+ (_, index) =>
+ `"Weekly update, part ${index}",https://www.loom.com/share/${index.toString(16).padStart(32, "0")},person${index % 40}@acme.com,Team ${index % 7}`,
+ ),
+ ].join("\n");
+ const startedAt = performance.now();
+ const parsed = parseCsv(csv);
+ const plan = buildLoomImportPlan(parsed, detectLoomImportMapping(parsed), {
+ allowOwners: true,
+ });
+ const elapsed = performance.now() - startedAt;
+ expect(plan.rows).toHaveLength(2000);
+ expect(plan.owners).toBe(40);
+ expect(plan.spaces).toBe(7);
+ expect(elapsed).toBeLessThan(250);
+ });
+});
+
+describe("tableFromPastedLinks", () => {
+ it("pulls Loom links out of free text", () => {
+ const table = tableFromPastedLinks(
+ `Here you go: https://www.loom.com/share/${ID_A}. And (https://loom.com/share/${ID_B})\nthanks!`,
+ );
+ expect(table.rows).toEqual([
+ [`https://www.loom.com/share/${ID_A}`],
+ [`https://loom.com/share/${ID_B}`],
+ ]);
+ });
+
+ it("accepts bare Loom video ids", () => {
+ expect(tableFromPastedLinks(`${ID_A}\n${ID_B}`).rows).toEqual([
+ [ID_A],
+ [ID_B],
+ ]);
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts
index a8068f6e3b3..703354e9802 100644
--- a/apps/web/__tests__/unit/loom-import.test.ts
+++ b/apps/web/__tests__/unit/loom-import.test.ts
@@ -706,454 +706,4 @@ describe("importFromLoom", () => {
])
expect(conditions).toContain(required);
});
-
- it("rejects a CSV import when the current user is not an organization admin or owner", async () => {
- getOrganizationAccessMock.mockResolvedValueOnce({
- id: "org-1",
- ownerId: "owner-456",
- memberId: "member-row",
- role: "member",
- });
-
- const fetchMock = vi.mocked(fetch);
- const { importFromLoomCsv } = await import("@/actions/loom");
-
- const result = await importFromLoomCsv({
- orgId: "org-1" as never,
- rows: [
- {
- rowNumber: 2,
- loomUrl: "https://www.loom.com/share/loom-abc1234567",
- userEmail: "member@example.com",
- },
- ],
- });
-
- expect(result).toEqual({
- success: false,
- importedCount: 0,
- failedCount: 0,
- results: [],
- error:
- "Only organization admins and owners can import Loom videos from a CSV.",
- });
- expect(getOrganizationAccessMock).toHaveBeenCalledWith("user-123", "org-1");
- expect(fetchMock).not.toHaveBeenCalled();
- expect(valuesMock).not.toHaveBeenCalled();
- });
-
- it("provisions missing CSV users and starts imports for them", async () => {
- whereMock
- .mockReturnValueOnce(withLimit([]))
- .mockReturnValueOnce(withLimit([]))
- .mockReturnValueOnce(withLimit([]))
- .mockReturnValueOnce(withLimit([]))
- .mockReturnValueOnce(withLimit([{ ownerId: "owner-456" }]))
- .mockReturnValueOnce(
- withLimit([{ inviteQuota: 1, stripeSubscriptionId: null }]),
- )
- .mockResolvedValueOnce([]);
-
- const fetchMock = vi.mocked(fetch);
- fetchMock.mockImplementation(async (input) => {
- const url = typeof input === "string" ? input : input.toString();
-
- if (url.includes("/transcoded-url")) {
- return {
- ok: true,
- status: 200,
- text: async () =>
- JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }),
- } as Response;
- }
-
- if (url === "https://www.loom.com/graphql") {
- return {
- ok: true,
- json: async () => ({
- data: { getVideo: { name: "Imported video" } },
- }),
- } as Response;
- }
-
- if (url.includes("/v1/oembed")) {
- return {
- ok: true,
- json: async () => ({ duration: 42, width: 1920, height: 1080 }),
- } as Response;
- }
-
- throw new Error(`Unexpected fetch: ${url}`);
- });
- const { importFromLoomCsv } = await import("@/actions/loom");
-
- const result = await importFromLoomCsv({
- orgId: "org-1" as never,
- rows: [
- {
- rowNumber: 2,
- loomUrl: "https://www.loom.com/share/loom-abc1234567",
- userEmail: "not-member@example.com",
- },
- ],
- });
-
- expect(result).toEqual({
- success: true,
- importedCount: 1,
- failedCount: 0,
- results: [
- {
- rowNumber: 2,
- userEmail: "not-member@example.com",
- spaceName: undefined,
- success: true,
- videoId: "video-123",
- error: undefined,
- },
- ],
- error: undefined,
- });
- expect(fetchMock).toHaveBeenCalled();
- expect(valuesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- email: "not-member@example.com",
- activeOrganizationId: "org-1",
- defaultOrgId: "org-1",
- }),
- );
- expect(valuesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- organizationId: "org-1",
- invitedEmail: "not-member@example.com",
- invitedByUserId: "user-123",
- role: "member",
- }),
- );
- expect(valuesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- organizationId: "org-1",
- userId: "video-123",
- role: "member",
- }),
- );
- expect(storageGetWritableAccessForUserMock).toHaveBeenCalledWith(
- "video-123",
- "org-1",
- );
- });
-
- it("imports a CSV row without calling the Vercel firewall", async () => {
- whereMock.mockImplementation((conditions: unknown) => {
- const serializedConditions = JSON.stringify(conditions);
-
- if (serializedConditions.includes("sourceId")) {
- return Promise.resolve(
- serializedConditions.includes("loom-existing123")
- ? [{ videoId: "existing-video" }]
- : [],
- );
- }
-
- return withLimit([{ userId: "member-123", email: "member@example.com" }]);
- });
-
- const fetchMock = vi.mocked(fetch);
- fetchMock.mockImplementation(async (input) => {
- const url = typeof input === "string" ? input : input.toString();
-
- if (url.includes("/transcoded-url")) {
- return {
- ok: true,
- status: 200,
- text: async () =>
- JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }),
- } as Response;
- }
-
- if (url === "https://www.loom.com/graphql") {
- return {
- ok: true,
- json: async () => ({
- data: { getVideo: { name: "Imported video" } },
- }),
- } as Response;
- }
-
- if (url.includes("/v1/oembed")) {
- return {
- ok: true,
- json: async () => ({ duration: 42, width: 1920, height: 1080 }),
- } as Response;
- }
-
- throw new Error(`Unexpected fetch: ${url}`);
- });
-
- const { importFromLoomCsv } = await import("@/actions/loom");
-
- const result = await importFromLoomCsv({
- orgId: "org-1" as never,
- rows: [
- {
- rowNumber: 2,
- loomUrl: "https://www.loom.com/share/loom-existing123",
- userEmail: "member@example.com",
- },
- {
- rowNumber: 3,
- loomUrl: "https://www.loom.com/share/loom-newvideo123",
- userEmail: "member@example.com",
- },
- ],
- });
-
- expect(result).toEqual({
- success: true,
- importedCount: 1,
- failedCount: 1,
- results: [
- {
- rowNumber: 2,
- userEmail: "member@example.com",
- spaceName: undefined,
- success: false,
- error: "This Loom video has already been imported.",
- },
- {
- rowNumber: 3,
- userEmail: "member@example.com",
- spaceName: undefined,
- success: true,
- videoId: "video-123",
- error: undefined,
- },
- ],
- error: undefined,
- });
- expect(checkRateLimitMock).not.toHaveBeenCalled();
- expect(fetchMock).toHaveBeenCalled();
- expect(startMock).toHaveBeenCalledTimes(1);
- });
-
- it("limits CSV imports to 500 rows", async () => {
- const fetchMock = vi.mocked(fetch);
- const { importFromLoomCsv } = await import("@/actions/loom");
-
- const result = await importFromLoomCsv({
- orgId: "org-1" as never,
- rows: Array.from({ length: 501 }, (_, index) => ({
- rowNumber: index + 2,
- loomUrl: `https://www.loom.com/share/loom-${String(index).padStart(10, "0")}`,
- userEmail: "member@example.com",
- })),
- });
-
- expect(result).toEqual({
- success: false,
- importedCount: 0,
- failedCount: 501,
- results: [],
- error:
- "CSV imports are limited to 500 rows at a time. Contact support to raise this limit.",
- });
- expect(checkRateLimitMock).not.toHaveBeenCalled();
- expect(fetchMock).not.toHaveBeenCalled();
- expect(valuesMock).not.toHaveBeenCalled();
- });
-
- it("starts CSV Loom imports for matched organization members when the current user is an organization admin", async () => {
- getOrganizationAccessMock.mockResolvedValueOnce({
- id: "org-1",
- ownerId: "owner-456",
- memberId: "member-row",
- role: "admin",
- });
- whereMock
- .mockReturnValueOnce(
- withLimit([{ userId: "member-123", email: "member@example.com" }]),
- )
- .mockResolvedValueOnce([]);
-
- const fetchMock = vi.mocked(fetch);
- fetchMock.mockImplementation(async (input) => {
- const url = typeof input === "string" ? input : input.toString();
-
- if (url.includes("/transcoded-url")) {
- return {
- ok: true,
- status: 200,
- text: async () =>
- JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }),
- } as Response;
- }
-
- if (url === "https://www.loom.com/graphql") {
- return {
- ok: true,
- json: async () => ({
- data: { getVideo: { name: "Imported video" } },
- }),
- } as Response;
- }
-
- if (url.includes("/v1/oembed")) {
- return {
- ok: true,
- json: async () => ({ duration: 42, width: 1920, height: 1080 }),
- } as Response;
- }
-
- throw new Error(`Unexpected fetch: ${url}`);
- });
-
- const { importFromLoomCsv } = await import("@/actions/loom");
-
- const result = await importFromLoomCsv({
- orgId: "org-1" as never,
- rows: [
- {
- rowNumber: 2,
- loomUrl: "https://www.loom.com/share/loom-abc1234567",
- userEmail: "MEMBER@example.com",
- },
- ],
- });
-
- expect(result).toEqual({
- success: true,
- importedCount: 1,
- failedCount: 0,
- results: [
- {
- rowNumber: 2,
- userEmail: "member@example.com",
- spaceName: undefined,
- success: true,
- videoId: "video-123",
- error: undefined,
- },
- ],
- error: undefined,
- });
- expect(storageGetWritableAccessForUserMock).toHaveBeenCalledWith(
- "member-123",
- "org-1",
- );
- expect(valuesMock).toHaveBeenNthCalledWith(
- 1,
- expect.objectContaining({
- ownerId: "member-123",
- }),
- );
- expect(startMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.arrayContaining([
- expect.objectContaining({
- userId: "member-123",
- rawFileKey: "member-123/video-123/raw-upload.mp4",
- }),
- ]),
- );
- });
-
- it("creates missing spaces and adds CSV Loom imports to them", async () => {
- whereMock
- .mockReturnValueOnce(
- withLimit([{ userId: "member-123", email: "member@example.com" }]),
- )
- .mockResolvedValueOnce([])
- .mockReturnValueOnce(withLimit([]))
- .mockReturnValueOnce(withLimit([]))
- .mockReturnValueOnce(withLimit([]));
-
- const fetchMock = vi.mocked(fetch);
- fetchMock.mockImplementation(async (input) => {
- const url = typeof input === "string" ? input : input.toString();
-
- if (url.includes("/transcoded-url")) {
- return {
- ok: true,
- status: 200,
- text: async () =>
- JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }),
- } as Response;
- }
-
- if (url === "https://www.loom.com/graphql") {
- return {
- ok: true,
- json: async () => ({
- data: { getVideo: { name: "Imported video" } },
- }),
- } as Response;
- }
-
- if (url.includes("/v1/oembed")) {
- return {
- ok: true,
- json: async () => ({ duration: 42, width: 1920, height: 1080 }),
- } as Response;
- }
-
- throw new Error(`Unexpected fetch: ${url}`);
- });
-
- const { importFromLoomCsv } = await import("@/actions/loom");
-
- const result = await importFromLoomCsv({
- orgId: "org-1" as never,
- rows: [
- {
- rowNumber: 2,
- loomUrl: "https://www.loom.com/share/loom-abc1234567",
- userEmail: "member@example.com",
- spaceName: " Sales Team ",
- },
- ],
- });
-
- expect(result).toEqual({
- success: true,
- importedCount: 1,
- failedCount: 0,
- results: [
- {
- rowNumber: 2,
- userEmail: "member@example.com",
- spaceName: "Sales Team",
- success: true,
- videoId: "video-123",
- error: undefined,
- },
- ],
- error: undefined,
- });
- expect(mockDb.transaction).toHaveBeenCalledTimes(2);
- expect(valuesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "Sales Team",
- organizationId: "org-1",
- createdById: "user-123",
- }),
- );
- expect(valuesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- spaceId: "video-123",
- userId: "user-123",
- role: "admin",
- }),
- );
- expect(valuesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- spaceId: "video-123",
- videoId: "video-123",
- addedById: "user-123",
- }),
- );
- expect(revalidatePathMock).toHaveBeenCalledWith(
- "/dashboard/spaces/video-123",
- );
- expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard");
- });
});
diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts
index 57deff5af8c..a0ebd149d9d 100644
--- a/apps/web/__tests__/unit/media-processing-workflows.test.ts
+++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(),
fetch: vi.fn(),
sleep: vi.fn(),
+ continueLoomImport: vi.fn(),
}));
vi.mock("@cap/database", () => ({
@@ -56,6 +57,9 @@ vi.mock("@/lib/video-storage", () => ({
decodeStorageVideo: (value: unknown) => value,
}));
vi.mock("@/lib/transcribe", () => ({ transcribeVideo: vi.fn() }));
+vi.mock("@/lib/loom-import/dispatch", () => ({
+ dispatchLoomImportForVideo: mocks.continueLoomImport,
+}));
vi.mock("@/lib/ai-generation-entitlement", () => ({
isAiGenerationEnabledForUser: () => false,
}));
@@ -111,6 +115,7 @@ describe("media processing workflows", () => {
);
mocks.remove.mockImplementation(() => Effect.void);
mocks.sleep.mockResolvedValue(undefined);
+ mocks.continueLoomImport.mockReset().mockResolvedValue(null);
vi.stubGlobal("fetch", mocks.fetch);
mocks.fetch.mockReset().mockImplementation(async (url: string) => {
if (url.startsWith("https://www.loom.com/")) {
@@ -198,6 +203,21 @@ describe("media processing workflows", () => {
priority: "bulk",
});
expect(mocks.remove).not.toHaveBeenCalled();
+ expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith("video");
+ });
+
+ it("frees its import slot when a Loom video fails for good", async () => {
+ mocks.continueLoomImport.mockRejectedValueOnce(new Error("Database busy"));
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mocks.fetch.mockImplementation(async (url: string) =>
+ url.startsWith("https://www.loom.com/")
+ ? new Response(null, { status: 404 })
+ : Response.json({ jobId: "job-1" }),
+ );
+ await expect(importLoomVideoWorkflow(payload)).rejects.toThrow(
+ "Could not retrieve a download URL from Loom",
+ );
+ expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith("video");
});
it("reuses a preserved Loom upload when retrying", async () => {
diff --git a/apps/web/actions/loom-import.ts b/apps/web/actions/loom-import.ts
new file mode 100644
index 00000000000..999629875a6
--- /dev/null
+++ b/apps/web/actions/loom-import.ts
@@ -0,0 +1,101 @@
+"use server";
+
+import { getCurrentUser } from "@cap/database/auth/session";
+import { userIsPro } from "@cap/utils";
+import type { Organisation } from "@cap/web-domain";
+import { start } from "workflow/api";
+import {
+ cancelLoomImportJob,
+ createLoomImportJob,
+ getLoomImportJobForUser,
+ LoomImportError,
+ markLoomImportJobStarting,
+ resetFailedLoomImportItems,
+} from "@/lib/loom-import/jobs";
+import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
+
+type ActionResult> =
+ | ({ ok: true } & T)
+ | { ok: false; error: string };
+
+const GENERIC_ERROR = "Something went wrong. Please try again.";
+
+export async function createLoomImportJobAction({
+ orgId,
+ fileName,
+ rows,
+}: {
+ orgId: Organisation.OrganisationId;
+ fileName: string;
+ rows: unknown;
+}): Promise> {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Please sign in again." };
+
+ try {
+ const { jobId } = await createLoomImportJob({
+ userId: user.id,
+ orgId,
+ fileName: typeof fileName === "string" ? fileName : "",
+ rows,
+ });
+ await start(loomImportJobWorkflow, [{ jobId }]);
+ return { ok: true, jobId };
+ } catch (error) {
+ if (error instanceof LoomImportError)
+ return { ok: false, error: error.message };
+ console.error("[loom-import] Could not create import", error);
+ return { ok: false, error: GENERIC_ERROR };
+ }
+}
+
+export async function startLoomImportJobAction(
+ jobId: string,
+): Promise> {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Please sign in again." };
+ const found = await getLoomImportJobForUser(jobId, user.id);
+ if (!found) return { ok: false, error: "This import doesn't exist." };
+ if (found.job.createdById !== user.id) {
+ return {
+ ok: false,
+ error: "Only the person who uploaded this CSV can start it.",
+ };
+ }
+ if (!userIsPro(user)) {
+ return { ok: false, error: "Importing from Loom needs Cap Pro." };
+ }
+
+ const started = await markLoomImportJobStarting(jobId);
+ if (started) await start(loomImportJobWorkflow, [{ jobId }]);
+ return { ok: true, started };
+}
+
+export async function cancelLoomImportJobAction(
+ jobId: string,
+): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Please sign in again." };
+ const found = await getLoomImportJobForUser(jobId, user.id);
+ if (!found) return { ok: false, error: "This import doesn't exist." };
+ await cancelLoomImportJob(jobId);
+ return { ok: true };
+}
+
+export async function retryLoomImportJobAction(
+ jobId: string,
+): Promise> {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Please sign in again." };
+ const found = await getLoomImportJobForUser(jobId, user.id);
+ if (!found) return { ok: false, error: "This import doesn't exist." };
+ if (
+ found.job.status === "cancelled" ||
+ found.job.status === "awaiting_upgrade"
+ ) {
+ return { ok: false, error: "This import can't be retried." };
+ }
+ const retried = await resetFailedLoomImportItems(jobId);
+ if (retried > 0) await start(loomImportJobWorkflow, [{ jobId }]);
+ return { ok: true, retried };
+}
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index 80386e189c7..8e4e46d9d83 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -7,12 +7,8 @@ import { nanoId } from "@cap/database/helpers";
import {
folders,
importedVideos,
- organizationMembers,
sharedVideos,
- spaceMembers,
- spaces,
spaceVideos,
- users,
videos,
videoUploads,
} from "@cap/database/schema";
@@ -22,8 +18,7 @@ import { dub, userIsPro } from "@cap/utils";
import { Storage } from "@cap/web-backend";
import {
type Organisation,
- Space,
- SpaceMemberId,
+ type Space,
type User,
Video,
} from "@cap/web-domain";
@@ -32,14 +27,11 @@ import { Option } from "effect";
import { revalidatePath } from "next/cache";
import { start } from "workflow/api";
import {
- getOrganizationAccess,
requireOrganizationAccess,
requireOrganizationSettingsManager,
} from "@/actions/organization/authorization";
import { requireSpaceManager } from "@/actions/organization/space-authorization";
import type { LoomImportDestination } from "@/lib/loom-import-destination";
-import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
-import { canManageOrganizationSettings } from "@/lib/permissions/roles";
import { runPromise } from "@/lib/server";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
@@ -68,36 +60,6 @@ export interface LoomImportResult {
error?: string;
}
-export interface LoomCsvImportRow {
- rowNumber: number;
- loomUrl: string;
- userEmail: string;
- spaceName?: string;
-}
-
-export interface LoomCsvImportRowResult {
- rowNumber: number;
- userEmail: string;
- spaceName?: string;
- success: boolean;
- videoId?: Video.VideoId;
- error?: string;
-}
-
-export interface LoomCsvImportResult {
- success: boolean;
- importedCount: number;
- failedCount: number;
- results: LoomCsvImportRowResult[];
- error?: string;
-}
-
-const MAX_LOOM_CSV_ROWS = 500;
-const MAX_LOOM_SPACE_NAME_LENGTH = 255;
-const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} rows at a time. Contact support to raise this limit.`;
-const LOOM_CSV_PERMISSION_ERROR =
- "Only organization admins and owners can import Loom videos from a CSV.";
-
function extractLoomVideoId(url: string): string | null {
try {
const parsed = new URL(url);
@@ -112,6 +74,9 @@ function extractLoomVideoId(url: string): string | null {
return null;
}
+ const trailingLoomId = id.match(/([0-9a-f]{32})$/i)?.[1];
+ if (trailingLoomId) return trailingLoomId.toLowerCase();
+
return id.split("?")[0] ?? null;
} catch {
return null;
@@ -159,7 +124,9 @@ async function fetchLoomEndpoint(
}
}
-async function fetchVideoName(videoId: string): Promise {
+async function fetchVideoDetails(
+ videoId: string,
+): Promise<{ name: string | null; createdAt: string | null }> {
try {
const response = await fetch("https://www.loom.com/graphql", {
method: "POST",
@@ -173,7 +140,7 @@ async function fetchVideoName(videoId: string): Promise {
variables: { videoId, password: null },
query: `query GetVideoName($videoId: ID!, $password: String) {
getVideo(id: $videoId, password: $password) {
- ... on RegularUserVideo { name }
+ ... on RegularUserVideo { name createdAt }
... on PrivateVideo { id }
... on VideoPasswordMissingOrIncorrect { id }
}
@@ -181,12 +148,18 @@ async function fetchVideoName(videoId: string): Promise {
}),
});
- if (!response.ok) return null;
+ if (!response.ok) return { name: null, createdAt: null };
const data = await response.json();
- return data?.data?.getVideo?.name ?? null;
+ const video = data?.data?.getVideo;
+ const createdAt =
+ typeof video?.createdAt === "string" &&
+ !Number.isNaN(Date.parse(video.createdAt))
+ ? new Date(video.createdAt).toISOString()
+ : null;
+ return { name: video?.name ?? null, createdAt };
} catch {
- return null;
+ return { name: null, createdAt: null };
}
}
@@ -270,14 +243,14 @@ export async function downloadLoomVideo(
};
}
- const [videoName, oembedMeta] = await Promise.all([
- fetchVideoName(videoId),
+ const [details, oembedMeta] = await Promise.all([
+ fetchVideoDetails(videoId),
fetchLoomOEmbed(videoId),
]);
return {
success: true,
videoId,
- videoName: videoName ?? undefined,
+ videoName: details.name ?? undefined,
downloadUrl,
downloadMode: isDirectMp4Url(downloadUrl)
? "direct-download"
@@ -364,10 +337,11 @@ async function importLoomVideoForOwner({
};
}
- const [videoName, oembedMeta] = await Promise.all([
- fetchVideoName(loomVideoId),
+ const [details, oembedMeta] = await Promise.all([
+ fetchVideoDetails(loomVideoId),
fetchLoomOEmbed(loomVideoId),
]);
+ const videoName = details.name;
const writableResult = await Storage.getWritableAccessForUser(ownerId, orgId)
.pipe(runPromise)
@@ -394,6 +368,7 @@ async function importLoomVideoForOwner({
const name =
videoName ||
`Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`;
+ const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`;
await db().transaction(async (tx) => {
await tx.insert(videos).values({
@@ -409,6 +384,9 @@ async function importLoomVideoForOwner({
...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}),
...(oembedMeta?.width ? { width: oembedMeta.width } : {}),
...(oembedMeta?.height ? { height: oembedMeta.height } : {}),
+ ...(details.createdAt
+ ? { metadata: { customCreatedAt: details.createdAt } }
+ : {}),
});
await tx.insert(videoUploads).values({
@@ -416,6 +394,7 @@ async function importLoomVideoForOwner({
phase: "uploading",
processingProgress: 0,
processingMessage: "Importing from Loom...",
+ rawFileKey,
});
await tx.insert(importedVideos).values({
@@ -444,8 +423,6 @@ async function importLoomVideoForOwner({
}
});
- const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`;
-
if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") {
await dub()
.links.create({
@@ -567,381 +544,3 @@ export async function importFromLoom({
destination: { folderId, spaceId },
});
}
-
-function normalizeImportEmail(email: string) {
- return email.trim().toLowerCase();
-}
-
-function normalizeImportSpaceName(spaceName: string) {
- return spaceName.trim().replace(/\s+/g, " ");
-}
-
-function getSpaceNameCacheKey(spaceName: string) {
- return normalizeImportSpaceName(spaceName).toLowerCase();
-}
-
-function isValidImportEmail(email: string) {
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
-}
-
-function isValidImportSpaceName(spaceName: string) {
- return spaceName.length <= MAX_LOOM_SPACE_NAME_LENGTH;
-}
-
-async function getOrganizationMemberByEmail(
- orgId: Organisation.OrganisationId,
- email: string,
-) {
- const [member] = await db()
- .select({
- userId: organizationMembers.userId,
- email: users.email,
- })
- .from(organizationMembers)
- .innerJoin(users, eq(organizationMembers.userId, users.id))
- .where(
- and(
- eq(organizationMembers.organizationId, orgId),
- eq(users.email, email),
- ),
- )
- .limit(1);
-
- return member ?? null;
-}
-
-type ImportSpaceCacheValue = {
- id: Space.SpaceIdOrOrganisationId;
- name: string;
-};
-
-async function getOrCreateImportSpace({
- orgId,
- createdById,
- name,
- spaceCache,
-}: {
- orgId: Organisation.OrganisationId;
- createdById: User.UserId;
- name: string;
- spaceCache: Map;
-}) {
- const normalizedName = normalizeImportSpaceName(name);
- const cacheKey = getSpaceNameCacheKey(normalizedName);
- const cached = spaceCache.get(cacheKey);
- if (cached) return cached;
-
- const [existingSpace] = await db()
- .select({
- id: spaces.id,
- name: spaces.name,
- })
- .from(spaces)
- .where(
- and(eq(spaces.organizationId, orgId), eq(spaces.name, normalizedName)),
- )
- .limit(1);
-
- if (existingSpace) {
- const value = {
- id: existingSpace.id,
- name: existingSpace.name,
- };
- spaceCache.set(cacheKey, value);
- return value;
- }
-
- const spaceId = Space.SpaceId.make(nanoId());
-
- await db().transaction(async (tx) => {
- await tx.insert(spaces).values({
- id: spaceId,
- name: normalizedName,
- organizationId: orgId,
- createdById,
- iconUrl: null,
- });
-
- await tx.insert(spaceMembers).values({
- id: SpaceMemberId.make(nanoId()),
- spaceId,
- userId: createdById,
- role: "admin",
- });
- });
-
- const value = {
- id: spaceId,
- name: normalizedName,
- };
- spaceCache.set(cacheKey, value);
- return value;
-}
-
-async function addImportedVideoToSpace({
- videoId,
- spaceId,
- addedById,
-}: {
- videoId: Video.VideoId;
- spaceId: Space.SpaceIdOrOrganisationId;
- addedById: User.UserId;
-}) {
- const [existingSpaceVideo] = await db()
- .select({ id: spaceVideos.id })
- .from(spaceVideos)
- .where(
- and(eq(spaceVideos.spaceId, spaceId), eq(spaceVideos.videoId, videoId)),
- )
- .limit(1);
-
- if (existingSpaceVideo) return;
-
- await db().insert(spaceVideos).values({
- id: nanoId(),
- spaceId,
- videoId,
- addedById,
- });
-}
-
-async function addImportOwnerToSpace({
- spaceId,
- userId,
-}: {
- spaceId: Space.SpaceIdOrOrganisationId;
- userId: User.UserId;
-}) {
- const [existingSpaceMember] = await db()
- .select({ id: spaceMembers.id })
- .from(spaceMembers)
- .where(
- and(eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.userId, userId)),
- )
- .limit(1);
-
- if (existingSpaceMember) return;
-
- await db()
- .insert(spaceMembers)
- .values({
- id: SpaceMemberId.make(nanoId()),
- spaceId,
- userId,
- role: "member",
- });
-}
-
-export async function importFromLoomCsv({
- rows,
- orgId,
-}: {
- rows: LoomCsvImportRow[];
- orgId: Organisation.OrganisationId;
-}): Promise {
- const user = await getCurrentUser();
- if (!user) {
- return {
- success: false,
- importedCount: 0,
- failedCount: 0,
- results: [],
- error: "Unauthorized",
- };
- }
-
- if (!userIsPro(user)) {
- return {
- success: false,
- importedCount: 0,
- failedCount: 0,
- results: [],
- error: "Importing from Loom requires a Cap Pro subscription.",
- };
- }
-
- const access = await getOrganizationAccess(user.id, orgId);
- if (!canManageOrganizationSettings(access?.role)) {
- return {
- success: false,
- importedCount: 0,
- failedCount: 0,
- results: [],
- error: LOOM_CSV_PERMISSION_ERROR,
- };
- }
-
- const inputRows = Array.isArray(rows) ? rows : [];
- const normalizedRows = inputRows
- .map((row, index) => ({
- rowNumber:
- Number.isInteger(row.rowNumber) && row.rowNumber > 0
- ? row.rowNumber
- : index + 2,
- loomUrl: typeof row.loomUrl === "string" ? row.loomUrl.trim() : "",
- userEmail:
- typeof row.userEmail === "string"
- ? normalizeImportEmail(row.userEmail)
- : "",
- spaceName:
- typeof row.spaceName === "string"
- ? normalizeImportSpaceName(row.spaceName)
- : "",
- }))
- .filter((row) => row.loomUrl || row.userEmail || row.spaceName);
-
- if (normalizedRows.length === 0) {
- return {
- success: false,
- importedCount: 0,
- failedCount: 0,
- results: [],
- error: "No rows found to import.",
- };
- }
-
- if (normalizedRows.length > MAX_LOOM_CSV_ROWS) {
- return {
- success: false,
- importedCount: 0,
- failedCount: normalizedRows.length,
- results: [],
- error: LOOM_CSV_LIMIT_ERROR,
- };
- }
-
- const results: LoomCsvImportRowResult[] = [];
- const spaceCache = new Map();
- const touchedSpaceIds = new Set();
-
- for (const row of normalizedRows) {
- if (!row.loomUrl) {
- results.push({
- rowNumber: row.rowNumber,
- userEmail: row.userEmail,
- spaceName: row.spaceName || undefined,
- success: false,
- error: "Missing Loom video URL.",
- });
- continue;
- }
-
- if (!isValidImportEmail(row.userEmail)) {
- results.push({
- rowNumber: row.rowNumber,
- userEmail: row.userEmail,
- spaceName: row.spaceName || undefined,
- success: false,
- error: "Missing or invalid user email.",
- });
- continue;
- }
-
- if (!isValidImportSpaceName(row.spaceName)) {
- results.push({
- rowNumber: row.rowNumber,
- userEmail: row.userEmail,
- spaceName: row.spaceName,
- success: false,
- error: `Space name must be ${MAX_LOOM_SPACE_NAME_LENGTH} characters or fewer.`,
- });
- continue;
- }
-
- let member = await getOrganizationMemberByEmail(orgId, row.userEmail);
-
- if (!member) {
- try {
- const provisionedMember = await provisionOrganizationInvitee({
- organizationId: orgId,
- email: row.userEmail,
- invitedByUserId: user.id,
- role: "member",
- });
- member = {
- userId: provisionedMember.userId,
- email: row.userEmail,
- };
- } catch {
- results.push({
- rowNumber: row.rowNumber,
- userEmail: row.userEmail,
- spaceName: row.spaceName || undefined,
- success: false,
- error: "Could not add this email to the organization.",
- });
- continue;
- }
- }
-
- try {
- const result = await importLoomVideoForOwner({
- loomUrl: row.loomUrl,
- orgId,
- ownerId: member.userId,
- });
-
- let spaceName = row.spaceName || undefined;
- let spaceError: string | undefined;
- if (result.success && result.videoId && row.spaceName) {
- try {
- const space = await getOrCreateImportSpace({
- orgId,
- createdById: user.id,
- name: row.spaceName,
- spaceCache,
- });
- await addImportedVideoToSpace({
- videoId: result.videoId,
- spaceId: space.id,
- addedById: user.id,
- });
- await addImportOwnerToSpace({
- spaceId: space.id,
- userId: member.userId,
- });
- touchedSpaceIds.add(space.id);
- spaceName = space.name;
- } catch {
- spaceError = "Import started, but it could not be added to a space.";
- }
- }
-
- results.push({
- rowNumber: row.rowNumber,
- userEmail: row.userEmail,
- spaceName,
- success: result.success,
- videoId: result.videoId,
- error: result.error ?? spaceError,
- });
- } catch {
- results.push({
- rowNumber: row.rowNumber,
- userEmail: row.userEmail,
- spaceName: row.spaceName || undefined,
- success: false,
- error: "Failed to start this import.",
- });
- }
- }
-
- const importedCount = results.filter((result) => result.success).length;
- const failedCount = results.length - importedCount;
-
- for (const spaceId of touchedSpaceIds) {
- revalidatePath(`/dashboard/spaces/${spaceId}`);
- }
-
- if (touchedSpaceIds.size > 0) {
- revalidatePath("/dashboard");
- }
-
- return {
- success: importedCount > 0,
- importedCount,
- failedCount,
- results,
- error: importedCount > 0 ? undefined : "No Loom videos were imported.",
- };
-}
diff --git a/apps/web/app/api/cron/recover-failed-video-processing/route.ts b/apps/web/app/api/cron/recover-failed-video-processing/route.ts
index 7d314b24d1c..68fd3c42089 100644
--- a/apps/web/app/api/cron/recover-failed-video-processing/route.ts
+++ b/apps/web/app/api/cron/recover-failed-video-processing/route.ts
@@ -1,5 +1,6 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
+import { recoverLoomImportJobs } from "@/lib/loom-import/recovery";
import { cleanupExpiredMediaProcessingBudgets } from "@/lib/media-processing-budget";
import { recoverStalledVideoPipeline } from "@/lib/video-pipeline-recovery";
import { recoverFailedVideoProcessing } from "@/lib/video-processing-recovery";
@@ -25,19 +26,25 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const [summary, stalledPipeline, expiredBudgetsDeleted] = await Promise.all([
- recoverFailedVideoProcessing(),
- recoverStalledVideoPipeline(),
- cleanupExpiredMediaProcessingBudgets().catch((error: unknown) => {
- console.error("Processing budget cleanup failed", error);
- return null;
- }),
- ]);
+ const [summary, stalledPipeline, expiredBudgetsDeleted, loomImports] =
+ await Promise.all([
+ recoverFailedVideoProcessing(),
+ recoverStalledVideoPipeline(),
+ cleanupExpiredMediaProcessingBudgets().catch((error: unknown) => {
+ console.error("Processing budget cleanup failed", error);
+ return null;
+ }),
+ recoverLoomImportJobs().catch((error: unknown) => {
+ console.error("Loom import recovery failed", error);
+ return null;
+ }),
+ ]);
return NextResponse.json({
success: true,
...summary,
stalledPipeline,
expiredBudgetsDeleted,
+ loomImports,
});
}
diff --git a/apps/web/app/api/import/loom/jobs/route.ts b/apps/web/app/api/import/loom/jobs/route.ts
new file mode 100644
index 00000000000..306f0ebbb4b
--- /dev/null
+++ b/apps/web/app/api/import/loom/jobs/route.ts
@@ -0,0 +1,133 @@
+import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain";
+import {
+ HttpApi,
+ HttpApiBuilder,
+ HttpApiEndpoint,
+ HttpApiError,
+ HttpApiGroup,
+} from "@effect/platform";
+import { Effect, Layer, Schema } from "effect";
+import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot";
+import { apiToHandler } from "@/lib/server";
+
+export const dynamic = "force-dynamic";
+
+const DisplayStatus = Schema.Literal(
+ "checking",
+ "ready",
+ "queued",
+ "importing",
+ "imported",
+ "failed",
+ "skipped",
+ "cancelled",
+);
+
+const Item = Schema.Struct({
+ id: Schema.String,
+ row: Schema.Number,
+ url: Schema.String,
+ loomId: Schema.NullOr(Schema.String),
+ title: Schema.NullOr(Schema.String),
+ email: Schema.NullOr(Schema.String),
+ space: Schema.NullOr(Schema.String),
+ status: DisplayStatus,
+ stage: Schema.optional(
+ Schema.Literal(
+ "starting",
+ "waiting",
+ "downloading",
+ "processing",
+ "finishing",
+ ),
+ ),
+ progress: Schema.optional(Schema.Number),
+ error: Schema.optional(Schema.String),
+ videoId: Schema.NullOr(Schema.String),
+ recordedAt: Schema.NullOr(Schema.String),
+ duration: Schema.NullOr(Schema.Number),
+ thumb: Schema.NullOr(Schema.String),
+ v: Schema.Number,
+});
+
+const Snapshot = Schema.Struct({
+ job: Schema.Struct({
+ id: Schema.String,
+ fileName: Schema.String,
+ status: Schema.Literal(
+ "checking",
+ "awaiting_upgrade",
+ "importing",
+ "completed",
+ "cancelled",
+ ),
+ totalCount: Schema.Number,
+ createdAt: Schema.String,
+ startedAt: Schema.NullOr(Schema.String),
+ completedAt: Schema.NullOr(Schema.String),
+ createdByMe: Schema.Boolean,
+ canStart: Schema.Boolean,
+ isPro: Schema.Boolean,
+ isAdmin: Schema.Boolean,
+ }),
+ counts: Schema.Struct({
+ checking: Schema.Number,
+ ready: Schema.Number,
+ queued: Schema.Number,
+ importing: Schema.Number,
+ imported: Schema.Number,
+ failed: Schema.Number,
+ skipped: Schema.Number,
+ cancelled: Schema.Number,
+ total: Schema.Number,
+ }),
+ totalDuration: Schema.Number,
+ importedDuration: Schema.Number,
+ owners: Schema.Number,
+ items: Schema.Array(Item),
+ cursor: Schema.Number,
+ full: Schema.Boolean,
+});
+
+class Api extends HttpApi.make("LoomImportJobsApi").add(
+ HttpApiGroup.make("root").add(
+ HttpApiEndpoint.get("getJob")`/api/import/loom/jobs`
+ .setUrlParams(
+ Schema.Struct({
+ jobId: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(64)),
+ since: Schema.optional(Schema.NumberFromString),
+ }),
+ )
+ .addSuccess(Snapshot)
+ .addError(HttpApiError.NotFound)
+ .addError(HttpApiError.InternalServerError)
+ .middleware(HttpAuthMiddleware),
+ ),
+) {}
+
+const ApiLive = HttpApiBuilder.api(Api).pipe(
+ Layer.provide(
+ HttpApiBuilder.group(Api, "root", (handlers) =>
+ handlers.handle("getJob", ({ urlParams }) =>
+ Effect.gen(function* () {
+ const user = yield* CurrentUser;
+ const snapshot = yield* Effect.tryPromise({
+ try: () =>
+ getLoomImportSnapshot({
+ jobId: urlParams.jobId,
+ userId: user.id,
+ since: urlParams.since,
+ }),
+ catch: () => new HttpApiError.InternalServerError(),
+ });
+ if (!snapshot) return yield* new HttpApiError.NotFound();
+ return snapshot;
+ }),
+ ),
+ ),
+ ),
+);
+
+const handler = apiToHandler(ApiLive);
+
+export const GET = handler;
diff --git a/apps/web/lib/loom-import/csv.ts b/apps/web/lib/loom-import/csv.ts
new file mode 100644
index 00000000000..12b08b8da5d
--- /dev/null
+++ b/apps/web/lib/loom-import/csv.ts
@@ -0,0 +1,462 @@
+export const LOOM_IMPORT_MAX_ROWS = 2000;
+export const LOOM_IMPORT_MAX_SPACE_NAME_LENGTH = 255;
+export const LOOM_IMPORT_MAX_URL_LENGTH = 1024;
+
+export type CsvTable = {
+ headers: string[];
+ rows: string[][];
+ delimiter: string;
+ headerless: boolean;
+};
+
+export type LoomImportField = "loomUrl" | "ownerEmail" | "spaceName";
+
+export type LoomImportMapping = Partial>;
+
+export type LoomImportRowInput = {
+ rowNumber: number;
+ loomUrl: string;
+ ownerEmail?: string;
+ spaceName?: string;
+};
+
+export type LoomImportRowIssue = {
+ rowNumber: number;
+ value: string;
+ reason: "not_loom" | "bad_email" | "space_too_long" | "duplicate";
+ duplicateOf?: number;
+};
+
+export type LoomImportPlan = {
+ rows: LoomImportRowInput[];
+ issues: LoomImportRowIssue[];
+ owners: number;
+ spaces: number;
+ overLimit: boolean;
+};
+
+const LOOM_ID_PATTERN = /^[0-9a-f]{32}$/i;
+const TRAILING_LOOM_ID_PATTERN = /([0-9a-f]{32})$/i;
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const LOOM_LINK_PATTERN =
+ /https?:\/\/(?:[a-z0-9-]+\.)*loom\.com\/[^\s"'<>,;)]+/gi;
+const CANDIDATE_DELIMITERS = [",", ";", "\t"] as const;
+
+const URL_HEADER_HINTS = [
+ "loomvideourl",
+ "loomurl",
+ "loomlink",
+ "sharelink",
+ "shareurl",
+ "videourl",
+ "videolink",
+ "link",
+ "url",
+];
+const ID_HEADER_HINTS = ["loomvideoid", "loomid", "videoid", "id"];
+const EMAIL_HEADER_HINTS = [
+ "useremail",
+ "owneremail",
+ "creatoremail",
+ "memberemail",
+ "recordedby",
+ "email",
+];
+const SPACE_HEADER_HINTS = ["spacename", "space", "loomspace", "capspace"];
+
+export function normalizeHeader(value: string) {
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
+}
+
+function detectDelimiter(text: string) {
+ const firstLineEnd = text.search(/\r?\n/);
+ const sample = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd);
+ let best: string = ",";
+ let bestCount = 0;
+ for (const delimiter of CANDIDATE_DELIMITERS) {
+ let count = 0;
+ let inQuotes = false;
+ for (let index = 0; index < sample.length; index++) {
+ const char = sample[index];
+ if (char === '"') inQuotes = !inQuotes;
+ else if (char === delimiter && !inQuotes) count++;
+ }
+ if (count > bestCount) {
+ best = delimiter;
+ bestCount = count;
+ }
+ }
+ return best;
+}
+
+export function parseCsvRecords(input: string, delimiter = ",") {
+ const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
+ const records: string[][] = [];
+ const length = text.length;
+ const delimiterCode = delimiter.charCodeAt(0);
+ let row: string[] = [];
+ let index = 0;
+
+ const pushRow = () => {
+ records.push(row);
+ row = [];
+ };
+
+ while (index <= length) {
+ if (index === length) {
+ if (row.length > 0) pushRow();
+ break;
+ }
+
+ if (text.charCodeAt(index) === 34) {
+ let value = "";
+ let segmentStart = index + 1;
+ let cursor = segmentStart;
+ let closed = false;
+ while (cursor < length) {
+ const quote = text.indexOf('"', cursor);
+ if (quote === -1) break;
+ if (text.charCodeAt(quote + 1) === 34) {
+ value += `${text.slice(segmentStart, quote)}"`;
+ cursor = quote + 2;
+ segmentStart = cursor;
+ continue;
+ }
+ value += text.slice(segmentStart, quote);
+ cursor = quote + 1;
+ closed = true;
+ break;
+ }
+ if (!closed) throw new Error("This CSV has an unclosed quoted field.");
+ let end = cursor;
+ while (
+ end < length &&
+ text.charCodeAt(end) !== delimiterCode &&
+ text.charCodeAt(end) !== 10 &&
+ text.charCodeAt(end) !== 13
+ ) {
+ end++;
+ }
+ row.push((value + text.slice(cursor, end)).trim());
+ index = end;
+ } else {
+ let end = index;
+ while (
+ end < length &&
+ text.charCodeAt(end) !== delimiterCode &&
+ text.charCodeAt(end) !== 10 &&
+ text.charCodeAt(end) !== 13
+ ) {
+ end++;
+ }
+ row.push(text.slice(index, end).trim());
+ index = end;
+ }
+
+ if (index >= length) {
+ pushRow();
+ break;
+ }
+
+ const code = text.charCodeAt(index);
+ if (code === delimiterCode) {
+ index++;
+ if (index === length) {
+ row.push("");
+ pushRow();
+ break;
+ }
+ continue;
+ }
+
+ if (code === 13 && text.charCodeAt(index + 1) === 10) index += 2;
+ else index++;
+ pushRow();
+ }
+
+ while (records.length > 0 && !isFilledRecord(records[records.length - 1])) {
+ records.pop();
+ }
+ return records;
+}
+
+function isFilledRecord(record: string[] | undefined) {
+ return Boolean(record?.some((cell) => cell.length > 0));
+}
+
+export function countFilledRows(table: CsvTable) {
+ let count = 0;
+ for (const row of table.rows) if (isFilledRecord(row)) count++;
+ return count;
+}
+
+export function parseCsv(text: string): CsvTable {
+ const delimiter = detectDelimiter(text);
+ const records = parseCsvRecords(text, delimiter);
+ const first = records[0];
+ if (!first || !isFilledRecord(first)) throw new Error("This CSV is empty.");
+ const headerless = first.some(
+ (cell) => /loom\.com/i.test(cell) && extractLoomVideoId(cell) !== null,
+ );
+ if (headerless) {
+ const width = records.reduce(
+ (max, record) => Math.max(max, record.length),
+ 0,
+ );
+ return {
+ headers: Array.from(
+ { length: width },
+ (_, index) => `Column ${index + 1}`,
+ ),
+ rows: records,
+ delimiter,
+ headerless: true,
+ };
+ }
+ return {
+ headers: first.map((header) => header.trim()),
+ rows: records.slice(1),
+ delimiter,
+ headerless: false,
+ };
+}
+
+export function extractLoomVideoId(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ if (LOOM_ID_PATTERN.test(trimmed)) return trimmed.toLowerCase();
+
+ let url: URL;
+ try {
+ url = new URL(
+ /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`,
+ );
+ } catch {
+ return null;
+ }
+
+ const host = url.hostname.toLowerCase();
+ if (host !== "loom.com" && !host.endsWith(".loom.com")) return null;
+
+ const segments = url.pathname.split("/").filter(Boolean);
+ const last = segments.at(-1);
+ if (!last || segments.length < 2) return null;
+
+ const trailingId = last.match(TRAILING_LOOM_ID_PATTERN)?.[1];
+ if (trailingId) return trailingId.toLowerCase();
+
+ return /^[a-z0-9]{10,64}$/i.test(last) ? last : null;
+}
+
+export function loomShareUrl(loomVideoId: string) {
+ return `https://www.loom.com/share/${loomVideoId}`;
+}
+
+export function isValidImportEmail(value: string) {
+ return EMAIL_PATTERN.test(value);
+}
+
+export function normalizeImportEmail(value: string) {
+ return value.trim().toLowerCase();
+}
+
+export function normalizeImportSpaceName(value: string) {
+ return value.trim().replace(/\s+/g, " ");
+}
+
+function sampleColumn(rows: string[][], column: number, limit = 60) {
+ const values: string[] = [];
+ for (const row of rows) {
+ const value = row[column]?.trim();
+ if (value) values.push(value);
+ if (values.length >= limit) break;
+ }
+ return values;
+}
+
+function headerScore(header: string, hints: string[]) {
+ const normalized = normalizeHeader(header);
+ const exact = hints.indexOf(normalized);
+ if (exact !== -1) return 1 - exact / (hints.length * 4);
+ const partial = hints.findIndex((hint) => normalized.includes(hint));
+ return partial === -1 ? 0 : 0.5 - partial / (hints.length * 4);
+}
+
+function bestColumn(
+ headers: string[],
+ score: (column: number) => number,
+ taken: Set,
+) {
+ let best: number | undefined;
+ let bestScore = 0;
+ for (let column = 0; column < headers.length; column++) {
+ if (taken.has(column)) continue;
+ const value = score(column);
+ if (value > bestScore) {
+ best = column;
+ bestScore = value;
+ }
+ }
+ return best;
+}
+
+export function detectLoomImportMapping(table: CsvTable): LoomImportMapping {
+ const { headers, rows } = table;
+ const taken = new Set();
+ const samples = headers.map((_, column) => sampleColumn(rows, column));
+
+ const ratio = (column: number, test: (value: string) => boolean) => {
+ const values = samples[column] ?? [];
+ if (values.length === 0) return 0;
+ return values.filter(test).length / values.length;
+ };
+
+ const loomUrl = bestColumn(
+ headers,
+ (column) => {
+ const linkRatio = ratio(column, (value) =>
+ /loom\.com/i.test(value) ? extractLoomVideoId(value) !== null : false,
+ );
+ const idRatio = ratio(column, (value) => LOOM_ID_PATTERN.test(value));
+ const header = headers[column] ?? "";
+ if (linkRatio >= 0.5)
+ return 2 + linkRatio + headerScore(header, URL_HEADER_HINTS);
+ if (idRatio >= 0.5)
+ return 1 + idRatio + headerScore(header, ID_HEADER_HINTS);
+ return headerScore(header, URL_HEADER_HINTS) * 0.5;
+ },
+ taken,
+ );
+ if (loomUrl !== undefined) taken.add(loomUrl);
+
+ const ownerEmail = bestColumn(
+ headers,
+ (column) => {
+ const emailRatio = ratio(column, isValidImportEmail);
+ const hint = headerScore(headers[column] ?? "", EMAIL_HEADER_HINTS);
+ return emailRatio >= 0.5 ? 1 + emailRatio + hint : 0;
+ },
+ taken,
+ );
+ if (ownerEmail !== undefined) taken.add(ownerEmail);
+
+ const spaceName = bestColumn(
+ headers,
+ (column) =>
+ SPACE_HEADER_HINTS.includes(normalizeHeader(headers[column] ?? "")) &&
+ ratio(column, () => true) > 0
+ ? 1
+ : 0,
+ taken,
+ );
+
+ return { loomUrl, ownerEmail, spaceName };
+}
+
+export function tableFromPastedLinks(text: string): CsvTable {
+ const rows: string[][] = [];
+ for (const match of text.matchAll(LOOM_LINK_PATTERN)) {
+ rows.push([match[0].replace(/[.)\]]+$/, "")]);
+ }
+ if (rows.length === 0) {
+ for (const token of text.split(/\s+/)) {
+ if (LOOM_ID_PATTERN.test(token)) rows.push([token]);
+ }
+ }
+ return { headers: ["Loom link"], rows, delimiter: ",", headerless: true };
+}
+
+export function buildLoomImportPlan(
+ table: CsvTable,
+ mapping: LoomImportMapping,
+ { allowOwners }: { allowOwners: boolean },
+): LoomImportPlan {
+ const rows: LoomImportRowInput[] = [];
+ const issues: LoomImportRowIssue[] = [];
+ const seen = new Map();
+ const owners = new Set();
+ const spaces = new Set();
+ const urlColumn = mapping.loomUrl;
+
+ if (urlColumn === undefined) {
+ return { rows, issues, owners: 0, spaces: 0, overLimit: false };
+ }
+
+ const emailColumn = allowOwners ? mapping.ownerEmail : undefined;
+ const spaceColumn = allowOwners ? mapping.spaceName : undefined;
+
+ const firstRowNumber = table.headerless ? 1 : 2;
+ table.rows.forEach((record, index) => {
+ const rowNumber = index + firstRowNumber;
+ const rawUrl = (record[urlColumn] ?? "").trim();
+ const rawEmail =
+ emailColumn === undefined
+ ? ""
+ : normalizeImportEmail(record[emailColumn] ?? "");
+ const rawSpace =
+ spaceColumn === undefined
+ ? ""
+ : normalizeImportSpaceName(record[spaceColumn] ?? "");
+
+ if (!rawUrl && !rawEmail && !rawSpace) return;
+
+ const loomVideoId =
+ rawUrl.length <= LOOM_IMPORT_MAX_URL_LENGTH
+ ? extractLoomVideoId(rawUrl)
+ : null;
+ if (!loomVideoId) {
+ issues.push({ rowNumber, value: rawUrl, reason: "not_loom" });
+ return;
+ }
+ if (rawEmail && !isValidImportEmail(rawEmail)) {
+ issues.push({ rowNumber, value: rawEmail, reason: "bad_email" });
+ return;
+ }
+ if (rawSpace.length > LOOM_IMPORT_MAX_SPACE_NAME_LENGTH) {
+ issues.push({ rowNumber, value: rawSpace, reason: "space_too_long" });
+ return;
+ }
+ const duplicateOf = seen.get(loomVideoId);
+ if (duplicateOf !== undefined) {
+ issues.push({
+ rowNumber,
+ value: rawUrl,
+ reason: "duplicate",
+ duplicateOf,
+ });
+ return;
+ }
+ seen.set(loomVideoId, rowNumber);
+ if (rawEmail) owners.add(rawEmail);
+ if (rawSpace) spaces.add(rawSpace.toLowerCase());
+ rows.push({
+ rowNumber,
+ loomUrl: /^https?:\/\//i.test(rawUrl)
+ ? rawUrl
+ : loomShareUrl(loomVideoId),
+ ...(rawEmail ? { ownerEmail: rawEmail } : {}),
+ ...(rawSpace ? { spaceName: rawSpace } : {}),
+ });
+ });
+
+ return {
+ rows,
+ issues,
+ owners: owners.size,
+ spaces: spaces.size,
+ overLimit: rows.length > LOOM_IMPORT_MAX_ROWS,
+ };
+}
+
+export const LOOM_IMPORT_ISSUE_LABELS: Record<
+ LoomImportRowIssue["reason"],
+ string
+> = {
+ not_loom: "Not a Loom link",
+ bad_email: "Owner email looks wrong",
+ space_too_long: "Space name is too long",
+ duplicate: "Duplicate video",
+};
+
+export const LOOM_CSV_TEMPLATE =
+ "loom_video_url,user_email,space_name\nhttps://www.loom.com/share/0123456789abcdef0123456789abcdef,teammate@example.com,Sales\n";
diff --git a/apps/web/lib/loom-import/dispatch.ts b/apps/web/lib/loom-import/dispatch.ts
new file mode 100644
index 00000000000..a6132bdb403
--- /dev/null
+++ b/apps/web/lib/loom-import/dispatch.ts
@@ -0,0 +1,496 @@
+import { db } from "@cap/database";
+import { nanoId } from "@cap/database/helpers";
+import {
+ importedVideos,
+ loomImportJobItems,
+ loomImportJobs,
+ spaceVideos,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import { getNewVideoPublic } from "@cap/database/video-sharing-default";
+import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
+import { dub } from "@cap/utils";
+import { Storage } from "@cap/web-backend/src/Storage/index";
+import { type Organisation, type User, Video } from "@cap/web-domain";
+import { and, asc, eq, inArray, sql } from "drizzle-orm";
+import { Option } from "effect";
+import { start } from "workflow/api";
+import { runWorkflowPromise } from "@/lib/workflow-runtime";
+import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
+
+const DEFAULT_CONCURRENCY = 4;
+const MAX_CONCURRENCY = 32;
+const WAITING_FOR_CAPACITY = "Queued for Loom import";
+const DELETED_VIDEO_ERROR = "The imported Cap was deleted.";
+
+type VideoInsert = typeof videos.$inferInsert;
+
+type Writable = {
+ bucketId: NonNullable | null;
+ storageIntegrationId: NonNullable | null;
+};
+
+type Launch = {
+ itemId: string;
+ videoId: Video.VideoId;
+ ownerId: User.UserId;
+ rawFileKey: string;
+ bucketId: string | null;
+ loomVideoId: string;
+ reuseExistingRawUpload: boolean;
+ created: boolean;
+};
+
+export function loomImportConcurrency() {
+ const configured = Number.parseInt(
+ serverEnv().LOOM_IMPORT_CONCURRENCY ?? "",
+ 10,
+ );
+ return Number.isFinite(configured) && configured > 0
+ ? Math.min(configured, MAX_CONCURRENCY)
+ : DEFAULT_CONCURRENCY;
+}
+
+function isDuplicateKeyError(error: unknown): boolean {
+ let current: unknown = error;
+ for (let depth = 0; current && depth < 4; depth++) {
+ if (
+ typeof current === "object" &&
+ ("code" in current || "errno" in current) &&
+ ((current as { code?: unknown }).code === "ER_DUP_ENTRY" ||
+ (current as { errno?: unknown }).errno === 1062)
+ ) {
+ return true;
+ }
+ current = (current as { cause?: unknown }).cause;
+ }
+ return false;
+}
+
+export function rawFileKeyFor(ownerId: string, videoId: string) {
+ return `${ownerId}/${videoId}/raw-upload.mp4`;
+}
+
+async function writableFor(
+ cache: Map,
+ ownerId: User.UserId,
+ orgId: Organisation.OrganisationId,
+) {
+ if (cache.has(ownerId)) return cache.get(ownerId) ?? null;
+ const writable = await Storage.getWritableAccessForUser(ownerId, orgId)
+ .pipe(runWorkflowPromise)
+ .then(
+ (value) => ({
+ bucketId: Option.getOrNull(value.bucketId),
+ storageIntegrationId: Option.getOrNull(value.storageIntegrationId),
+ }),
+ (error) => {
+ console.error("[loom-import] Could not resolve storage", {
+ ownerId,
+ orgId,
+ error,
+ });
+ return null;
+ },
+ );
+ cache.set(ownerId, writable);
+ return writable;
+}
+
+export async function dispatchLoomImportJob(jobId: string) {
+ const [job] = await db()
+ .select({ orgId: loomImportJobs.orgId, status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .limit(1);
+ if (!job || job.status !== "importing") {
+ return { started: 0, completed: job?.status === "completed" };
+ }
+
+ const limit = loomImportConcurrency();
+ const isPublic = await getNewVideoPublic(job.orgId);
+ const storage = new Map();
+
+ const outcome = await db().transaction(async (tx) => {
+ const [locked] = await tx
+ .select({ status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .for("update");
+ if (!locked || locked.status !== "importing") {
+ return { launches: [] as Launch[], completed: false };
+ }
+
+ const inFlight = await tx
+ .select({
+ id: loomImportJobItems.id,
+ videoId: loomImportJobItems.videoId,
+ videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
+ uploadVideoId: videoUploads.videoId,
+ uploadPhase: videoUploads.phase,
+ uploadMessage: videoUploads.processingMessage,
+ uploadError: videoUploads.processingError,
+ })
+ .from(loomImportJobItems)
+ .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .leftJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "importing"),
+ ),
+ );
+
+ const now = new Date();
+ const completedIds: string[] = [];
+ const failures = new Map();
+ let active = 0;
+ let waiting = 0;
+ for (const row of inFlight) {
+ if (!row.videoId || !row.videoExists) {
+ failures.set(DELETED_VIDEO_ERROR, [
+ ...(failures.get(DELETED_VIDEO_ERROR) ?? []),
+ row.id,
+ ]);
+ } else if (!row.uploadVideoId) {
+ completedIds.push(row.id);
+ } else if (row.uploadPhase === "error") {
+ const error = (row.uploadError || "Loom import failed.").slice(0, 512);
+ failures.set(error, [...(failures.get(error) ?? []), row.id]);
+ } else {
+ active++;
+ if (row.uploadMessage?.startsWith(WAITING_FOR_CAPACITY)) waiting++;
+ }
+ }
+
+ if (completedIds.length > 0) {
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "complete", error: null, updatedAt: now })
+ .where(inArray(loomImportJobItems.id, completedIds));
+ }
+ for (const [error, ids] of failures) {
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "failed", error, updatedAt: now })
+ .where(inArray(loomImportJobItems.id, ids));
+ }
+
+ const launches: Launch[] = [];
+ while (waiting === 0 && active + launches.length < limit) {
+ const claimed = await tx
+ .select({
+ id: loomImportJobItems.id,
+ ownerId: loomImportJobItems.ownerId,
+ spaceId: loomImportJobItems.spaceId,
+ videoId: loomImportJobItems.videoId,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ title: loomImportJobItems.title,
+ loomCreatedAt: loomImportJobItems.loomCreatedAt,
+ durationSeconds: loomImportJobItems.durationSeconds,
+ width: loomImportJobItems.width,
+ height: loomImportJobItems.height,
+ })
+ .from(loomImportJobItems)
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "ready"),
+ ),
+ )
+ .orderBy(asc(loomImportJobItems.rowNumber))
+ .limit(limit - active - launches.length)
+ .for("update");
+ if (claimed.length === 0) break;
+
+ for (const item of claimed) {
+ if (!item.ownerId || !item.loomVideoId) {
+ await tx
+ .update(loomImportJobItems)
+ .set({
+ status: "failed",
+ error: "We couldn't find an owner for this video.",
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, item.id));
+ continue;
+ }
+
+ if (item.videoId) {
+ const [existing] = await tx
+ .select({
+ id: videos.id,
+ bucket: videos.bucket,
+ ownerId: videos.ownerId,
+ })
+ .from(videos)
+ .where(eq(videos.id, item.videoId))
+ .limit(1);
+ if (existing) {
+ const rawFileKey = rawFileKeyFor(existing.ownerId, existing.id);
+ await tx
+ .insert(videoUploads)
+ .values({
+ videoId: existing.id,
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Retrying Loom import...",
+ rawFileKey,
+ })
+ .onDuplicateKeyUpdate({
+ set: {
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Retrying Loom import...",
+ processingError: null,
+ rawFileKey,
+ updatedAt: now,
+ },
+ });
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "importing", error: null, updatedAt: now })
+ .where(eq(loomImportJobItems.id, item.id));
+ launches.push({
+ itemId: item.id,
+ videoId: existing.id,
+ ownerId: existing.ownerId,
+ rawFileKey,
+ bucketId: existing.bucket,
+ loomVideoId: item.loomVideoId,
+ reuseExistingRawUpload: true,
+ created: false,
+ });
+ continue;
+ }
+ }
+
+ const writable = await writableFor(storage, item.ownerId, job.orgId);
+ if (!writable) {
+ await tx
+ .update(loomImportJobItems)
+ .set({
+ status: "failed",
+ error: "We couldn't prepare storage for this video.",
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, item.id));
+ continue;
+ }
+
+ const videoId = Video.VideoId.make(nanoId());
+ const ownerId = item.ownerId;
+ const loomVideoId = item.loomVideoId;
+ const rawFileKey = rawFileKeyFor(ownerId, videoId);
+ const fallbackName = `Loom Import - ${now.toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`;
+ try {
+ await tx.transaction(async (savepoint) => {
+ await savepoint.insert(videos).values({
+ id: videoId,
+ name: item.title || fallbackName,
+ ownerId,
+ orgId: job.orgId,
+ source: { type: "webMP4" as const },
+ bucket: writable.bucketId,
+ storageIntegrationId: writable.storageIntegrationId,
+ public: isPublic,
+ ...(item.durationSeconds
+ ? { duration: item.durationSeconds }
+ : {}),
+ ...(item.width ? { width: item.width } : {}),
+ ...(item.height ? { height: item.height } : {}),
+ ...(item.loomCreatedAt
+ ? {
+ metadata: {
+ customCreatedAt: item.loomCreatedAt.toISOString(),
+ },
+ }
+ : {}),
+ });
+ await savepoint.insert(videoUploads).values({
+ videoId,
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Importing from Loom...",
+ rawFileKey,
+ });
+ await savepoint.insert(importedVideos).values({
+ id: videoId,
+ orgId: job.orgId,
+ source: "loom",
+ sourceId: loomVideoId,
+ });
+ if (item.spaceId) {
+ await savepoint.insert(spaceVideos).values({
+ id: nanoId(),
+ spaceId: item.spaceId,
+ videoId,
+ addedById: ownerId,
+ });
+ }
+ await savepoint
+ .update(loomImportJobItems)
+ .set({
+ status: "importing",
+ videoId,
+ error: null,
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, item.id));
+ });
+ } catch (error) {
+ if (!isDuplicateKeyError(error)) throw error;
+ const [existing] = await tx
+ .select({ id: importedVideos.id })
+ .from(importedVideos)
+ .where(
+ and(
+ eq(importedVideos.orgId, job.orgId),
+ eq(importedVideos.source, "loom"),
+ eq(importedVideos.sourceId, loomVideoId),
+ ),
+ )
+ .limit(1);
+ await tx
+ .update(loomImportJobItems)
+ .set({
+ status: "skipped",
+ videoId: existing ? Video.VideoId.make(existing.id) : null,
+ error: "Already in Cap.",
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, item.id));
+ continue;
+ }
+
+ launches.push({
+ itemId: item.id,
+ videoId,
+ ownerId,
+ rawFileKey,
+ bucketId: writable.bucketId,
+ loomVideoId,
+ reuseExistingRawUpload: false,
+ created: true,
+ });
+ }
+ }
+
+ let completed = false;
+ if (active + launches.length === 0) {
+ const [open] = await tx
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(loomImportJobItems)
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ inArray(loomImportJobItems.status, [
+ "pending",
+ "ready",
+ "importing",
+ ]),
+ ),
+ );
+ completed = (open?.count ?? 0) === 0;
+ }
+
+ await tx
+ .update(loomImportJobs)
+ .set(
+ completed
+ ? { status: "completed", completedAt: now, updatedAt: now }
+ : { updatedAt: now },
+ )
+ .where(eq(loomImportJobs.id, jobId));
+
+ return { launches, completed };
+ });
+
+ for (const launch of outcome.launches) {
+ try {
+ await start(importLoomVideoWorkflow, [
+ {
+ videoId: launch.videoId,
+ userId: launch.ownerId,
+ rawFileKey: launch.rawFileKey,
+ bucketId: launch.bucketId,
+ loomVideoId: launch.loomVideoId,
+ ...(launch.reuseExistingRawUpload
+ ? { reuseExistingRawUpload: true }
+ : {}),
+ },
+ ]);
+ } catch (error) {
+ console.error("[loom-import] Could not start import workflow", {
+ videoId: launch.videoId,
+ error,
+ });
+ const now = new Date();
+ await db()
+ .update(videoUploads)
+ .set({
+ phase: "error",
+ processingError: "Loom import could not start.",
+ processingMessage: "Loom import failed",
+ updatedAt: now,
+ })
+ .where(eq(videoUploads.videoId, launch.videoId));
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ status: "failed",
+ error: "Loom import could not start.",
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, launch.itemId));
+ }
+ }
+
+ if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") {
+ await Promise.all(
+ outcome.launches
+ .filter((launch) => launch.created)
+ .map((launch) =>
+ dub()
+ .links.create({
+ url: `${serverEnv().WEB_URL}/s/${launch.videoId}`,
+ domain: "cap.link",
+ key: launch.videoId,
+ })
+ .catch(() => undefined),
+ ),
+ );
+ }
+
+ return { started: outcome.launches.length, completed: outcome.completed };
+}
+
+export async function dispatchLoomImportForVideo(videoId: string) {
+ const [item] = await db()
+ .select({ jobId: loomImportJobItems.jobId })
+ .from(loomImportJobItems)
+ .where(
+ and(
+ eq(loomImportJobItems.videoId, Video.VideoId.make(videoId)),
+ eq(loomImportJobItems.status, "importing"),
+ ),
+ )
+ .limit(1);
+ if (!item) return null;
+ return dispatchLoomImportJob(item.jobId);
+}
+
+export async function isLoomImportedVideo(videoId: Video.VideoId) {
+ const [row] = await db()
+ .select({ id: importedVideos.id })
+ .from(importedVideos)
+ .where(
+ and(eq(importedVideos.id, videoId), eq(importedVideos.source, "loom")),
+ )
+ .limit(1);
+ return Boolean(row);
+}
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
new file mode 100644
index 00000000000..9d2f0c5c0b2
--- /dev/null
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -0,0 +1,954 @@
+import { db } from "@cap/database";
+import { nanoId } from "@cap/database/helpers";
+import {
+ importedVideos,
+ type LoomImportJobStatus,
+ loomImportJobItems,
+ loomImportJobs,
+ organizationMembers,
+ spaceMembers,
+ spaces,
+ users,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import { userIsPro } from "@cap/utils";
+import {
+ type Organisation,
+ Space,
+ SpaceMemberId,
+ User,
+ type Video,
+} from "@cap/web-domain";
+import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
+import { getOrganizationAccess } from "@/actions/organization/authorization";
+import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
+import { canManageOrganizationSettings } from "@/lib/permissions/roles";
+import {
+ extractLoomVideoId,
+ isValidImportEmail,
+ LOOM_IMPORT_MAX_ROWS,
+ LOOM_IMPORT_MAX_SPACE_NAME_LENGTH,
+ LOOM_IMPORT_MAX_URL_LENGTH,
+ normalizeImportEmail,
+ normalizeImportSpaceName,
+} from "./csv";
+import {
+ type LoomVideoLookup,
+ lookupLoomVideos,
+ loomLookupError,
+} from "./loom-api";
+
+const INSERT_CHUNK = 500;
+const IN_CHUNK = 500;
+
+export class LoomImportError extends Error {}
+
+export const LOOM_IMPORT_LIMIT_MESSAGE = `Each CSV can hold up to ${LOOM_IMPORT_MAX_ROWS.toLocaleString("en-US")} videos. Split your list into more than one CSV and import them one after another.`;
+
+type ItemInsert = typeof loomImportJobItems.$inferInsert;
+
+function chunk(values: T[], size: number) {
+ const chunks: T[][] = [];
+ for (let index = 0; index < values.length; index += size) {
+ chunks.push(values.slice(index, index + size));
+ }
+ return chunks;
+}
+
+function truncate(value: string, length: number) {
+ return value.length > length ? value.slice(0, length) : value;
+}
+
+export async function getLoomImportAccess(
+ userId: User.UserId,
+ orgId: Organisation.OrganisationId,
+) {
+ const access = await getOrganizationAccess(userId, orgId);
+ if (!access) return null;
+ return { ...access, isAdmin: canManageOrganizationSettings(access.role) };
+}
+
+export async function getLoomImportJobForUser(
+ jobId: string,
+ userId: User.UserId,
+) {
+ const [job] = await db()
+ .select()
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .limit(1);
+ if (!job) return null;
+ const access = await getLoomImportAccess(userId, job.orgId);
+ if (!access) return null;
+ if (job.createdById !== userId && !access.isAdmin) return null;
+ return { job, isAdmin: access.isAdmin };
+}
+
+type IncomingRow = {
+ rowNumber?: unknown;
+ loomUrl?: unknown;
+ ownerEmail?: unknown;
+ spaceName?: unknown;
+};
+
+export function planLoomImportItems(
+ jobId: string,
+ rows: unknown,
+ { isAdmin }: { isAdmin: boolean },
+): ItemInsert[] {
+ if (!Array.isArray(rows)) throw new LoomImportError("No Loom links found.");
+ const seen = new Map();
+ const usedRows = new Set();
+ const items: ItemInsert[] = [];
+ const now = new Date();
+
+ (rows as IncomingRow[]).forEach((row, index) => {
+ const candidateRow =
+ typeof row?.rowNumber === "number" &&
+ Number.isInteger(row.rowNumber) &&
+ row.rowNumber > 0
+ ? row.rowNumber
+ : index + 1;
+ const rowNumber = usedRows.has(candidateRow)
+ ? Math.max(...usedRows) + 1
+ : candidateRow;
+ usedRows.add(rowNumber);
+
+ const loomUrl = typeof row?.loomUrl === "string" ? row.loomUrl.trim() : "";
+ const ownerEmail =
+ isAdmin && typeof row?.ownerEmail === "string"
+ ? normalizeImportEmail(row.ownerEmail)
+ : "";
+ const spaceName =
+ isAdmin && typeof row?.spaceName === "string"
+ ? normalizeImportSpaceName(row.spaceName)
+ : "";
+
+ const base: ItemInsert = {
+ id: nanoId(),
+ jobId,
+ rowNumber,
+ loomUrl: truncate(loomUrl || "(empty)", LOOM_IMPORT_MAX_URL_LENGTH),
+ ownerEmail: ownerEmail ? truncate(ownerEmail, 255) : null,
+ spaceName: spaceName
+ ? truncate(spaceName, LOOM_IMPORT_MAX_SPACE_NAME_LENGTH)
+ : null,
+ status: "pending",
+ updatedAt: now,
+ };
+
+ const loomVideoId =
+ loomUrl.length <= LOOM_IMPORT_MAX_URL_LENGTH
+ ? extractLoomVideoId(loomUrl)
+ : null;
+ if (!loomVideoId) {
+ items.push({
+ ...base,
+ status: "failed",
+ error: "This isn't a Loom link.",
+ });
+ return;
+ }
+ if (ownerEmail && !isValidImportEmail(ownerEmail)) {
+ items.push({
+ ...base,
+ loomVideoId,
+ status: "failed",
+ error: "The owner email looks wrong.",
+ });
+ return;
+ }
+ if (spaceName.length > LOOM_IMPORT_MAX_SPACE_NAME_LENGTH) {
+ items.push({
+ ...base,
+ loomVideoId,
+ status: "failed",
+ error: `Space names can be up to ${LOOM_IMPORT_MAX_SPACE_NAME_LENGTH} characters.`,
+ });
+ return;
+ }
+ const duplicateOf = seen.get(loomVideoId);
+ if (duplicateOf !== undefined) {
+ items.push({
+ ...base,
+ loomVideoId,
+ status: "skipped",
+ error: `Same video as row ${duplicateOf}.`,
+ });
+ return;
+ }
+ seen.set(loomVideoId, rowNumber);
+ items.push({ ...base, loomVideoId });
+ });
+
+ if (items.length === 0) throw new LoomImportError("No Loom links found.");
+ if (items.length > LOOM_IMPORT_MAX_ROWS)
+ throw new LoomImportError(LOOM_IMPORT_LIMIT_MESSAGE);
+ return items;
+}
+
+export async function createLoomImportJob({
+ userId,
+ orgId,
+ fileName,
+ rows,
+}: {
+ userId: User.UserId;
+ orgId: Organisation.OrganisationId;
+ fileName: string;
+ rows: unknown;
+}) {
+ const access = await getLoomImportAccess(userId, orgId);
+ if (!access)
+ throw new LoomImportError("You don't have access to this organization.");
+
+ const jobId = nanoId();
+ const items = planLoomImportItems(jobId, rows, { isAdmin: access.isAdmin });
+ const now = new Date();
+ const name = truncate(fileName.trim() || "Loom links", 255);
+
+ await db().transaction(async (tx) => {
+ await tx.insert(loomImportJobs).values({
+ id: jobId,
+ orgId,
+ createdById: userId,
+ fileName: name,
+ status: "checking",
+ totalCount: items.length,
+ createdAt: now,
+ updatedAt: now,
+ });
+ for (const values of chunk(items, INSERT_CHUNK)) {
+ await tx.insert(loomImportJobItems).values(values);
+ }
+ });
+
+ return { jobId, totalCount: items.length };
+}
+
+type PendingItem = {
+ id: string;
+ jobId: string;
+ rowNumber: number;
+ loomUrl: string;
+ loomVideoId: string | null;
+};
+
+// MySQL applies these assignments in column order, so `status` may already hold
+// the new value when the other columns are evaluated. Accept either state.
+const whilePending = (column: string) =>
+ sql.raw(
+ `IF(\`status\` IN ('pending', VALUES(\`status\`)), VALUES(\`${column}\`), \`${column}\`)`,
+ );
+
+async function writeItemUpdates(rows: ItemInsert[]) {
+ if (rows.length === 0) return;
+ for (const values of chunk(rows, INSERT_CHUNK)) {
+ await db()
+ .insert(loomImportJobItems)
+ .values(values)
+ .onDuplicateKeyUpdate({
+ set: {
+ title: whilePending("title"),
+ loomCreatedAt: whilePending("loom_created_at"),
+ durationSeconds: whilePending("duration_seconds"),
+ width: whilePending("width"),
+ height: whilePending("height"),
+ thumbnailUrl: whilePending("thumbnail_url"),
+ videoId: whilePending("video_id"),
+ error: whilePending("error"),
+ updatedAt: whilePending("updated_at"),
+ status: sql.raw(
+ "IF(`status` = 'pending', VALUES(`status`), `status`)",
+ ),
+ },
+ });
+ }
+}
+
+function resolvedItem(
+ item: PendingItem,
+ lookup: LoomVideoLookup,
+ now: Date,
+): ItemInsert {
+ const base: ItemInsert = {
+ id: item.id,
+ jobId: item.jobId,
+ rowNumber: item.rowNumber,
+ loomUrl: item.loomUrl,
+ updatedAt: now,
+ };
+ if (lookup.status !== "ok") {
+ return { ...base, status: "failed", error: loomLookupError(lookup) };
+ }
+ return {
+ ...base,
+ status: "ready",
+ title: lookup.title,
+ loomCreatedAt: lookup.createdAt ? new Date(lookup.createdAt) : null,
+ durationSeconds: lookup.durationSeconds,
+ width: lookup.width ? Math.round(lookup.width) : null,
+ height: lookup.height ? Math.round(lookup.height) : null,
+ thumbnailUrl: lookup.thumbnailUrl
+ ? truncate(lookup.thumbnailUrl, 1024)
+ : null,
+ error: null,
+ };
+}
+
+export async function resolveLoomImportJob(
+ jobId: string,
+ options: { fetchImpl?: typeof fetch } = {},
+) {
+ const [job] = await db()
+ .select({ orgId: loomImportJobs.orgId, status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .limit(1);
+ if (!job || job.status === "cancelled" || job.status === "completed") return;
+
+ const pending: PendingItem[] = await db()
+ .select({
+ id: loomImportJobItems.id,
+ jobId: loomImportJobItems.jobId,
+ rowNumber: loomImportJobItems.rowNumber,
+ loomUrl: loomImportJobItems.loomUrl,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ })
+ .from(loomImportJobItems)
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "pending"),
+ ),
+ )
+ .orderBy(asc(loomImportJobItems.rowNumber));
+ if (pending.length === 0) return;
+
+ const loomIds = Array.from(
+ new Set(
+ pending.flatMap((item) => (item.loomVideoId ? [item.loomVideoId] : [])),
+ ),
+ );
+ const liveImports = new Map();
+ const staleImportIds: string[] = [];
+ for (const ids of chunk(loomIds, IN_CHUNK)) {
+ const existing = await db()
+ .select({
+ importId: importedVideos.id,
+ sourceId: importedVideos.sourceId,
+ videoId: videos.id,
+ })
+ .from(importedVideos)
+ .leftJoin(
+ videos,
+ and(
+ eq(videos.id, importedVideos.id),
+ eq(videos.orgId, importedVideos.orgId),
+ ),
+ )
+ .where(
+ and(
+ eq(importedVideos.orgId, job.orgId),
+ eq(importedVideos.source, "loom"),
+ inArray(importedVideos.sourceId, ids),
+ ),
+ );
+ for (const row of existing) {
+ if (row.videoId) liveImports.set(row.sourceId, row.videoId);
+ else staleImportIds.push(row.importId);
+ }
+ }
+
+ for (const ids of chunk(staleImportIds, IN_CHUNK)) {
+ await db()
+ .delete(importedVideos)
+ .where(
+ and(
+ eq(importedVideos.orgId, job.orgId),
+ eq(importedVideos.source, "loom"),
+ inArray(importedVideos.id, ids),
+ ),
+ );
+ }
+
+ const now = new Date();
+ const skipped: ItemInsert[] = [];
+ const toLookup: PendingItem[] = [];
+ for (const item of pending) {
+ const existingVideo = item.loomVideoId
+ ? liveImports.get(item.loomVideoId)
+ : undefined;
+ if (existingVideo) {
+ skipped.push({
+ id: item.id,
+ jobId: item.jobId,
+ rowNumber: item.rowNumber,
+ loomUrl: item.loomUrl,
+ status: "skipped",
+ videoId: existingVideo,
+ error: "Already in Cap.",
+ updatedAt: now,
+ });
+ } else {
+ toLookup.push(item);
+ }
+ }
+ await writeItemUpdates(skipped);
+
+ const itemsByLoomId = new Map();
+ for (const item of toLookup) {
+ if (!item.loomVideoId) continue;
+ const list = itemsByLoomId.get(item.loomVideoId) ?? [];
+ list.push(item);
+ itemsByLoomId.set(item.loomVideoId, list);
+ }
+
+ await lookupLoomVideos(Array.from(itemsByLoomId.keys()), {
+ fetchImpl: options.fetchImpl,
+ onBatch: async (results) => {
+ const updates: ItemInsert[] = [];
+ const batchTime = new Date();
+ for (const [loomVideoId, lookup] of results) {
+ for (const item of itemsByLoomId.get(loomVideoId) ?? []) {
+ updates.push(resolvedItem(item, lookup, batchTime));
+ }
+ }
+ await writeItemUpdates(updates);
+ },
+ });
+
+ await db()
+ .update(loomImportJobs)
+ .set({ updatedAt: new Date() })
+ .where(eq(loomImportJobs.id, jobId));
+}
+
+async function resolveImportOwners({
+ orgId,
+ creator,
+ emails,
+}: {
+ orgId: Organisation.OrganisationId;
+ creator: { id: User.UserId; email: string };
+ emails: string[];
+}) {
+ const owners = new Map();
+ owners.set(creator.email.toLowerCase(), creator.id);
+ const remaining = emails.filter((email) => !owners.has(email));
+
+ for (const batch of chunk(remaining, IN_CHUNK)) {
+ const members = await db()
+ .select({ userId: organizationMembers.userId, email: users.email })
+ .from(organizationMembers)
+ .innerJoin(users, eq(organizationMembers.userId, users.id))
+ .where(
+ and(
+ eq(organizationMembers.organizationId, orgId),
+ inArray(users.email, batch),
+ ),
+ );
+ for (const member of members) {
+ owners.set(member.email.toLowerCase(), member.userId);
+ }
+ }
+
+ const failed = new Set();
+ for (const email of remaining) {
+ if (owners.has(email)) continue;
+ try {
+ const provisioned = await provisionOrganizationInvitee({
+ organizationId: orgId,
+ email,
+ invitedByUserId: creator.id,
+ role: "member",
+ });
+ owners.set(email, User.UserId.make(provisioned.userId));
+ } catch {
+ failed.add(email);
+ }
+ }
+ return { owners, failed };
+}
+
+async function resolveImportSpaces({
+ orgId,
+ creatorId,
+ names,
+}: {
+ orgId: Organisation.OrganisationId;
+ creatorId: User.UserId;
+ names: string[];
+}) {
+ const byKey = new Map();
+ if (names.length === 0) return byKey;
+
+ const existing = await db()
+ .select({ id: spaces.id, name: spaces.name })
+ .from(spaces)
+ .where(eq(spaces.organizationId, orgId));
+ for (const space of existing) {
+ const key = normalizeImportSpaceName(space.name).toLowerCase();
+ if (!byKey.has(key)) byKey.set(key, space.id);
+ }
+
+ for (const name of names) {
+ const key = name.toLowerCase();
+ if (byKey.has(key)) continue;
+ const spaceId = Space.SpaceId.make(nanoId());
+ await db().transaction(async (tx) => {
+ await tx.insert(spaces).values({
+ id: spaceId,
+ name,
+ organizationId: orgId,
+ createdById: creatorId,
+ iconUrl: null,
+ });
+ await tx.insert(spaceMembers).values({
+ id: SpaceMemberId.make(nanoId()),
+ spaceId,
+ userId: creatorId,
+ role: "admin",
+ });
+ });
+ byKey.set(key, spaceId);
+ }
+ return byKey;
+}
+
+async function ensureSpaceMembers(
+ pairs: Map>,
+) {
+ for (const [spaceId, userIds] of pairs) {
+ const ids = Array.from(userIds);
+ const existing = await db()
+ .select({ userId: spaceMembers.userId })
+ .from(spaceMembers)
+ .where(
+ and(
+ eq(spaceMembers.spaceId, spaceId),
+ inArray(spaceMembers.userId, ids),
+ ),
+ );
+ const present = new Set(existing.map((row) => row.userId));
+ const missing = ids.filter((id) => !present.has(id));
+ if (missing.length === 0) continue;
+ await db()
+ .insert(spaceMembers)
+ .values(
+ missing.map((userId) => ({
+ id: SpaceMemberId.make(nanoId()),
+ spaceId,
+ userId,
+ role: "member" as const,
+ })),
+ );
+ }
+}
+
+export async function prepareLoomImportJob(
+ jobId: string,
+): Promise {
+ const [job] = await db()
+ .select()
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .limit(1);
+ if (!job) return null;
+ if (job.status === "cancelled" || job.status === "completed")
+ return job.status;
+
+ const [creator] = await db()
+ .select()
+ .from(users)
+ .where(eq(users.id, job.createdById))
+ .limit(1);
+
+ if (!creator || !userIsPro(creator)) {
+ await db()
+ .update(loomImportJobs)
+ .set({ status: "awaiting_upgrade", updatedAt: new Date() })
+ .where(
+ and(
+ eq(loomImportJobs.id, jobId),
+ inArray(loomImportJobs.status, ["checking", "importing"]),
+ ),
+ );
+ return "awaiting_upgrade";
+ }
+
+ const access = await getLoomImportAccess(creator.id, job.orgId);
+ const unassigned = await db()
+ .select({
+ id: loomImportJobItems.id,
+ ownerEmail: loomImportJobItems.ownerEmail,
+ spaceName: loomImportJobItems.spaceName,
+ })
+ .from(loomImportJobItems)
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "ready"),
+ isNull(loomImportJobItems.ownerId),
+ ),
+ );
+
+ if (!access) {
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ status: "failed",
+ error: "You no longer have access to this organization.",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ inArray(loomImportJobItems.status, ["pending", "ready"]),
+ ),
+ );
+ await db()
+ .update(loomImportJobs)
+ .set({
+ status: "completed",
+ completedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(loomImportJobs.id, jobId));
+ return "completed";
+ }
+
+ if (unassigned.length > 0) {
+ const emails = access.isAdmin
+ ? Array.from(
+ new Set(
+ unassigned.flatMap((item) =>
+ item.ownerEmail ? [item.ownerEmail.toLowerCase()] : [],
+ ),
+ ),
+ )
+ : [];
+ const spaceNamesByKey = new Map();
+ if (access.isAdmin) {
+ for (const item of unassigned) {
+ const key = item.spaceName?.toLowerCase();
+ if (key && item.spaceName && !spaceNamesByKey.has(key)) {
+ spaceNamesByKey.set(key, item.spaceName);
+ }
+ }
+ }
+ const spaceNames = Array.from(spaceNamesByKey.values());
+
+ const { owners, failed } = await resolveImportOwners({
+ orgId: job.orgId,
+ creator: { id: creator.id, email: creator.email },
+ emails,
+ });
+ const spaceByKey = await resolveImportSpaces({
+ orgId: job.orgId,
+ creatorId: creator.id,
+ names: spaceNames,
+ });
+
+ const groups = new Map<
+ string,
+ {
+ ownerId: User.UserId;
+ spaceId: Space.SpaceIdOrOrganisationId | null;
+ ids: string[];
+ }
+ >();
+ const failedByEmail = new Map();
+ const spacePairs = new Map<
+ Space.SpaceIdOrOrganisationId,
+ Set
+ >();
+
+ for (const item of unassigned) {
+ const email =
+ access.isAdmin && item.ownerEmail
+ ? item.ownerEmail.toLowerCase()
+ : null;
+ if (email && failed.has(email)) {
+ const list = failedByEmail.get(email) ?? [];
+ list.push(item.id);
+ failedByEmail.set(email, list);
+ continue;
+ }
+ const ownerId = email ? owners.get(email) : creator.id;
+ if (!ownerId) {
+ const list = failedByEmail.get(email ?? "") ?? [];
+ list.push(item.id);
+ failedByEmail.set(email ?? "", list);
+ continue;
+ }
+ const spaceId =
+ access.isAdmin && item.spaceName
+ ? (spaceByKey.get(item.spaceName.toLowerCase()) ?? null)
+ : null;
+ if (spaceId && ownerId !== creator.id) {
+ const members = spacePairs.get(spaceId) ?? new Set();
+ members.add(ownerId);
+ spacePairs.set(spaceId, members);
+ }
+ const key = `${ownerId}|${spaceId ?? ""}`;
+ const group = groups.get(key) ?? { ownerId, spaceId, ids: [] };
+ group.ids.push(item.id);
+ groups.set(key, group);
+ }
+
+ await ensureSpaceMembers(spacePairs);
+
+ const now = new Date();
+ for (const group of groups.values()) {
+ for (const ids of chunk(group.ids, IN_CHUNK)) {
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ ownerId: group.ownerId,
+ spaceId: group.spaceId,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ inArray(loomImportJobItems.id, ids),
+ eq(loomImportJobItems.status, "ready"),
+ ),
+ );
+ }
+ }
+ for (const [email, ids] of failedByEmail) {
+ for (const batch of chunk(ids, IN_CHUNK)) {
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ status: "failed",
+ error: email
+ ? `We couldn't add ${email} to your organization.`
+ : "We couldn't find an owner for this video.",
+ updatedAt: now,
+ })
+ .where(inArray(loomImportJobItems.id, batch));
+ }
+ }
+ }
+
+ await db()
+ .update(loomImportJobs)
+ .set({
+ status: "importing",
+ startedAt: job.startedAt ?? new Date(),
+ completedAt: null,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomImportJobs.id, jobId),
+ inArray(loomImportJobs.status, [
+ "checking",
+ "awaiting_upgrade",
+ "importing",
+ ]),
+ ),
+ );
+ return "importing";
+}
+
+export async function markLoomImportJobStarting(jobId: string) {
+ const result = await db()
+ .update(loomImportJobs)
+ .set({ status: "checking", updatedAt: new Date() })
+ .where(
+ and(
+ eq(loomImportJobs.id, jobId),
+ eq(loomImportJobs.status, "awaiting_upgrade"),
+ ),
+ );
+ return affectedRows(result) > 0;
+}
+
+export async function cancelLoomImportJob(jobId: string) {
+ await db().transaction(async (tx) => {
+ const [job] = await tx
+ .select({ status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .for("update");
+ if (!job || job.status === "completed" || job.status === "cancelled")
+ return;
+ const now = new Date();
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "cancelled", updatedAt: now })
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ inArray(loomImportJobItems.status, ["pending", "ready"]),
+ ),
+ );
+ await tx
+ .update(loomImportJobs)
+ .set({ status: "cancelled", completedAt: now, updatedAt: now })
+ .where(eq(loomImportJobs.id, jobId));
+ });
+}
+
+export async function resetFailedLoomImportItems(jobId: string) {
+ const failed = await db()
+ .select({
+ id: loomImportJobItems.id,
+ videoId: loomImportJobItems.videoId,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
+ uploadPhase: videoUploads.phase,
+ uploadVideoId: videoUploads.videoId,
+ })
+ .from(loomImportJobItems)
+ .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .leftJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ inArray(loomImportJobItems.status, ["failed", "importing", "complete"]),
+ ),
+ );
+
+ const toPending: string[] = [];
+ const toReady: string[] = [];
+ for (const item of failed) {
+ if (!item.loomVideoId) continue;
+ if (!item.videoId || !item.videoExists) {
+ toPending.push(item.id);
+ } else if (item.uploadPhase === "error") {
+ toReady.push(item.id);
+ }
+ }
+
+ const now = new Date();
+ for (const ids of chunk(toPending, IN_CHUNK)) {
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ status: "pending",
+ videoId: null,
+ ownerId: null,
+ spaceId: null,
+ error: null,
+ updatedAt: now,
+ })
+ .where(inArray(loomImportJobItems.id, ids));
+ }
+ for (const ids of chunk(toReady, IN_CHUNK)) {
+ await db()
+ .update(loomImportJobItems)
+ .set({ status: "ready", error: null, updatedAt: now })
+ .where(inArray(loomImportJobItems.id, ids));
+ }
+
+ const retried = toPending.length + toReady.length;
+ if (retried > 0) {
+ await db()
+ .update(loomImportJobs)
+ .set({
+ status: "checking",
+ completedAt: null,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(loomImportJobs.id, jobId),
+ inArray(loomImportJobs.status, ["importing", "completed"]),
+ ),
+ );
+ }
+ return retried;
+}
+
+export type LoomImportJobSummary = {
+ id: string;
+ fileName: string;
+ status: LoomImportJobStatus;
+ totalCount: number;
+ createdAt: string;
+ completedAt: string | null;
+ imported: number;
+ failed: number;
+ settled: number;
+};
+
+export async function listLoomImportJobs({
+ userId,
+ orgId,
+ limit = 8,
+}: {
+ userId: User.UserId;
+ orgId: Organisation.OrganisationId;
+ limit?: number;
+}): Promise {
+ const jobs = await db()
+ .select()
+ .from(loomImportJobs)
+ .where(
+ and(
+ eq(loomImportJobs.orgId, orgId),
+ eq(loomImportJobs.createdById, userId),
+ ),
+ )
+ .orderBy(desc(loomImportJobs.createdAt))
+ .limit(limit);
+ if (jobs.length === 0) return [];
+
+ const counts = await db()
+ .select({
+ jobId: loomImportJobItems.jobId,
+ status: loomImportJobItems.status,
+ count: sql`COUNT(*)`.mapWith(Number),
+ })
+ .from(loomImportJobItems)
+ .where(
+ inArray(
+ loomImportJobItems.jobId,
+ jobs.map((job) => job.id),
+ ),
+ )
+ .groupBy(loomImportJobItems.jobId, loomImportJobItems.status);
+
+ return jobs.map((job) => {
+ const byStatus = new Map(
+ counts
+ .filter((row) => row.jobId === job.id)
+ .map((row) => [row.status, row.count] as const),
+ );
+ const imported = byStatus.get("complete") ?? 0;
+ const failed = byStatus.get("failed") ?? 0;
+ const settled =
+ imported +
+ failed +
+ (byStatus.get("skipped") ?? 0) +
+ (byStatus.get("cancelled") ?? 0);
+ return {
+ id: job.id,
+ fileName: job.fileName,
+ status: job.status,
+ totalCount: job.totalCount,
+ createdAt: job.createdAt.toISOString(),
+ completedAt: job.completedAt?.toISOString() ?? null,
+ imported,
+ failed,
+ settled,
+ };
+ });
+}
+
+export function affectedRows(result: unknown) {
+ if (Array.isArray(result)) {
+ return (
+ (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
+ );
+ }
+ return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
+}
diff --git a/apps/web/lib/loom-import/loom-api.ts b/apps/web/lib/loom-import/loom-api.ts
new file mode 100644
index 00000000000..4475a0b2a81
--- /dev/null
+++ b/apps/web/lib/loom-import/loom-api.ts
@@ -0,0 +1,189 @@
+export type LoomVideoLookup =
+ | {
+ status: "ok";
+ title: string | null;
+ createdAt: string | null;
+ durationSeconds: number | null;
+ width: number | null;
+ height: number | null;
+ thumbnailUrl: string | null;
+ }
+ | { status: "private" }
+ | { status: "password" }
+ | { status: "not_found" }
+ | { status: "error" };
+
+type LookupOptions = {
+ batchSize?: number;
+ concurrency?: number;
+ attempts?: number;
+ fetchImpl?: typeof fetch;
+ onBatch?: (results: Map) => Promise | void;
+};
+
+type GraphqlVideo = {
+ __typename?: string;
+ name?: string | null;
+ createdAt?: string | null;
+ thumbnails?: { default?: string | null } | null;
+ video_properties?: {
+ duration?: number | null;
+ width?: number | null;
+ height?: number | null;
+ } | null;
+} | null;
+
+const LOOM_GRAPHQL_URL = "https://www.loom.com/graphql";
+const VIDEO_FIELDS = `__typename
+... on RegularUserVideo { name createdAt thumbnails { default } video_properties { duration width height } }
+... on PrivateVideo { id }
+... on VideoPasswordMissingOrIncorrect { id }`;
+
+function positiveNumber(value: unknown) {
+ return typeof value === "number" && Number.isFinite(value) && value > 0
+ ? value
+ : null;
+}
+
+function validIsoDate(value: unknown) {
+ if (typeof value !== "string") return null;
+ return Number.isNaN(Date.parse(value)) ? null : value;
+}
+
+function httpsUrl(value: unknown) {
+ if (typeof value !== "string") return null;
+ try {
+ return new URL(value).protocol === "https:" ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+export function toLoomVideoLookup(video: GraphqlVideo): LoomVideoLookup {
+ if (!video) return { status: "not_found" };
+ switch (video.__typename) {
+ case "RegularUserVideo":
+ return {
+ status: "ok",
+ title: video.name?.trim().slice(0, 255) || null,
+ createdAt: validIsoDate(video.createdAt),
+ durationSeconds: positiveNumber(video.video_properties?.duration),
+ width: positiveNumber(video.video_properties?.width),
+ height: positiveNumber(video.video_properties?.height),
+ thumbnailUrl: httpsUrl(video.thumbnails?.default),
+ };
+ case "PrivateVideo":
+ return { status: "private" };
+ case "VideoPasswordMissingOrIncorrect":
+ return { status: "password" };
+ default:
+ return { status: "not_found" };
+ }
+}
+
+export function buildLoomVideosQuery(ids: string[]) {
+ const variables = ids.map((_, index) => `$id${index}: ID!`).join(", ");
+ const selections = ids
+ .map(
+ (_, index) =>
+ `v${index}: getVideo(id: $id${index}, password: null) { ${VIDEO_FIELDS} }`,
+ )
+ .join("\n");
+ return {
+ operationName: "CapImportVideos",
+ query: `query CapImportVideos(${variables}) {\n${selections}\n}`,
+ variables: Object.fromEntries(ids.map((id, index) => [`id${index}`, id])),
+ };
+}
+
+async function lookupBatch(
+ ids: string[],
+ fetchImpl: typeof fetch,
+ attempts: number,
+): Promise> {
+ const body = JSON.stringify(buildLoomVideosQuery(ids));
+ for (let attempt = 0; attempt < attempts; attempt++) {
+ try {
+ const response = await fetchImpl(LOOM_GRAPHQL_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "x-loom-request-source": "loom_web",
+ },
+ body,
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (response.ok) {
+ const json = (await response.json()) as {
+ data?: Record | null;
+ };
+ if (json.data) {
+ const results = new Map();
+ ids.forEach((id, index) => {
+ results.set(
+ id,
+ toLoomVideoLookup(json.data?.[`v${index}`] ?? null),
+ );
+ });
+ return results;
+ }
+ } else if (response.status < 500 && response.status !== 429) {
+ break;
+ }
+ } catch {}
+ if (attempt < attempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, 400 * 2 ** attempt));
+ }
+ }
+ return new Map(ids.map((id) => [id, { status: "error" } as const]));
+}
+
+export async function lookupLoomVideos(
+ ids: string[],
+ {
+ batchSize = 25,
+ concurrency = 4,
+ attempts = 3,
+ fetchImpl = fetch,
+ onBatch,
+ }: LookupOptions = {},
+): Promise> {
+ const unique = Array.from(new Set(ids));
+ const batches: string[][] = [];
+ for (let index = 0; index < unique.length; index += batchSize) {
+ batches.push(unique.slice(index, index + batchSize));
+ }
+
+ const results = new Map();
+ let next = 0;
+ const workers = Array.from(
+ { length: Math.min(concurrency, batches.length) },
+ async () => {
+ while (next < batches.length) {
+ const batch = batches[next++];
+ if (!batch) continue;
+ const batchResults = await lookupBatch(batch, fetchImpl, attempts);
+ for (const [id, lookup] of batchResults) results.set(id, lookup);
+ await onBatch?.(batchResults);
+ }
+ },
+ );
+ await Promise.all(workers);
+ return results;
+}
+
+export function loomLookupError(lookup: LoomVideoLookup) {
+ switch (lookup.status) {
+ case "private":
+ return "This Loom is private. Ask the owner to share it with anyone who has the link.";
+ case "password":
+ return "This Loom is password protected.";
+ case "not_found":
+ return "We couldn't find this Loom. It may have been deleted.";
+ case "error":
+ return "Loom didn't respond. Try again in a minute.";
+ default:
+ return null;
+ }
+}
diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts
new file mode 100644
index 00000000000..a640491ed4a
--- /dev/null
+++ b/apps/web/lib/loom-import/recovery.ts
@@ -0,0 +1,110 @@
+import { db } from "@cap/database";
+import {
+ loomImportJobItems,
+ loomImportJobs,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import { and, eq, lt } from "drizzle-orm";
+import { start } from "workflow/api";
+import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
+import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
+import { dispatchLoomImportJob, rawFileKeyFor } from "./dispatch";
+
+const STALE_CHECKING_MS = 10 * 60 * 1000;
+const STALE_IMPORTING_MS = 2 * 60 * 1000;
+const STUCK_START_MS = 30 * 60 * 1000;
+
+export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
+ const staleChecking = await db()
+ .select({ id: loomImportJobs.id })
+ .from(loomImportJobs)
+ .where(
+ and(
+ eq(loomImportJobs.status, "checking"),
+ lt(
+ loomImportJobs.updatedAt,
+ new Date(now.getTime() - STALE_CHECKING_MS),
+ ),
+ ),
+ )
+ .limit(limit);
+ for (const job of staleChecking) {
+ await db()
+ .update(loomImportJobs)
+ .set({ updatedAt: now })
+ .where(eq(loomImportJobs.id, job.id));
+ await start(loomImportJobWorkflow, [{ jobId: job.id }]);
+ }
+
+ const staleImporting = await db()
+ .select({ id: loomImportJobs.id })
+ .from(loomImportJobs)
+ .where(
+ and(
+ eq(loomImportJobs.status, "importing"),
+ lt(
+ loomImportJobs.updatedAt,
+ new Date(now.getTime() - STALE_IMPORTING_MS),
+ ),
+ ),
+ )
+ .limit(limit);
+
+ let restarted = 0;
+ for (const job of staleImporting) {
+ const stuck = await db()
+ .select({
+ videoId: videos.id,
+ ownerId: videos.ownerId,
+ bucket: videos.bucket,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ rawFileKey: videoUploads.rawFileKey,
+ })
+ .from(loomImportJobItems)
+ .innerJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .innerJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, job.id),
+ eq(loomImportJobItems.status, "importing"),
+ eq(videoUploads.phase, "uploading"),
+ lt(videoUploads.updatedAt, new Date(now.getTime() - STUCK_START_MS)),
+ ),
+ );
+ for (const row of stuck) {
+ if (!row.loomVideoId) continue;
+ const rawFileKey =
+ row.rawFileKey ?? rawFileKeyFor(row.ownerId, row.videoId);
+ await db()
+ .update(videoUploads)
+ .set({
+ processingMessage: "Retrying Loom import...",
+ rawFileKey,
+ updatedAt: now,
+ })
+ .where(eq(videoUploads.videoId, row.videoId));
+ await start(importLoomVideoWorkflow, [
+ {
+ videoId: row.videoId,
+ userId: row.ownerId,
+ rawFileKey,
+ bucketId: row.bucket,
+ loomVideoId: row.loomVideoId,
+ reuseExistingRawUpload: true,
+ },
+ ]);
+ restarted++;
+ }
+ await dispatchLoomImportJob(job.id);
+ }
+
+ return {
+ checking: staleChecking.length,
+ importing: staleImporting.length,
+ restarted,
+ };
+}
diff --git a/apps/web/lib/loom-import/report.ts b/apps/web/lib/loom-import/report.ts
new file mode 100644
index 00000000000..461ab76d76d
--- /dev/null
+++ b/apps/web/lib/loom-import/report.ts
@@ -0,0 +1,47 @@
+import type { LoomImportItemView } from "./status";
+
+const COLUMNS = [
+ "row",
+ "loom_url",
+ "title",
+ "recorded_at",
+ "owner_email",
+ "space",
+ "status",
+ "cap_url",
+ "note",
+] as const;
+
+function escapeCell(value: string) {
+ const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
+ return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
+}
+
+export function buildLoomImportReport(
+ items: readonly LoomImportItemView[],
+ origin: string,
+) {
+ const lines = [COLUMNS.join(",")];
+ for (const item of items) {
+ const capUrl =
+ item.videoId && (item.status === "imported" || item.status === "skipped")
+ ? `${origin}/s/${item.videoId}`
+ : "";
+ lines.push(
+ [
+ String(item.row),
+ item.url,
+ item.title ?? "",
+ item.recordedAt ?? "",
+ item.email ?? "",
+ item.space ?? "",
+ item.status,
+ capUrl,
+ item.error ?? "",
+ ]
+ .map(escapeCell)
+ .join(","),
+ );
+ }
+ return `${lines.join("\n")}\n`;
+}
diff --git a/apps/web/lib/loom-import/snapshot.ts b/apps/web/lib/loom-import/snapshot.ts
new file mode 100644
index 00000000000..c12b44cad24
--- /dev/null
+++ b/apps/web/lib/loom-import/snapshot.ts
@@ -0,0 +1,161 @@
+import { db } from "@cap/database";
+import {
+ loomImportJobItems,
+ users,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import { userIsPro } from "@cap/utils";
+import type { User } from "@cap/web-domain";
+import { asc, eq, sql } from "drizzle-orm";
+import { getLoomImportJobForUser } from "./jobs";
+import {
+ countLoomImportItems,
+ deriveLoomImportItemState,
+ type LoomImportItemView,
+ type LoomImportSnapshot,
+} from "./status";
+
+const CURSOR_OVERLAP_MS = 5_000;
+
+function time(value: Date | null | undefined) {
+ return value ? value.getTime() : 0;
+}
+
+export async function getLoomImportSnapshot({
+ jobId,
+ userId,
+ since,
+}: {
+ jobId: string;
+ userId: User.UserId;
+ since?: number;
+}): Promise {
+ const found = await getLoomImportJobForUser(jobId, userId);
+ if (!found) return null;
+ const { job, isAdmin } = found;
+
+ const [[viewer], rows] = await Promise.all([
+ db()
+ .select({
+ stripeSubscriptionStatus: users.stripeSubscriptionStatus,
+ thirdPartyStripeSubscriptionId: users.thirdPartyStripeSubscriptionId,
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1),
+ db()
+ .select({
+ id: loomImportJobItems.id,
+ rowNumber: loomImportJobItems.rowNumber,
+ loomUrl: loomImportJobItems.loomUrl,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ ownerEmail: loomImportJobItems.ownerEmail,
+ spaceName: loomImportJobItems.spaceName,
+ status: loomImportJobItems.status,
+ videoId: loomImportJobItems.videoId,
+ title: loomImportJobItems.title,
+ loomCreatedAt: loomImportJobItems.loomCreatedAt,
+ durationSeconds: loomImportJobItems.durationSeconds,
+ thumbnailUrl: loomImportJobItems.thumbnailUrl,
+ error: loomImportJobItems.error,
+ updatedAt: loomImportJobItems.updatedAt,
+ videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
+ videoUpdatedAt: videos.updatedAt,
+ uploadPhase: videoUploads.phase,
+ uploadProgress: videoUploads.processingProgress,
+ uploadMessage: videoUploads.processingMessage,
+ uploadError: videoUploads.processingError,
+ uploadUpdatedAt: videoUploads.updatedAt,
+ })
+ .from(loomImportJobItems)
+ .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .leftJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(eq(loomImportJobItems.jobId, jobId))
+ .orderBy(asc(loomImportJobItems.rowNumber)),
+ ]);
+
+ const all: LoomImportItemView[] = rows.map((row) => {
+ const state = deriveLoomImportItemState(
+ {
+ status: row.status,
+ videoId: row.videoId,
+ error: row.error,
+ videoExists: Boolean(row.videoExists),
+ uploadPhase: row.uploadPhase ?? null,
+ uploadProgress: row.uploadProgress ?? null,
+ uploadMessage: row.uploadMessage ?? null,
+ uploadError: row.uploadError ?? null,
+ },
+ job.status,
+ );
+ return {
+ id: row.id,
+ row: row.rowNumber,
+ url: row.loomUrl,
+ loomId: row.loomVideoId,
+ title: row.title,
+ email: row.ownerEmail,
+ space: row.spaceName,
+ ...state,
+ videoId: row.videoId,
+ recordedAt: row.loomCreatedAt?.toISOString() ?? null,
+ duration: row.durationSeconds,
+ thumb: row.thumbnailUrl,
+ v: Math.max(
+ time(row.updatedAt),
+ time(row.videoUpdatedAt),
+ time(row.uploadUpdatedAt),
+ ),
+ };
+ });
+
+ let cursor = 0;
+ let totalDuration = 0;
+ let importedDuration = 0;
+ const owners = new Set();
+ for (const item of all) {
+ cursor = Math.max(cursor, item.v);
+ if (item.email) owners.add(item.email);
+ if (
+ item.duration &&
+ item.status !== "failed" &&
+ item.status !== "cancelled"
+ ) {
+ totalDuration += item.duration;
+ if (item.status === "imported") importedDuration += item.duration;
+ }
+ }
+
+ const isPro = userIsPro(viewer ?? null);
+ const createdByMe = job.createdById === userId;
+ const full = since === undefined || !Number.isFinite(since);
+
+ return {
+ job: {
+ id: job.id,
+ fileName: job.fileName,
+ status: job.status,
+ totalCount: job.totalCount,
+ createdAt: job.createdAt.toISOString(),
+ startedAt: job.startedAt?.toISOString() ?? null,
+ completedAt: job.completedAt?.toISOString() ?? null,
+ createdByMe,
+ canStart: createdByMe && isPro && job.status === "awaiting_upgrade",
+ isPro,
+ isAdmin,
+ },
+ counts: countLoomImportItems(all),
+ totalDuration,
+ importedDuration,
+ owners: Math.max(owners.size, 1),
+ items: full
+ ? all
+ : all.filter((item) => item.v > (since as number) - CURSOR_OVERLAP_MS),
+ cursor,
+ full,
+ };
+}
diff --git a/apps/web/lib/loom-import/status.ts b/apps/web/lib/loom-import/status.ts
new file mode 100644
index 00000000000..6d26126bf52
--- /dev/null
+++ b/apps/web/lib/loom-import/status.ts
@@ -0,0 +1,219 @@
+import type {
+ LoomImportItemStatus,
+ LoomImportJobStatus,
+} from "@cap/database/schema";
+
+export type { LoomImportItemStatus, LoomImportJobStatus };
+
+export type LoomImportDisplayStatus =
+ | "checking"
+ | "ready"
+ | "queued"
+ | "importing"
+ | "imported"
+ | "failed"
+ | "skipped"
+ | "cancelled";
+
+export type LoomImportStage =
+ | "starting"
+ | "waiting"
+ | "downloading"
+ | "processing"
+ | "finishing";
+
+export type LoomImportItemSource = {
+ status: LoomImportItemStatus;
+ videoId: string | null;
+ error: string | null;
+ videoExists: boolean;
+ uploadPhase: string | null;
+ uploadProgress: number | null;
+ uploadMessage: string | null;
+ uploadError: string | null;
+};
+
+export type LoomImportItemState = {
+ status: LoomImportDisplayStatus;
+ stage?: LoomImportStage;
+ progress?: number;
+ error?: string;
+};
+
+export type LoomImportCounts = Record & {
+ total: number;
+};
+
+export type LoomImportItemView = {
+ id: string;
+ row: number;
+ url: string;
+ loomId: string | null;
+ title: string | null;
+ email: string | null;
+ space: string | null;
+ status: LoomImportDisplayStatus;
+ stage?: LoomImportStage;
+ progress?: number;
+ error?: string;
+ videoId: string | null;
+ recordedAt: string | null;
+ duration: number | null;
+ thumb: string | null;
+ v: number;
+};
+
+export type LoomImportJobView = {
+ id: string;
+ fileName: string;
+ status: LoomImportJobStatus;
+ totalCount: number;
+ createdAt: string;
+ startedAt: string | null;
+ completedAt: string | null;
+ createdByMe: boolean;
+ canStart: boolean;
+ isPro: boolean;
+ isAdmin: boolean;
+};
+
+export type LoomImportSnapshot = {
+ job: LoomImportJobView;
+ counts: LoomImportCounts;
+ totalDuration: number;
+ importedDuration: number;
+ owners: number;
+ items: LoomImportItemView[];
+ cursor: number;
+ full: boolean;
+};
+
+export const LOOM_IMPORT_TERMINAL_STATUSES: ReadonlySet =
+ new Set(["imported", "failed", "skipped", "cancelled"]);
+
+const DELETED_VIDEO_ERROR = "The imported Cap was deleted.";
+
+function importStage(
+ phase: string | null,
+ progress: number,
+ message: string | null,
+): LoomImportStage {
+ if (phase === "uploading") return "starting";
+ if (phase === "generating_thumbnail" || progress >= 80) return "finishing";
+ if (message?.startsWith("Queued")) return "waiting";
+ if (progress < 10) return "downloading";
+ return "processing";
+}
+
+export function deriveLoomImportItemState(
+ source: LoomImportItemSource,
+ jobStatus: LoomImportJobStatus,
+): LoomImportItemState {
+ switch (source.status) {
+ case "pending":
+ return { status: "checking" };
+ case "ready":
+ return {
+ status:
+ jobStatus === "awaiting_upgrade" || jobStatus === "checking"
+ ? "ready"
+ : "queued",
+ };
+ case "skipped":
+ return { status: "skipped", error: source.error ?? undefined };
+ case "cancelled":
+ return { status: "cancelled" };
+ default:
+ break;
+ }
+
+ if (!source.videoId) {
+ if (source.status === "importing")
+ return { status: "importing", stage: "starting", progress: 0 };
+ if (source.status === "complete") return { status: "imported" };
+ return { status: "failed", error: source.error ?? undefined };
+ }
+
+ if (!source.videoExists) {
+ return { status: "failed", error: source.error ?? DELETED_VIDEO_ERROR };
+ }
+
+ if (source.uploadPhase === null) {
+ if (source.status === "failed" && source.error) {
+ return { status: "failed", error: source.error };
+ }
+ return { status: "imported" };
+ }
+
+ if (source.uploadPhase === "error") {
+ return {
+ status: "failed",
+ error:
+ source.uploadError ?? source.error ?? "Loom import failed. Try again.",
+ };
+ }
+
+ const progress = Math.max(0, Math.min(100, source.uploadProgress ?? 0));
+ return {
+ status: "importing",
+ stage: importStage(source.uploadPhase, progress, source.uploadMessage),
+ progress,
+ };
+}
+
+export function emptyLoomImportCounts(): LoomImportCounts {
+ return {
+ checking: 0,
+ ready: 0,
+ queued: 0,
+ importing: 0,
+ imported: 0,
+ failed: 0,
+ skipped: 0,
+ cancelled: 0,
+ total: 0,
+ };
+}
+
+export function countLoomImportItems(
+ items: Iterable<{ status: LoomImportDisplayStatus }>,
+): LoomImportCounts {
+ const counts = emptyLoomImportCounts();
+ for (const item of items) {
+ counts[item.status]++;
+ counts.total++;
+ }
+ return counts;
+}
+
+export function loomImportProgress(
+ counts: LoomImportCounts,
+ importingProgress = 0,
+) {
+ const settled =
+ counts.imported + counts.failed + counts.skipped + counts.cancelled;
+ const total = counts.total;
+ if (total === 0) return 0;
+ return Math.min(1, (settled + importingProgress / 100) / total);
+}
+
+export function isLoomImportJobActive(status: LoomImportJobStatus) {
+ return status === "checking" || status === "importing";
+}
+
+export function loomImportStageLabel(stage: LoomImportStage | undefined) {
+ switch (stage) {
+ case "starting":
+ return "Starting";
+ case "waiting":
+ return "Waiting for a slot";
+ case "downloading":
+ return "Downloading from Loom";
+ case "processing":
+ return "Copying to Cap";
+ case "finishing":
+ return "Making previews";
+ default:
+ return "Importing";
+ }
+}
diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts
index e821e92d885..cc94f36e128 100644
--- a/apps/web/workflows/import-loom-video.ts
+++ b/apps/web/workflows/import-loom-video.ts
@@ -7,6 +7,7 @@ import { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect } from "effect";
import { FatalError, sleep } from "workflow";
+import { dispatchLoomImportForVideo } from "@/lib/loom-import/dispatch";
import {
createMediaServerCapacityError,
isMediaServerCapacityError,
@@ -236,6 +237,7 @@ export async function importLoomVideoWorkflow(
}
await saveMetadataAndComplete(payload.videoId, metadata);
await completeAgentImport(payload.agentOperationId, payload.videoId);
+ await continueLoomImportJob(payload.videoId);
return {
success: true,
@@ -246,10 +248,24 @@ export async function importLoomVideoWorkflow(
const errorMessage = error instanceof Error ? error.message : String(error);
await setProcessingError(payload.videoId, errorMessage);
await failAgentImport(payload.agentOperationId, errorMessage);
+ await continueLoomImportJob(payload.videoId);
throw new FatalError(errorMessage);
}
}
+async function continueLoomImportJob(videoId: string): Promise {
+ "use step";
+
+ try {
+ await dispatchLoomImportForVideo(videoId);
+ } catch (error) {
+ console.error("[import-loom-video] Could not continue Loom import job", {
+ videoId,
+ error,
+ });
+ }
+}
+
function getInputExtension(url: string): string | undefined {
const pathname = new URL(url).pathname.toLowerCase();
diff --git a/apps/web/workflows/loom-import-job.ts b/apps/web/workflows/loom-import-job.ts
new file mode 100644
index 00000000000..aa39c54c8b4
--- /dev/null
+++ b/apps/web/workflows/loom-import-job.ts
@@ -0,0 +1,41 @@
+import { dispatchLoomImportJob } from "@/lib/loom-import/dispatch";
+import {
+ prepareLoomImportJob,
+ resolveLoomImportJob,
+} from "@/lib/loom-import/jobs";
+
+interface LoomImportJobPayload {
+ jobId: string;
+}
+
+async function resolveLoomImportJobStep(jobId: string) {
+ "use step";
+
+ await resolveLoomImportJob(jobId);
+}
+
+async function prepareLoomImportJobStep(jobId: string) {
+ "use step";
+
+ return prepareLoomImportJob(jobId);
+}
+
+async function dispatchLoomImportJobStep(jobId: string) {
+ "use step";
+
+ return dispatchLoomImportJob(jobId);
+}
+
+export async function loomImportJobWorkflow({ jobId }: LoomImportJobPayload) {
+ "use workflow";
+
+ await resolveLoomImportJobStep(jobId);
+ const status = await prepareLoomImportJobStep(jobId);
+ if (status !== "importing") return { jobId, status };
+ const dispatched = await dispatchLoomImportJobStep(jobId);
+ return {
+ jobId,
+ status: dispatched.completed ? "completed" : "importing",
+ started: dispatched.started,
+ };
+}
diff --git a/packages/env/server.ts b/packages/env/server.ts
index 78d649a442f..e488c019fce 100644
--- a/packages/env/server.ts
+++ b/packages/env/server.ts
@@ -188,6 +188,12 @@ function createServerEnv() {
.describe(
"Base URL for media server webhooks (use host.docker.internal for Docker setups)",
),
+ LOOM_IMPORT_CONCURRENCY: z
+ .string()
+ .optional()
+ .describe(
+ "How many videos each Loom CSV import sends to the media server at once (default 4)",
+ ),
/// Ignore
NODE_ENV: z.string(),
From bb06457554b54b9402f7522d13ffd30247000f62 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 04:49:16 +0000
Subject: [PATCH 03/39] fix: only transcribe imported Loom videos once someone
watches them
The media-server webhook queued transcription for every finished web MP4,
and stalled-pipeline recovery picked up any recent video without a
transcript, so bulk Loom imports paid for transcription and AI on videos
nobody opened. Imported Loom videos now wait for the share page or embed
to start them on first view, and the webhook refills the import window
instead.
Co-authored-by: Richie McIlroy
---
.../unit/media-server-progress.test.ts | 50 +++++++++++++++++++
.../unit/video-pipeline-recovery.test.ts | 31 ++++++++++++
.../webhooks/media-server/progress/route.ts | 26 +++++++++-
apps/web/lib/video-pipeline-recovery.ts | 10 +++-
4 files changed, 115 insertions(+), 2 deletions(-)
diff --git a/apps/web/__tests__/unit/media-server-progress.test.ts b/apps/web/__tests__/unit/media-server-progress.test.ts
index f4e817178a3..50d2ce7fb0d 100644
--- a/apps/web/__tests__/unit/media-server-progress.test.ts
+++ b/apps/web/__tests__/unit/media-server-progress.test.ts
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
transcribe: vi.fn(),
invalidateQuota: vi.fn(),
audio: vi.fn(),
+ isLoomImport: vi.fn(),
+ continueLoomImport: vi.fn(),
tables: {
videos: {
id: "videos.id",
@@ -68,6 +70,10 @@ vi.mock("@/lib/google-drive-storage-quota", () => ({
vi.mock("@/lib/google-drive-storage-quota-cache", () => ({
invalidateGoogleDriveStorageQuotaCache: mocks.invalidateQuota,
}));
+vi.mock("@/lib/loom-import/dispatch", () => ({
+ isLoomImportedVideo: mocks.isLoomImport,
+ dispatchLoomImportForVideo: mocks.continueLoomImport,
+}));
vi.mock("@/lib/queue-video-transcription", () => ({
queueVideoTranscription: mocks.transcribe,
shouldQueueTranscriptionAfterMediaComplete: () => true,
@@ -253,6 +259,17 @@ function databaseFixture(initial: DesktopRecordingJob | null = fixture().job) {
};
}
+function webPayload(overrides: Record = {}) {
+ return {
+ jobId: "media-job-1",
+ videoId,
+ phase: "complete",
+ progress: 100,
+ metadata: { ...metadata, bitrate: 1_000_000 },
+ ...overrides,
+ };
+}
+
function request(
body: unknown = fixture().payload,
secret: string | null = "media-secret",
@@ -283,6 +300,8 @@ describe("media-server recording progress webhook", () => {
);
mocks.queue.mockReset().mockResolvedValue("queued");
mocks.transcribe.mockReset().mockResolvedValue({ success: true });
+ mocks.isLoomImport.mockReset().mockResolvedValue(false);
+ mocks.continueLoomImport.mockReset().mockResolvedValue(null);
mocks.invalidateQuota.mockResolvedValue(undefined);
mocks.retry.mockResolvedValue(true);
mocks.blocked.mockResolvedValue(true);
@@ -331,6 +350,37 @@ describe("media-server recording progress webhook", () => {
},
);
+ it("leaves AI for imported Loom videos until they are viewed and keeps the import moving", async () => {
+ const database = databaseFixture(null);
+ database.video.source = { type: "webMP4" };
+ mocks.isLoomImport.mockResolvedValue(true);
+ expect((await request(webPayload())).status).toBe(200);
+ expect(mocks.isLoomImport).toHaveBeenCalledExactlyOnceWith(videoId);
+ expect(mocks.transcribe).not.toHaveBeenCalled();
+ expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith(videoId);
+ expect(database.mutations).toContainEqual({
+ operation: "delete",
+ table: mocks.tables.uploads,
+ });
+ });
+
+ it("still transcribes ordinary web uploads as soon as processing finishes", async () => {
+ const database = databaseFixture(null);
+ database.video.source = { type: "webMP4" };
+ expect((await request(webPayload())).status).toBe(200);
+ expect(mocks.transcribe).toHaveBeenCalledExactlyOnceWith(videoId);
+ expect(mocks.continueLoomImport).not.toHaveBeenCalled();
+ });
+
+ it("keeps a Loom import moving when a video fails for good", async () => {
+ const database = databaseFixture(null);
+ database.video.source = { type: "webMP4" };
+ const failed = webPayload({ phase: "error", error: "Source unavailable" });
+ expect((await request(failed)).status).toBe(200);
+ expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith(videoId);
+ expect(mocks.transcribe).not.toHaveBeenCalled();
+ });
+
it("refuses callbacks when the webhook secret is not configured", async () => {
databaseFixture();
mocks.secret = undefined;
diff --git a/apps/web/__tests__/unit/video-pipeline-recovery.test.ts b/apps/web/__tests__/unit/video-pipeline-recovery.test.ts
index dd1de83dfdd..d362bada4e5 100644
--- a/apps/web/__tests__/unit/video-pipeline-recovery.test.ts
+++ b/apps/web/__tests__/unit/video-pipeline-recovery.test.ts
@@ -188,6 +188,37 @@ describe("recoverStalledVideoPipeline", () => {
expect(result.ai.statuses).toEqual({ started: 1 });
});
+ it("leaves never-watched Loom imports for their first view instead of transcribing them", async () => {
+ const chains: ReturnType[] = [];
+ mockDb.mockImplementation(() => {
+ const chain = makeSelectChain([]);
+ chains.push(chain);
+ return chain;
+ });
+
+ const { recoverStalledVideoPipeline } = await import(
+ "@/lib/video-pipeline-recovery"
+ );
+ await recoverStalledVideoPipeline({ now, concurrency: 1 });
+
+ const transcriptionQuery = chains[1];
+ expect(transcriptionQuery?.leftJoin).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "importedVideos.id" }),
+ expect.anything(),
+ );
+ const conditions = JSON.stringify(transcriptionQuery?.where.mock.calls[0]);
+ expect(conditions).toContain(
+ JSON.stringify(["videos.transcriptionStatus", "importedVideos.id"]),
+ );
+ expect(conditions).toContain(
+ JSON.stringify({
+ left: "videos.transcriptionStatus",
+ right: "PROCESSING",
+ }),
+ );
+ expect(mockTranscribeVideo).not.toHaveBeenCalled();
+ });
+
it("does not start a duplicate media workflow after a lost claim", async () => {
const mediaCandidate = {
videoId: "video-media",
diff --git a/apps/web/app/api/webhooks/media-server/progress/route.ts b/apps/web/app/api/webhooks/media-server/progress/route.ts
index 9e4a7bf825f..4426c2a823c 100644
--- a/apps/web/app/api/webhooks/media-server/progress/route.ts
+++ b/apps/web/app/api/webhooks/media-server/progress/route.ts
@@ -15,6 +15,10 @@ import {
} from "@/lib/desktop-recording-verification";
import { queueDesktopSegmentsFinalization } from "@/lib/desktop-segments-finalization";
import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota";
+import {
+ dispatchLoomImportForVideo,
+ isLoomImportedVideo,
+} from "@/lib/loom-import/dispatch";
import {
queueVideoTranscription,
shouldQueueTranscriptionAfterMediaComplete,
@@ -62,6 +66,17 @@ function getValidDuration(duration: number) {
return Number.isFinite(duration) && duration > 0 ? duration : undefined;
}
+async function continueLoomImport(videoId: Video.VideoId) {
+ try {
+ await dispatchLoomImportForVideo(videoId);
+ } catch (error) {
+ console.warn("[media-server-webhook] Could not continue Loom import", {
+ videoId,
+ error,
+ });
+ }
+}
+
function mapPhaseToDbPhase(
phase: ProgressWebhookPayload["phase"],
): "uploading" | "processing" | "generating_thumbnail" | "complete" | "error" {
@@ -262,7 +277,15 @@ export async function POST(request: NextRequest) {
currentVideo?.storageIntegrationId,
);
- if (
+ const videoId = payload.videoId as Video.VideoId;
+ const isLoomImport =
+ !isEditUpload &&
+ currentVideo?.source.type === "webMP4" &&
+ (await isLoomImportedVideo(videoId));
+
+ if (isLoomImport) {
+ await continueLoomImport(videoId);
+ } else if (
shouldQueueTranscriptionAfterMediaComplete(
currentVideo?.source.type,
Boolean(isEditUpload),
@@ -309,6 +332,7 @@ export async function POST(request: NextRequest) {
updatedAt: new Date(),
})
.where(eq(videoUploads.videoId, payload.videoId as Video.VideoId));
+ await continueLoomImport(payload.videoId as Video.VideoId);
}
} else {
await db()
diff --git a/apps/web/lib/video-pipeline-recovery.ts b/apps/web/lib/video-pipeline-recovery.ts
index 060df34392d..85dc8cf018f 100644
--- a/apps/web/lib/video-pipeline-recovery.ts
+++ b/apps/web/lib/video-pipeline-recovery.ts
@@ -342,6 +342,14 @@ export async function recoverStalledVideoPipeline({
.from(videos)
.innerJoin(users, eq(videos.ownerId, users.id))
.leftJoin(videoUploads, eq(videos.id, videoUploads.videoId))
+ .leftJoin(
+ importedVideos,
+ and(
+ eq(importedVideos.id, videos.id),
+ eq(importedVideos.orgId, videos.orgId),
+ eq(importedVideos.source, "loom"),
+ ),
+ )
.where(
and(
isNull(videoUploads.videoId),
@@ -349,7 +357,7 @@ export async function recoverStalledVideoPipeline({
gte(videos.createdAt, recentBefore),
lte(videos.updatedAt, staleBefore),
or(
- isNull(videos.transcriptionStatus),
+ and(isNull(videos.transcriptionStatus), isNull(importedVideos.id)),
eq(videos.transcriptionStatus, "PROCESSING"),
),
),
From 9aca43ccb99a98c45f6a7b675afc9c66cc883f23 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 04:49:16 +0000
Subject: [PATCH 04/39] feat: guided Loom bulk import with live progress and
upgrade flow
Hand-drawn walkthrough of the import, a drop zone that also takes pasted
links, automatic column detection with a review step, a live job page with
a virtualized list of every video, and an upgrade panel that lets free
users check their whole library before upgrading. Checkout returns to the
job and starts it.
Co-authored-by: Richie McIlroy
---
.../__tests__/unit/loom-import-status.test.ts | 291 ++++++
.../web/__tests__/unit/loom-import-ui.test.ts | 4 +-
.../dashboard/import/loom/ImportLoomPage.tsx | 960 +-----------------
.../import/loom/[jobId]/LoomImportJobView.tsx | 635 ++++++++++++
.../import/loom/[jobId]/import-list.tsx | 297 ++++++
.../dashboard/import/loom/[jobId]/page.tsx | 35 +
.../import/loom/[jobId]/upgrade-panel.tsx | 287 ++++++
.../loom/[jobId]/use-loom-import-job.ts | 187 ++++
.../import/loom/_components/bulk-import.tsx | 668 ++++++++++++
.../import/loom/_components/doodles.tsx | 495 +++++++++
.../loom/_components/how-import-works.tsx | 578 +++++++++++
.../import/loom/_components/loom-import.css | 260 +++++
.../loom/_components/recent-imports.tsx | 90 ++
.../app/(org)/dashboard/import/loom/page.tsx | 12 +-
.../api/settings/billing/subscribe/route.ts | 15 +-
15 files changed, 3885 insertions(+), 929 deletions(-)
create mode 100644 apps/web/__tests__/unit/loom-import-status.test.ts
create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/doodles.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/how-import-works.tsx
create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css
create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx
diff --git a/apps/web/__tests__/unit/loom-import-status.test.ts b/apps/web/__tests__/unit/loom-import-status.test.ts
new file mode 100644
index 00000000000..d5f438ece88
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-import-status.test.ts
@@ -0,0 +1,291 @@
+import { describe, expect, it, vi } from "vitest";
+import { mergeLoomImportItems } from "@/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job";
+import {
+ buildLoomVideosQuery,
+ lookupLoomVideos,
+ toLoomVideoLookup,
+} from "@/lib/loom-import/loom-api";
+import { buildLoomImportReport } from "@/lib/loom-import/report";
+import {
+ countLoomImportItems,
+ deriveLoomImportItemState,
+ type LoomImportItemSource,
+ type LoomImportItemView,
+ type LoomImportSnapshot,
+ loomImportProgress,
+} from "@/lib/loom-import/status";
+
+const source = (
+ overrides: Partial,
+): LoomImportItemSource => ({
+ status: "importing",
+ videoId: "video-1",
+ error: null,
+ videoExists: true,
+ uploadPhase: "processing",
+ uploadProgress: 30,
+ uploadMessage: "Processing video...",
+ uploadError: null,
+ ...overrides,
+});
+
+describe("deriveLoomImportItemState", () => {
+ it("shows checked rows as ready before an upgrade and queued once importing", () => {
+ expect(
+ deriveLoomImportItemState(
+ source({ status: "ready" }),
+ "awaiting_upgrade",
+ ),
+ ).toEqual({
+ status: "ready",
+ });
+ expect(
+ deriveLoomImportItemState(source({ status: "ready" }), "importing"),
+ ).toEqual({
+ status: "queued",
+ });
+ });
+
+ it("reads live media progress and friendly stages", () => {
+ expect(deriveLoomImportItemState(source({}), "importing")).toEqual({
+ status: "importing",
+ stage: "processing",
+ progress: 30,
+ });
+ expect(
+ deriveLoomImportItemState(
+ source({ uploadPhase: "uploading", uploadProgress: 0 }),
+ "importing",
+ ).stage,
+ ).toBe("starting");
+ expect(
+ deriveLoomImportItemState(
+ source({
+ uploadMessage: "Queued for Loom import processing...",
+ uploadProgress: 0,
+ }),
+ "importing",
+ ).stage,
+ ).toBe("waiting");
+ expect(
+ deriveLoomImportItemState(
+ source({ uploadPhase: "generating_thumbnail", uploadProgress: 90 }),
+ "importing",
+ ).stage,
+ ).toBe("finishing");
+ });
+
+ it("treats a finished upload as imported even if the job row lags behind", () => {
+ expect(
+ deriveLoomImportItemState(source({ uploadPhase: null }), "importing"),
+ ).toEqual({
+ status: "imported",
+ });
+ });
+
+ it("surfaces media errors, deleted Caps and retries started elsewhere", () => {
+ expect(
+ deriveLoomImportItemState(
+ source({ uploadPhase: "error", uploadError: "Source unavailable" }),
+ "importing",
+ ),
+ ).toEqual({ status: "failed", error: "Source unavailable" });
+ expect(
+ deriveLoomImportItemState(source({ videoExists: false }), "completed"),
+ ).toEqual({
+ status: "failed",
+ error: "The imported Cap was deleted.",
+ });
+ expect(
+ deriveLoomImportItemState(
+ source({ status: "failed", error: "Old failure" }),
+ "completed",
+ ).status,
+ ).toBe("importing");
+ });
+});
+
+describe("progress", () => {
+ it("counts partial progress of videos that are mid-copy", () => {
+ const counts = countLoomImportItems([
+ { status: "imported" },
+ { status: "failed" },
+ { status: "importing" },
+ { status: "queued" },
+ ]);
+ expect(counts).toMatchObject({
+ imported: 1,
+ failed: 1,
+ importing: 1,
+ queued: 1,
+ total: 4,
+ });
+ expect(loomImportProgress(counts, 50)).toBeCloseTo(0.625);
+ });
+});
+
+describe("Loom lookups", () => {
+ it("maps Loom's video types to import outcomes", () => {
+ expect(
+ toLoomVideoLookup({
+ __typename: "RegularUserVideo",
+ name: " Weekly update ",
+ createdAt: "2022-08-05T21:16:35.487Z",
+ thumbnails: { default: "https://cdn.loom.com/thumb.jpg" },
+ video_properties: { duration: 154, width: 1152, height: 720 },
+ }),
+ ).toEqual({
+ status: "ok",
+ title: "Weekly update",
+ createdAt: "2022-08-05T21:16:35.487Z",
+ durationSeconds: 154,
+ width: 1152,
+ height: 720,
+ thumbnailUrl: "https://cdn.loom.com/thumb.jpg",
+ });
+ expect(toLoomVideoLookup({ __typename: "PrivateVideo" })).toEqual({
+ status: "private",
+ });
+ expect(
+ toLoomVideoLookup({ __typename: "VideoPasswordMissingOrIncorrect" }),
+ ).toEqual({
+ status: "password",
+ });
+ expect(toLoomVideoLookup(null)).toEqual({ status: "not_found" });
+ for (const unsafe of [
+ "javascript:alert(1)",
+ "http://cdn.loom.com/thumb.jpg",
+ ]) {
+ expect(
+ toLoomVideoLookup({
+ __typename: "RegularUserVideo",
+ thumbnails: { default: unsafe },
+ }),
+ ).toMatchObject({ status: "ok", thumbnailUrl: null });
+ }
+ });
+
+ it("asks for many videos per request with aliases", () => {
+ const query = buildLoomVideosQuery(["a", "b"]);
+ expect(query.query).toContain("v0: getVideo(id: $id0");
+ expect(query.query).toContain("v1: getVideo(id: $id1");
+ expect(query.variables).toEqual({ id0: "a", id1: "b" });
+ });
+
+ it("batches lookups, retries a failed request and reports batches as they land", async () => {
+ const ids = Array.from({ length: 60 }, (_, index) => `id${index}`);
+ let calls = 0;
+ const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
+ calls++;
+ if (calls === 1) return new Response("busy", { status: 503 });
+ const { variables } = JSON.parse(String(init?.body)) as {
+ variables: Record;
+ };
+ const data = Object.fromEntries(
+ Object.keys(variables).map((key) => [
+ key.replace("id", "v"),
+ { __typename: "RegularUserVideo", name: variables[key] },
+ ]),
+ );
+ return Response.json({ data });
+ }) as unknown as typeof fetch;
+ const batches: number[] = [];
+ const results = await lookupLoomVideos([...ids, "id0"], {
+ fetchImpl,
+ concurrency: 2,
+ onBatch: (batch) => {
+ batches.push(batch.size);
+ },
+ });
+ expect(results.size).toBe(60);
+ expect(batches.sort()).toEqual([10, 25, 25]);
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
+ expect(results.get("id42")).toMatchObject({ status: "ok", title: "id42" });
+ });
+
+ it("gives up cleanly when Loom keeps failing", async () => {
+ const fetchImpl = vi.fn(
+ async () => new Response("no", { status: 500 }),
+ ) as unknown as typeof fetch;
+ const results = await lookupLoomVideos(["a"], { fetchImpl, attempts: 2 });
+ expect(results.get("a")).toEqual({ status: "error" });
+ });
+});
+
+const view = (overrides: Partial): LoomImportItemView => ({
+ id: "item-1",
+ row: 2,
+ url: "https://www.loom.com/share/abc",
+ loomId: "abc",
+ title: "Kickoff",
+ email: "ana@acme.com",
+ space: null,
+ status: "imported",
+ videoId: "video-1",
+ recordedAt: "2022-08-05T21:16:35.487Z",
+ duration: 154,
+ thumb: null,
+ v: 1,
+ ...overrides,
+});
+
+describe("results report", () => {
+ it("maps every Loom link to its new Cap link and neutralizes spreadsheet formulas", () => {
+ const csv = buildLoomImportReport(
+ [
+ view({}),
+ view({
+ id: "item-2",
+ row: 3,
+ title: '=HYPERLINK("x"), "quoted"',
+ status: "failed",
+ videoId: null,
+ error: "This Loom is private.",
+ }),
+ ],
+ "https://cap.so",
+ );
+ expect(csv.split("\n")).toEqual([
+ "row,loom_url,title,recorded_at,owner_email,space,status,cap_url,note",
+ "2,https://www.loom.com/share/abc,Kickoff,2022-08-05T21:16:35.487Z,ana@acme.com,,imported,https://cap.so/s/video-1,",
+ `3,https://www.loom.com/share/abc,"'=HYPERLINK(""x""), ""quoted""",2022-08-05T21:16:35.487Z,ana@acme.com,,failed,,This Loom is private.`,
+ "",
+ ]);
+ });
+});
+
+describe("mergeLoomImportItems", () => {
+ const snapshot = (items: LoomImportItemView[], full: boolean) =>
+ ({ items, full, cursor: 0 }) as unknown as LoomImportSnapshot;
+
+ it("replaces only rows that changed and keeps CSV order", () => {
+ const map = new Map();
+ const first = mergeLoomImportItems(
+ map,
+ [],
+ snapshot([view({ id: "a", row: 2 }), view({ id: "b", row: 3 })], true),
+ );
+ expect(first).toEqual({ order: ["a", "b"], changed: true });
+ const original = map.get("a");
+
+ const unchanged = mergeLoomImportItems(
+ map,
+ first.order,
+ snapshot([view({ id: "a", row: 2 })], false),
+ );
+ expect(unchanged.changed).toBe(false);
+ expect(map.get("a")).toBe(original);
+
+ const progressed = mergeLoomImportItems(
+ map,
+ first.order,
+ snapshot(
+ [view({ id: "b", row: 3, status: "importing", progress: 40, v: 2 })],
+ false,
+ ),
+ );
+ expect(progressed).toEqual({ order: ["a", "b"], changed: true });
+ expect(map.get("a")).toBe(original);
+ expect(map.get("b")?.progress).toBe(40);
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts
index d92f8e90913..22cc595c77c 100644
--- a/apps/web/__tests__/unit/loom-import-ui.test.ts
+++ b/apps/web/__tests__/unit/loom-import-ui.test.ts
@@ -99,7 +99,9 @@ vi.mock("next/link", () => ({
vi.mock("@/actions/loom", () => ({
getLoomImportFolders: mocks.folders,
importFromLoom: mocks.import,
- importFromLoomCsv: vi.fn(),
+}));
+vi.mock("@/actions/loom-import", () => ({
+ createLoomImportJobAction: vi.fn(),
}));
vi.mock("@cap/utils", async () => await import("@cap/utils/helpers"));
vi.mock("@cap/ui", async () => ({
diff --git a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
index ebca8079829..a3fbf7b366b 100644
--- a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
@@ -2,34 +2,18 @@
import {
Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
Input,
- Select,
SelectContent,
SelectItem,
SelectRoot,
SelectTrigger,
SelectValue,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
} from "@cap/ui";
import { Folder } from "@cap/web-domain";
import {
faArrowLeft,
- faCircleCheck,
- faDownload,
faFileCsv,
faLink,
- faTriangleExclamation,
- faUpload,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQuery } from "@tanstack/react-query";
@@ -37,24 +21,12 @@ import clsx from "clsx";
import { motion } from "framer-motion";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
-import {
- type ChangeEvent,
- type DragEvent,
- useId,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { useId, useMemo, useState } from "react";
import { toast } from "sonner";
-import {
- getLoomImportFolders,
- importFromLoom,
- importFromLoomCsv,
- type LoomCsvImportResult,
- type LoomCsvImportRowResult,
-} from "@/actions/loom";
+import { getLoomImportFolders, importFromLoom } from "@/actions/loom";
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import { UpgradeModal } from "@/components/UpgradeModal";
+import type { LoomImportJobSummary } from "@/lib/loom-import/jobs";
import {
type LoomImportDestination,
loomImportDestinationHref,
@@ -65,194 +37,20 @@ import {
canManageOrganizationSettings,
getEffectiveOrganizationRole,
} from "@/lib/permissions/roles";
+import { BulkImport } from "./_components/bulk-import";
+import { LoomMark } from "./_components/doodles";
+import { RecentImports } from "./_components/recent-imports";
type Mode = "single" | "csv";
-type CsvData = {
- fileName: string;
- headers: string[];
- rows: string[][];
-};
-
-type Mapping = {
- loomUrl?: string;
- userEmail?: string;
- spaceName?: string;
-};
-
-type MappedRow = {
- rowNumber: number;
- loomUrl: string;
- userEmail: string;
- spaceName: string;
-};
-
-const LOOM_CSV_TEMPLATE =
- "loom_video_url,user_email,space_name\nhttps://www.loom.com/share/0123456789abcdef,user@example.com,Sales\n";
-
const ROOT_FOLDER_VALUE = "__cap_root_folder__";
-const OPTIONAL_COLUMN_VALUE = "__cap_skip_column__";
-const MAX_SPACE_NAME_LENGTH = 255;
-const MAX_LOOM_CSV_IMPORT_ROWS = 500;
-const LOOM_CSV_BATCH_SIZE = 10;
-const LOOM_CSV_BATCH_DELAY_MS = 1500;
-const LOOM_CSV_LIMIT_MESSAGE =
- "CSV imports are limited to 500 videos at a time. Contact support to raise this limit.";
-const LOOM_CSV_PERMISSION_MESSAGE =
- "Only organization admins and owners can import Loom videos from a CSV.";
-
-function delay(ms: number) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-function chunkRows(rows: T[], size: number) {
- const chunks: T[][] = [];
- for (let index = 0; index < rows.length; index += size) {
- chunks.push(rows.slice(index, index + size));
- }
- return chunks;
-}
-
-function buildCsvImportResult(
- results: LoomCsvImportRowResult[],
- error?: string,
-): LoomCsvImportResult {
- const importedCount = results.filter((row) => row.success).length;
- const failedCount = results.length - importedCount;
-
- return {
- success: importedCount > 0,
- importedCount,
- failedCount,
- results,
- error:
- error ??
- (importedCount > 0 ? undefined : "No Loom videos were imported."),
- };
-}
-
-function parseCsvRecords(text: string) {
- const records: string[][] = [];
- let field = "";
- let row: string[] = [];
- let inQuotes = false;
- const input = text.replace(/^\uFEFF/, "");
-
- for (let index = 0; index < input.length; index += 1) {
- const char = input.charAt(index);
- const next = input.charAt(index + 1);
-
- if (char === '"') {
- if (inQuotes && next === '"') {
- field += '"';
- index += 1;
- } else {
- inQuotes = !inQuotes;
- }
- continue;
- }
-
- if (char === "," && !inQuotes) {
- row.push(field.trim());
- field = "";
- continue;
- }
-
- if ((char === "\n" || char === "\r") && !inQuotes) {
- if (char === "\r" && next === "\n") index += 1;
- row.push(field.trim());
- if (row.some((cell) => cell.length > 0)) records.push(row);
- row = [];
- field = "";
- continue;
- }
-
- field += char;
- }
-
- if (inQuotes) throw new Error("CSV has an unclosed quoted field.");
-
- if (field.length > 0 || row.length > 0) {
- row.push(field.trim());
- if (row.some((cell) => cell.length > 0)) records.push(row);
- }
-
- return records;
-}
-
-function parseCsv(text: string, fileName: string): CsvData {
- const records = parseCsvRecords(text);
- const headers = records[0]?.map((header) => header.trim()) ?? [];
- const rows = records
- .slice(1)
- .filter((row) => row.some((cell) => cell.trim().length > 0));
-
- if (headers.length === 0) {
- throw new Error("No CSV headers found.");
- }
-
- return { fileName, headers, rows };
-}
-
-function normalizeHeader(value: string) {
- return value.toLowerCase().replace(/[^a-z0-9]/g, "");
-}
-
-function guessColumn(headers: string[], candidates: string[]) {
- const normalizedHeaders = headers.map(normalizeHeader);
- const directMatch = normalizedHeaders.findIndex((header) =>
- candidates.includes(header),
- );
- if (directMatch !== -1) return String(directMatch);
-
- const partialMatch = normalizedHeaders.findIndex((header) =>
- candidates.some((candidate) => header.includes(candidate)),
- );
- return partialMatch === -1 ? undefined : String(partialMatch);
-}
-
-function isLoomUrl(value: string) {
- try {
- return new URL(value).hostname.includes("loom.com");
- } catch {
- return false;
- }
-}
-
-function isEmail(value: string) {
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
-}
-
-function isValidSpaceName(value: string) {
- return value.length <= MAX_SPACE_NAME_LENGTH;
-}
-
-function pluralize(count: number, singular: string, plural: string) {
- return count === 1 ? singular : plural;
-}
-
-const LoomMark = ({ size = 18 }: { size?: number }) => (
-
-
-
-);
-
export const ImportLoomPage = ({
initialDestination = {},
+ recentJobs = [],
}: {
initialDestination?: LoomImportDestination;
+ recentJobs?: LoomImportJobSummary[];
}) => {
const { user, activeOrganization, spacesData } = useDashboardContext();
const router = useRouter();
@@ -269,11 +67,10 @@ export const ImportLoomPage = ({
ownerId: activeOrganization?.organization.ownerId,
memberRole: currentMember?.role,
});
- const canUseCsvImport = canManageOrganizationSettings(currentRole);
+ const isAdmin = canManageOrganizationSettings(currentRole);
const [mode, setMode] = useState(prefilledMode);
- const activeMode = canUseCsvImport ? mode : "single";
- const [upgradeModalOpen, setUpgradeModalOpen] = useState(!user?.isPro);
+ const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState(
initialDestination.folderId ?? ROOT_FOLDER_VALUE,
@@ -285,7 +82,7 @@ export const ImportLoomPage = ({
queryKey: ["loom-import-folders", user.id, orgId, spaceId],
queryFn: () =>
orgId ? getLoomImportFolders({ orgId, spaceId }) : Promise.resolve([]),
- enabled: Boolean(orgId) && activeMode === "single",
+ enabled: Boolean(orgId) && mode === "single",
});
const folderRows = useMemo(
() => buildMoveFolderDestinationRows(foldersQuery.data ?? []),
@@ -317,76 +114,6 @@ export const ImportLoomPage = ({
const [loomUrl, setLoomUrl] = useState(prefilledLoomUrl);
const [isImporting, setIsImporting] = useState(false);
- const inputRef = useRef(null);
- const [csvData, setCsvData] = useState(null);
- const [mapping, setMapping] = useState({});
- const [isDragOver, setIsDragOver] = useState(false);
- const [confirmOpen, setConfirmOpen] = useState(false);
- const [isCsvImporting, setIsCsvImporting] = useState(false);
- const [csvImportProgress, setCsvImportProgress] = useState(0);
- const [result, setResult] = useState(null);
-
- const selectedColumnValues = [
- mapping.loomUrl,
- mapping.userEmail,
- mapping.spaceName,
- ].filter((value) => value !== undefined);
- const selectedColumnsConflict =
- new Set(selectedColumnValues).size !== selectedColumnValues.length;
-
- const mappedRows = useMemo(() => {
- if (
- !csvData ||
- mapping.loomUrl === undefined ||
- mapping.userEmail === undefined
- ) {
- return [];
- }
-
- const loomIndex = Number(mapping.loomUrl);
- const emailIndex = Number(mapping.userEmail);
- const spaceIndex =
- mapping.spaceName === undefined ? undefined : Number(mapping.spaceName);
-
- return csvData.rows
- .map((row, index) => ({
- rowNumber: index + 2,
- loomUrl: (row[loomIndex] ?? "").trim(),
- userEmail: (row[emailIndex] ?? "").trim().toLowerCase(),
- spaceName:
- spaceIndex === undefined ? "" : (row[spaceIndex] ?? "").trim(),
- }))
- .filter((row) => row.loomUrl || row.userEmail || row.spaceName);
- }, [csvData, mapping.loomUrl, mapping.spaceName, mapping.userEmail]);
-
- const readyRows = useMemo(
- () =>
- mappedRows.filter(
- (row) =>
- isLoomUrl(row.loomUrl) &&
- isEmail(row.userEmail) &&
- isValidSpaceName(row.spaceName),
- ),
- [mappedRows],
- );
-
- const invalidRows = mappedRows.length - readyRows.length;
- const previewRows = mappedRows.slice(0, 5);
- const csvLimitExceeded = readyRows.length > MAX_LOOM_CSV_IMPORT_ROWS;
- const canImport =
- canUseCsvImport &&
- !!activeOrganization &&
- !selectedColumnsConflict &&
- readyRows.length > 0 &&
- !csvLimitExceeded &&
- !isCsvImporting;
-
- const columnOptions =
- csvData?.headers.map((header, index) => ({
- value: String(index),
- label: header || `Column ${index + 1}`,
- })) ?? [];
-
const isValidLoomUrl = (() => {
try {
const parsed = new URL(loomUrl.trim());
@@ -431,153 +158,6 @@ export const ImportLoomPage = ({
}
};
- const handleTemplateDownload = () => {
- const blob = new Blob([LOOM_CSV_TEMPLATE], {
- type: "text/csv;charset=utf-8",
- });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = "cap-loom-import-template.csv";
- link.click();
- URL.revokeObjectURL(url);
- };
-
- const loadCsvFile = async (file: File) => {
- if (!user) return;
-
- if (!canUseCsvImport) {
- toast.error(LOOM_CSV_PERMISSION_MESSAGE);
- return;
- }
-
- if (!user.isPro) {
- setUpgradeModalOpen(true);
- return;
- }
-
- if (!file.name.toLowerCase().endsWith(".csv") && file.type !== "text/csv") {
- toast.error("Please upload a CSV file.");
- return;
- }
-
- try {
- const parsed = parseCsv(await file.text(), file.name);
- if (parsed.rows.length > MAX_LOOM_CSV_IMPORT_ROWS) {
- toast.error(LOOM_CSV_LIMIT_MESSAGE);
- }
-
- const loomUrlGuess = guessColumn(parsed.headers, [
- "loomvideourl",
- "loomurl",
- "loomlink",
- "videourl",
- "url",
- ]);
- const userEmailGuess = guessColumn(parsed.headers, [
- "useremail",
- "memberemail",
- "owneremail",
- "email",
- ]);
- const spaceNameGuess = guessColumn(parsed.headers, [
- "spacename",
- "space",
- "workspace",
- "workspacename",
- ]);
-
- setCsvData(parsed);
- setMapping({
- loomUrl: loomUrlGuess,
- userEmail: userEmailGuess,
- spaceName: spaceNameGuess,
- });
- setResult(null);
- setCsvImportProgress(0);
- } catch (error) {
- toast.error(
- error instanceof Error ? error.message : "Could not parse CSV.",
- );
- }
- };
-
- const handleFileChange = async (event: ChangeEvent) => {
- const file = event.target.files?.[0];
- if (!file) return;
- await loadCsvFile(file);
- if (inputRef.current) inputRef.current.value = "";
- };
-
- const handleDrop = async (event: DragEvent) => {
- event.preventDefault();
- setIsDragOver(false);
- const file = event.dataTransfer.files[0];
- if (!file) return;
- await loadCsvFile(file);
- };
-
- const handleCsvImport = async () => {
- if (csvLimitExceeded) {
- toast.error(LOOM_CSV_LIMIT_MESSAGE);
- return;
- }
-
- if (!activeOrganization || !canImport) return;
-
- setIsCsvImporting(true);
- setResult(null);
- setCsvImportProgress(0);
-
- try {
- const batches = chunkRows(readyRows, LOOM_CSV_BATCH_SIZE);
- let combinedResults: LoomCsvImportRowResult[] = [];
- let blockedError: string | undefined;
-
- for (const [batchIndex, batch] of batches.entries()) {
- const importResult = await importFromLoomCsv({
- orgId: activeOrganization.organization.id,
- rows: batch,
- });
-
- if (importResult.results.length === 0 && importResult.error) {
- blockedError = importResult.error;
- break;
- }
-
- combinedResults = [...combinedResults, ...importResult.results];
- setCsvImportProgress(combinedResults.length);
- setResult(buildCsvImportResult(combinedResults));
-
- if (batchIndex < batches.length - 1) {
- await delay(LOOM_CSV_BATCH_DELAY_MS);
- }
- }
-
- const finalResult = buildCsvImportResult(combinedResults, blockedError);
- setResult(finalResult);
-
- if (finalResult.importedCount > 0) {
- toast.success(
- `${finalResult.importedCount} ${pluralize(
- finalResult.importedCount,
- "Loom import",
- "Loom imports",
- )} started.`,
- );
- router.refresh();
- } else {
- toast.error(finalResult.error || "No Loom videos were imported.");
- }
-
- setConfirmOpen(false);
- } catch {
- toast.error("An unexpected error occurred. Please try again.");
- } finally {
- setIsCsvImporting(false);
- }
- };
-
return (
@@ -597,37 +177,34 @@ export const ImportLoomPage = ({
Import from Loom
- {canUseCsvImport
- ? "Bring a single Loom video into Cap, or bulk import recordings for organization members and new users from a CSV."
- : "Paste a Loom share link to bring it into Cap."}
+ Bring one Loom over with a link, or move a whole library from a
+ CSV. Titles and original recording dates come with them.
- {canUseCsvImport && (
-
- setMode("single")}
- />
- setMode("csv")}
- />
-
- )}
+
+ setMode("single")}
+ />
+ setMode("csv")}
+ />
+
- {activeMode === "single" ? (
+ {mode === "single" ? (
Loom video URL
@@ -753,405 +330,13 @@ export const ImportLoomPage = ({
- ) : (
-
- {!csvData && (
- <>
-
-
-
-
-
-
-
- First time? Start with our template
-
-
- Two columns required:{" "}
-
- loom_video_url
- {" "}
- and{" "}
-
- user_email
-
- . Add{" "}
-
- space_name
- {" "}
- to place videos in spaces. Emails that are not members
- yet will be added without an email invite.
-
-
-
-
-
- Download Template
-
-
+ ) : orgId ? (
+
+ ) : null}
-
{
- event.preventDefault();
- setIsDragOver(true);
- }}
- onDragLeave={() => setIsDragOver(false)}
- onDrop={handleDrop}
- className={clsx(
- "relative flex flex-col items-center justify-center w-full rounded-xl border-2 border-dashed transition-all duration-200 py-14 px-8",
- isDragOver
- ? "border-blue-10 bg-blue-3"
- : "border-gray-4 bg-gray-1 hover:border-gray-6 hover:bg-gray-2",
- )}
- >
-
-
-
-
-
-
- Drag and drop your CSV here
-
-
- Or browse your computer to upload a file.
-
-
-
inputRef.current?.click()}
- variant="dark"
- size="sm"
- className="mt-2"
- >
- Browse CSV
-
-
-
- >
- )}
-
-
-
- {csvData && (
-
-
-
-
-
-
-
-
- {csvData.fileName}
-
-
- {csvData.rows.length}{" "}
- {pluralize(csvData.rows.length, "row", "rows")} detected
-
-
-
-
{
- setCsvData(null);
- setMapping({});
- setResult(null);
- setCsvImportProgress(0);
- }}
- >
- Replace CSV
-
-
-
-
-
-
- Map columns
-
-
-
- setMapping((current) => ({
- ...current,
- loomUrl: value,
- }))
- }
- />
-
- setMapping((current) => ({
- ...current,
- userEmail: value,
- }))
- }
- />
-
- setMapping((current) => ({
- ...current,
- spaceName: value,
- }))
- }
- />
-
- {selectedColumnsConflict && (
-
- Choose different columns for each mapped field.
-
- )}
-
-
-
-
-
-
-
-
- {csvLimitExceeded && (
-
-
-
-
- CSV imports are limited to {MAX_LOOM_CSV_IMPORT_ROWS}{" "}
- videos at a time.
-
-
- Split this file into smaller batches or{" "}
-
- contact support
- {" "}
- to raise the limit.
-
-
-
- )}
-
- {previewRows.length > 0 && (
-
-
-
-
- Row
- Loom URL
- User email
- Space
- Status
-
-
-
- {previewRows.map((row) => {
- const valid =
- isLoomUrl(row.loomUrl) &&
- isEmail(row.userEmail) &&
- isValidSpaceName(row.spaceName);
- return (
-
-
- {row.rowNumber}
-
-
- {row.loomUrl || "—"}
-
- {row.userEmail || "—"}
- {row.spaceName || "—"}
-
-
-
-
- );
- })}
-
-
- {mappedRows.length > previewRows.length && (
-
- Showing {previewRows.length} of {mappedRows.length}{" "}
- mapped rows.
-
- )}
-
- )}
-
-
- {
- setCsvData(null);
- setMapping({});
- setResult(null);
- setCsvImportProgress(0);
- }}
- >
- Clear
-
- setConfirmOpen(true)}
- >
- Review Import
-
-
-
-
- )}
-
- {result && (
-
-
-
-
- Import results
-
-
- {result.importedCount}{" "}
- {pluralize(result.importedCount, "started", "started")},{" "}
- {result.failedCount}{" "}
- {pluralize(result.failedCount, "failed", "failed")}
-
-
-
-
- {result.failedCount > 0 && (
-
- )}
-
-
-
-
-
-
- Row
- User email
- Space
- Status
-
-
-
- {result.results.map((row) => (
-
-
- {row.rowNumber}
-
- {row.userEmail || "—"}
- {row.spaceName || "—"}
-
- {row.error || (row.success ? "Started" : "Failed")}
-
-
- ))}
-
-
-
-
- )}
-
- )}
+ {mode === "csv" && }
-
-
- }
- >
- Start CSV import
-
-
- {readyRows.length} {pluralize(readyRows.length, "video", "videos")}{" "}
- will be imported for existing members or newly added users in
- batches of {LOOM_CSV_BATCH_SIZE}.
- {readyRows.some((row) => row.spaceName) && (
-
- Rows with a space name will be added to that space. Missing
- spaces will be created.
-
- )}
- {invalidRows > 0 && (
-
- {invalidRows} {pluralize(invalidRows, "row", "rows")} will be
- skipped because the Loom URL, email, or space name is invalid.
-
- )}
-
-
- setConfirmOpen(false)}
- >
- Cancel
-
-
- {isCsvImporting
- ? `Importing ${csvImportProgress}/${readyRows.length}`
- : "Start Import"}
-
-
-
-
-
{label}
);
-
-const MappingField = ({
- label,
- value,
- options,
- optional = false,
- onValueChange,
-}: {
- label: string;
- value: string | undefined;
- options: { value: string; label: string }[];
- optional?: boolean;
- onValueChange: (value: string | undefined) => void;
-}) => {
- const fieldOptions = optional
- ? [{ value: OPTIONAL_COLUMN_VALUE, label: "Do not import" }, ...options]
- : options;
-
- return (
-
-
{label}
-
- onValueChange(
- nextValue === OPTIONAL_COLUMN_VALUE ? undefined : nextValue,
- )
- }
- options={fieldOptions}
- placeholder="Choose column"
- />
-
- );
-};
-
-const StatusPill = ({ ready, label }: { ready: boolean; label?: string }) => (
-
-
- {label ?? (ready ? "Ready" : "Needs fix")}
-
-);
-
-const StatBox = ({
- label,
- value,
- tone,
-}: {
- label: string;
- value: number;
- tone: "positive" | "warn" | "neutral";
-}) => {
- const accent =
- tone === "positive"
- ? "text-green-11"
- : tone === "warn" && value > 0
- ? "text-red-11"
- : "text-gray-12";
-
- return (
-
- );
-};
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
new file mode 100644
index 00000000000..235e4acd66d
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
@@ -0,0 +1,635 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import NumberFlow from "@number-flow/react";
+import clsx from "clsx";
+import { formatDistanceToNowStrict } from "date-fns";
+import Link from "next/link";
+import {
+ type ReactNode,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { toast } from "sonner";
+import {
+ cancelLoomImportJobAction,
+ retryLoomImportJobAction,
+ startLoomImportJobAction,
+} from "@/actions/loom-import";
+import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog";
+import { buildLoomImportReport } from "@/lib/loom-import/report";
+import {
+ type LoomImportCounts,
+ type LoomImportItemView,
+ type LoomImportSnapshot,
+ loomImportProgress,
+} from "@/lib/loom-import/status";
+import { Boil, Doodle, LoomMark, Squiggle } from "../_components/doodles";
+import { HowImportWorks } from "../_components/how-import-works";
+import "../_components/loom-import.css";
+import { ROW_HEIGHT, VirtualImportList } from "./import-list";
+import { formatHours, UpgradePanel } from "./upgrade-panel";
+import { useLoomImportJob } from "./use-loom-import-job";
+
+const numberFormat = new Intl.NumberFormat("en-US");
+const LIST_VIEWPORT = ROW_HEIGHT * 9;
+
+type Filter = "all" | "active" | "imported" | "failed" | "skipped";
+
+const FILTERS: { value: Filter; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "active", label: "In progress" },
+ { value: "imported", label: "In Cap" },
+ { value: "failed", label: "Failed" },
+ { value: "skipped", label: "Skipped" },
+];
+
+function filterCount(counts: LoomImportCounts, filter: Filter) {
+ switch (filter) {
+ case "all":
+ return counts.total;
+ case "active":
+ return counts.checking + counts.ready + counts.queued + counts.importing;
+ case "imported":
+ return counts.imported;
+ case "failed":
+ return counts.failed;
+ case "skipped":
+ return counts.skipped + counts.cancelled;
+ }
+}
+
+function matchesFilter(item: LoomImportItemView, filter: Filter) {
+ switch (filter) {
+ case "all":
+ return true;
+ case "active":
+ return (
+ item.status === "checking" ||
+ item.status === "ready" ||
+ item.status === "queued" ||
+ item.status === "importing"
+ );
+ case "imported":
+ return item.status === "imported";
+ case "failed":
+ return item.status === "failed";
+ case "skipped":
+ return item.status === "skipped" || item.status === "cancelled";
+ }
+}
+
+function etaLabel(remaining: number, perMinute: number | null) {
+ if (!perMinute || remaining <= 0) return null;
+ const minutes = remaining / perMinute;
+ if (minutes < 1) return "Under a minute left";
+ if (minutes < 90) return `About ${Math.ceil(minutes)} min left`;
+ return `About ${(minutes / 60).toFixed(1)} hours left`;
+}
+
+const StatLine = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+const Hero = ({
+ doodle,
+ title,
+ body,
+ children,
+ aside,
+}: {
+ doodle: Parameters[0]["kind"];
+ title: ReactNode;
+ body: ReactNode;
+ children?: ReactNode;
+ aside?: ReactNode;
+}) => (
+
+);
+
+const STAGES = [
+ { key: "checking", label: "Checking" },
+ { key: "queued", label: "Queued" },
+ { key: "importing", label: "Copying now" },
+ { key: "imported", label: "In Cap" },
+] as const;
+
+const PipelineStrip = ({
+ counts,
+ active,
+}: {
+ counts: LoomImportCounts;
+ active: boolean;
+}) => {
+ const values = {
+ checking: counts.checking,
+ queued: counts.queued + counts.ready,
+ importing: counts.importing,
+ imported: counts.imported,
+ };
+ return (
+
+ {STAGES.map((stage, index) => {
+ const value = values[stage.key];
+ const next = STAGES[index + 1];
+ const flowing =
+ active && value > 0 && next !== undefined && stage.key !== "imported";
+ return (
+ 0
+ ? "border-blue-6 bg-blue-2"
+ : "border-gray-3 bg-gray-1",
+ )}
+ >
+
+
{stage.label}
+ {next && (
+
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+ 0 ? "text-red-11" : "text-gray-12",
+ )}
+ >
+ {numberFormat.format(counts.failed)}
+
+ Failed
+
+
+
+ {numberFormat.format(counts.skipped + counts.cancelled)}
+
+ Skipped
+
+
+
+ );
+};
+
+export const LoomImportJobView = ({
+ initial,
+ returnedFromCheckout,
+}: {
+ initial: LoomImportSnapshot;
+ returnedFromCheckout: boolean;
+}) => {
+ const [watchForUpgrade] = useState(returnedFromCheckout);
+ const { summary, items, rate, refresh } = useLoomImportJob(initial, {
+ watchForUpgrade,
+ });
+ const { job, counts } = summary;
+ const [filter, setFilter] = useState("all");
+ const [query, setQuery] = useState("");
+ const deferredQuery = useDeferredValue(query.trim().toLowerCase());
+ const [howOpen, setHowOpen] = useState(false);
+ const [cancelOpen, setCancelOpen] = useState(false);
+ const [busy, setBusy] = useState<"start" | "cancel" | "retry" | null>(null);
+ const autoStarted = useRef(false);
+
+ const settled =
+ counts.imported + counts.failed + counts.skipped + counts.cancelled;
+ const importingProgress = useMemo(
+ () =>
+ items.reduce(
+ (sum, item) =>
+ item.status === "importing" ? sum + (item.progress ?? 0) : sum,
+ 0,
+ ),
+ [items],
+ );
+ const progress = loomImportProgress(counts, importingProgress);
+ const checkingProgress =
+ counts.total > 0 ? (counts.total - counts.checking) / counts.total : 0;
+ const showOwner = summary.owners > 1;
+ const thumbs = useMemo(
+ () =>
+ items
+ .filter((item) => item.thumb && item.status !== "failed")
+ .slice(0, 12)
+ .map((item) => item.thumb as string),
+ [items],
+ );
+
+ const visible = useMemo(
+ () =>
+ items.filter((item) => {
+ if (!matchesFilter(item, filter)) return false;
+ if (!deferredQuery) return true;
+ return (
+ item.title?.toLowerCase().includes(deferredQuery) ||
+ item.url.toLowerCase().includes(deferredQuery) ||
+ item.email?.includes(deferredQuery) ||
+ item.space?.toLowerCase().includes(deferredQuery) ||
+ String(item.row) === deferredQuery
+ );
+ }),
+ [items, filter, deferredQuery],
+ );
+
+ const runStart = async (automatic: boolean) => {
+ setBusy("start");
+ const result = await startLoomImportJobAction(job.id);
+ if (!result.ok) toast.error(result.error);
+ else if (automatic)
+ toast.success("You're on Cap Pro. Your import has started.");
+ await refresh().catch(() => undefined);
+ setBusy(null);
+ };
+
+ useEffect(() => {
+ if (!watchForUpgrade || !job.canStart || autoStarted.current) return;
+ autoStarted.current = true;
+ void runStart(true);
+ });
+
+ const runCancel = async () => {
+ setBusy("cancel");
+ const result = await cancelLoomImportJobAction(job.id);
+ if (!result.ok) toast.error(result.error);
+ setCancelOpen(false);
+ await refresh().catch(() => undefined);
+ setBusy(null);
+ };
+
+ const runRetry = async () => {
+ setBusy("retry");
+ const result = await retryLoomImportJobAction(job.id);
+ if (!result.ok) toast.error(result.error);
+ else if (result.retried === 0) toast("Nothing left to retry.");
+ else
+ toast.success(`Retrying ${numberFormat.format(result.retried)} videos.`);
+ await refresh().catch(() => undefined);
+ setBusy(null);
+ };
+
+ const downloadReport = () => {
+ const csv = buildLoomImportReport(items, window.location.origin);
+ const url = URL.createObjectURL(
+ new Blob([csv], { type: "text/csv;charset=utf-8" }),
+ );
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${job.fileName.replace(/\.(csv|tsv|txt)$/i, "")} - Cap import results.csv`;
+ link.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const howStep =
+ job.status === "checking"
+ ? 2
+ : job.status === "importing"
+ ? 3
+ : job.status === "completed"
+ ? 4
+ : 0;
+
+ const eta = etaLabel(counts.total - settled, rate);
+ const canCancel = job.status === "checking" || job.status === "importing";
+ const canRetry =
+ counts.failed > 0 &&
+ (job.status === "importing" || job.status === "completed");
+
+ return (
+
+
+
+
+ Import from Loom
+
+
+
+
+
+
+
+
+ {job.fileName}
+
+
+ {numberFormat.format(job.totalCount)}{" "}
+ {job.totalCount === 1 ? "video" : "videos"} · added{" "}
+ {formatDistanceToNowStrict(new Date(job.createdAt), {
+ addSuffix: true,
+ })}
+
+
+
+
setHowOpen(true)}
+ className="shrink-0 self-start text-sm text-gray-11 underline decoration-gray-6 underline-offset-4 transition-colors hover:text-gray-12"
+ >
+ How does this work?
+
+
+
+
+
+ {job.status === "checking" && (
+
+
+
+
+ {numberFormat.format(counts.total - counts.checking)} of{" "}
+ {numberFormat.format(counts.total)} checked
+
+
+
+ )}
+
+ {job.status === "awaiting_upgrade" && (
+
runStart(false)}
+ />
+ )}
+
+ {job.status === "importing" && (
+
+
+
+ of {numberFormat.format(counts.total)} videos are in Cap
+
+
+ }
+ body="Videos copy over a few at a time, each into its owner's library with its original title and date. You can close this tab, the import keeps going."
+ >
+
+
+
+
+ {Math.round(progress * 100)}%
+
+
+ {rate && (
+
+
+ {rate >= 10 ? Math.round(rate) : rate.toFixed(1)} videos a
+ minute
+
+
+ )}
+ {eta && {eta} }
+ {summary.importedDuration > 0 && (
+
+ {formatHours(summary.importedDuration)} of{" "}
+ {formatHours(summary.totalDuration)} copied
+
+ )}
+
+
+ )}
+
+ {job.status === "completed" && (
+ 0 ? "done" : "error"}
+ title={
+ counts.imported > 0
+ ? `All done. ${numberFormat.format(counts.imported)} ${counts.imported === 1 ? "video is" : "videos are"} in Cap.`
+ : "Nothing could be imported"
+ }
+ body={
+ counts.failed > 0
+ ? `${numberFormat.format(counts.failed)} ${counts.failed === 1 ? "video" : "videos"} didn't make it. You'll find the reason next to each one, and you can try them again.`
+ : "Transcripts, summaries and chapters are made the first time each video is opened, so there's nothing left to wait for."
+ }
+ >
+
+
+
+ Open my Caps
+
+
+ Download Loom to Cap links
+
+
+
+ )}
+
+ {job.status === "cancelled" && (
+
+
+
+ Download Loom to Cap links
+
+
+
+ )}
+
+ {job.status !== "awaiting_upgrade" && (
+
+ )}
+
+
+
+
+ {FILTERS.map((option) => {
+ const count = filterCount(counts, option.value);
+ return (
+ setFilter(option.value)}
+ className={clsx(
+ "inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm transition-colors",
+ filter === option.value
+ ? "bg-gray-12 text-gray-1"
+ : "text-gray-11 hover:bg-gray-3 hover:text-gray-12",
+ )}
+ >
+ {option.label}
+
+ {numberFormat.format(count)}
+
+
+ );
+ })}
+
+
+ setQuery(event.target.value)}
+ placeholder="Search titles, owners, links"
+ aria-label="Search videos"
+ className="h-8 w-full rounded-full border border-gray-4 bg-gray-2 px-3 text-sm text-gray-12 outline-none transition-colors placeholder:text-gray-9 focus:border-blue-8 sm:w-56"
+ />
+ {canRetry && (
+
+ Retry {numberFormat.format(counts.failed)} failed
+
+ )}
+ {settled > 0 &&
+ job.status !== "completed" &&
+ job.status !== "cancelled" && (
+
+ Download results
+
+ )}
+ {canCancel && (
+ setCancelOpen(true)}
+ >
+ Stop import
+
+ )}
+
+
+
+
+
+
+ AI titles, summaries and chapters for imported videos are made the
+ first time each video is opened. Need more than{" "}
+ {numberFormat.format(2000)} videos? Start another import with the next
+ CSV, they run side by side.
+
+
+
+
+
setCancelOpen(false)}
+ />
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
new file mode 100644
index 00000000000..1f1d7103378
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
@@ -0,0 +1,297 @@
+"use client";
+
+import clsx from "clsx";
+import Link from "next/link";
+import {
+ memo,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import {
+ type LoomImportDisplayStatus,
+ type LoomImportItemView,
+ loomImportStageLabel,
+} from "@/lib/loom-import/status";
+
+export const ROW_HEIGHT = 64;
+const OVERSCAN = 8;
+
+const dateFormat = new Intl.DateTimeFormat("en-US", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+});
+
+export function formatDuration(seconds: number | null | undefined) {
+ if (!seconds || seconds <= 0) return null;
+ const total = Math.round(seconds);
+ const hours = Math.floor(total / 3600);
+ const minutes = Math.floor((total % 3600) / 60);
+ const secs = total % 60;
+ return hours > 0
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
+ : `${minutes}:${String(secs).padStart(2, "0")}`;
+}
+
+function shortLoomUrl(url: string) {
+ return url.replace(/^https?:\/\/(www\.)?/, "");
+}
+
+export function useVirtualWindow(count: number, viewport: number) {
+ const ref = useRef(null);
+ const [scrollTop, setScrollTop] = useState(0);
+ const frame = useRef(null);
+
+ const onScroll = useCallback(() => {
+ if (frame.current !== null) return;
+ frame.current = window.requestAnimationFrame(() => {
+ frame.current = null;
+ setScrollTop(ref.current?.scrollTop ?? 0);
+ });
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (frame.current !== null) window.cancelAnimationFrame(frame.current);
+ },
+ [],
+ );
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+ const max = Math.max(0, count * ROW_HEIGHT - viewport);
+ if (element.scrollTop > max) {
+ element.scrollTop = max;
+ setScrollTop(max);
+ }
+ }, [count, viewport]);
+
+ const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
+ const end = Math.min(
+ count,
+ Math.ceil((scrollTop + viewport) / ROW_HEIGHT) + OVERSCAN,
+ );
+ return { ref, onScroll, start, end };
+}
+
+const STATUS_TONE: Record = {
+ checking: "text-gray-10",
+ ready: "text-gray-11",
+ queued: "text-gray-10",
+ importing: "text-blue-11",
+ imported: "text-[#218358] dark:text-[#3dd68c]",
+ failed: "text-red-11",
+ skipped: "text-gray-10",
+ cancelled: "text-gray-9",
+};
+
+const STATUS_LABEL: Record = {
+ checking: "Checking",
+ ready: "Ready",
+ queued: "Queued",
+ importing: "Importing",
+ imported: "In Cap",
+ failed: "Failed",
+ skipped: "Skipped",
+ cancelled: "Cancelled",
+};
+
+const Tick = () => (
+
+
+
+);
+
+const StatusCell = ({ item }: { item: LoomImportItemView }) => {
+ const tone = STATUS_TONE[item.status];
+ if (item.status === "importing") {
+ const progress = item.progress ?? 0;
+ return (
+
+
+ {loomImportStageLabel(item.stage)}
+
+
+
+ );
+ }
+
+ if (item.status === "imported" && item.videoId) {
+ return (
+
+
+ {STATUS_LABEL.imported}
+
+ );
+ }
+
+ if (item.status === "skipped" && item.videoId) {
+ return (
+
+ Already in Cap
+
+ Open it
+
+
+ );
+ }
+
+ return (
+
+
+ {item.status === "checking" && (
+
+ )}
+ {STATUS_LABEL[item.status]}
+
+ {item.error && (
+
+ {item.error}
+
+ )}
+
+ );
+};
+
+const Thumb = ({ src }: { src: string | null }) => (
+
+ {src && (
+
+ )}
+
+);
+
+export const ImportRow = memo(function ImportRow({
+ item,
+ showOwner,
+}: {
+ item: LoomImportItemView;
+ showOwner: boolean;
+}) {
+ const meta = [
+ item.recordedAt
+ ? `Recorded ${dateFormat.format(new Date(item.recordedAt))}`
+ : null,
+ formatDuration(item.duration),
+ showOwner ? item.email : null,
+ item.space,
+ ].filter(Boolean);
+ return (
+
+
+
+
+ {item.title ?? shortLoomUrl(item.url)}
+
+
+ Row {item.row}
+ {meta.length > 0 && ` · ${meta.join(" · ")}`}
+
+
+
+
+
+
+ );
+});
+
+export const VirtualImportList = ({
+ items,
+ viewport,
+ showOwner,
+ empty,
+}: {
+ items: LoomImportItemView[];
+ viewport: number;
+ showOwner: boolean;
+ empty: ReactNode;
+}) => {
+ const { ref, onScroll, start, end } = useVirtualWindow(
+ items.length,
+ viewport,
+ );
+ const height = Math.min(viewport, Math.max(items.length, 1) * ROW_HEIGHT);
+
+ if (items.length === 0) {
+ return (
+
+ {empty}
+
+ );
+ }
+
+ return (
+
+
+ {items.slice(start, end).map((item, index) => (
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx
new file mode 100644
index 00000000000..1a4627ca545
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx
@@ -0,0 +1,35 @@
+import { getCurrentUser } from "@cap/database/auth/session";
+import type { Metadata } from "next";
+import { notFound, redirect } from "next/navigation";
+import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot";
+import { LoomImportJobView } from "./LoomImportJobView";
+
+export const metadata: Metadata = {
+ title: "Loom import — Cap",
+};
+
+export default async function Page({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ jobId: string }>;
+ searchParams: Promise>;
+}) {
+ const [{ jobId }, query, user] = await Promise.all([
+ params,
+ searchParams,
+ getCurrentUser(),
+ ]);
+ if (!user) redirect("/login");
+
+ const snapshot = await getLoomImportSnapshot({ jobId, userId: user.id });
+ if (!snapshot) notFound();
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx
new file mode 100644
index 00000000000..728ea042dfa
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import NumberFlow from "@number-flow/react";
+import { useMutation } from "@tanstack/react-query";
+import clsx from "clsx";
+import { useCurrency } from "hooks/useCurrency";
+import { useId, useState } from "react";
+import { toast } from "sonner";
+import { useStripeContext } from "@/app/Layout/StripeContext";
+import { UpgradeModal } from "@/components/UpgradeModal";
+import { PRICING } from "@/data/pricing";
+import { Boil, Doodle, delay } from "../_components/doodles";
+
+const numberFormat = new Intl.NumberFormat("en-US");
+const ANNUAL_SAVINGS = Math.round(
+ (1 - PRICING.pro.annualPerMonth / PRICING.pro.monthly) * 100,
+);
+
+export function formatHours(seconds: number) {
+ if (seconds <= 0) return "0m";
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.round((seconds % 3600) / 60);
+ if (hours === 0) return `${Math.max(minutes, 1)}m`;
+ return minutes > 0
+ ? `${numberFormat.format(hours)}h ${minutes}m`
+ : `${numberFormat.format(hours)}h`;
+}
+
+const PERKS = [
+ "Every video, any length, unlimited storage",
+ "Original titles and recording dates kept",
+ "AI summaries and chapters the first time a video is watched",
+ "Custom domain, passwords and viewer analytics",
+];
+
+const InkCheck = ({ at }: { at: number }) => (
+
+
+
+
+
+);
+
+export const UpgradePanel = ({
+ jobId,
+ videoCount,
+ totalDuration,
+ owners,
+ thumbs,
+ confirming,
+ canStart,
+ starting,
+ onStart,
+}: {
+ jobId: string;
+ videoCount: number;
+ totalDuration: number;
+ owners: number;
+ thumbs: string[];
+ confirming: boolean;
+ canStart: boolean;
+ starting: boolean;
+ onStart: () => void;
+}) => {
+ const stripe = useStripeContext();
+ const { currency } = useCurrency();
+ const [annual, setAnnual] = useState(true);
+ const [compareOpen, setCompareOpen] = useState(false);
+ const titleId = useId();
+ const perMonth = annual ? PRICING.pro.annualPerMonth : PRICING.pro.monthly;
+
+ const checkout = useMutation({
+ mutationFn: async () => {
+ const response = await fetch("/api/settings/billing/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ priceId: stripe.plans[annual ? "yearly" : "monthly"],
+ quantity: 1,
+ returnTo: `/dashboard/import/loom/${jobId}`,
+ }),
+ });
+ const data = (await response.json().catch(() => ({}))) as {
+ url?: string;
+ subscription?: boolean;
+ };
+ if (data.subscription) {
+ window.location.reload();
+ return;
+ }
+ if (!data.url) throw new Error("Checkout unavailable");
+ window.location.href = data.url;
+ },
+ onError: () => {
+ toast.error("We couldn't open checkout. Please try again.");
+ },
+ });
+
+ if (confirming || canStart) {
+ return (
+
+
+
+
+ {canStart ? "You're on Cap Pro" : "Confirming your upgrade"}
+
+
+ {canStart
+ ? `${numberFormat.format(videoCount)} videos are checked and ready to copy into Cap.`
+ : "This usually takes a few seconds. Your import starts on its own once it's done."}
+
+
+ {canStart && (
+
+ Start importing
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Your Loom library is ready to move
+
+
+ We checked every link. Upgrade to Cap Pro and we'll copy them into
+ Cap for you, right where your team expects them.
+
+
+
+
+
+ {[
+ {
+ value: numberFormat.format(videoCount),
+ label: videoCount === 1 ? "video" : "videos",
+ },
+ { value: formatHours(totalDuration), label: "of recordings" },
+ {
+ value: numberFormat.format(owners),
+ label: owners === 1 ? "owner" : "owners",
+ },
+ ].map((stat) => (
+
+
{stat.label}
+
+ {stat.value}
+
+
+ ))}
+
+
+ {thumbs.length > 0 && (
+
+ {thumbs.slice(0, 12).map((thumb, index) => (
+
= 8 && "hidden sm:block",
+ )}
+ style={delay(0.05 * index)}
+ >
+
+
+ ))}
+
+ )}
+
+
+
+
+
Cap Pro
+
+ {[
+ { value: false, label: "Monthly" },
+ { value: true, label: `Yearly · save ${ANNUAL_SAVINGS}%` },
+ ].map((option) => (
+ setAnnual(option.value)}
+ className={clsx(
+ "rounded-full px-2.5 py-1 transition-colors",
+ annual === option.value
+ ? "bg-gray-12 text-gray-1"
+ : "text-gray-11 hover:text-gray-12",
+ )}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ per user / month{annual ? ", billed yearly" : ""}
+
+
+
+
+ {PERKS.map((perk, index) => (
+
+
+ {perk}
+
+ ))}
+
+
+
+
checkout.mutate()}
+ >
+ Upgrade and import {numberFormat.format(videoCount)}{" "}
+ {videoCount === 1 ? "video" : "videos"}
+
+
+ Cancel anytime. Your import starts as soon as you're back.
+
+
setCompareOpen(true)}
+ className="mx-auto text-xs text-gray-11 underline decoration-gray-6 underline-offset-4 hover:text-gray-12"
+ >
+ See everything in Cap Pro
+
+
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
new file mode 100644
index 00000000000..20b1b5c3890
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -0,0 +1,187 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ isLoomImportJobActive,
+ type LoomImportItemView,
+ type LoomImportSnapshot,
+} from "@/lib/loom-import/status";
+
+const ACTIVE_POLL_MS = 1500;
+const HIDDEN_POLL_MS = 15_000;
+const UPGRADE_POLL_MS = 2500;
+const RATE_WINDOW_MS = 90_000;
+const RATE_MIN_SPAN_MS = 10_000;
+
+export type LoomImportSummary = Omit<
+ LoomImportSnapshot,
+ "items" | "cursor" | "full"
+>;
+
+type RateSample = { at: number; settled: number };
+
+function toSummary(snapshot: LoomImportSnapshot): LoomImportSummary {
+ return {
+ job: snapshot.job,
+ counts: snapshot.counts,
+ totalDuration: snapshot.totalDuration,
+ importedDuration: snapshot.importedDuration,
+ owners: snapshot.owners,
+ };
+}
+
+function settledCount(summary: LoomImportSummary) {
+ const { imported, failed, skipped, cancelled } = summary.counts;
+ return imported + failed + skipped + cancelled;
+}
+
+export function mergeLoomImportItems(
+ map: Map,
+ order: string[],
+ snapshot: LoomImportSnapshot,
+) {
+ let nextOrder = order;
+ if (snapshot.full) {
+ map.clear();
+ nextOrder = snapshot.items.map((item) => item.id);
+ }
+ let changed = snapshot.full;
+ for (const item of snapshot.items) {
+ const previous = map.get(item.id);
+ if (
+ !previous ||
+ previous.v !== item.v ||
+ previous.status !== item.status ||
+ previous.progress !== item.progress ||
+ previous.stage !== item.stage
+ ) {
+ map.set(item.id, item);
+ changed = true;
+ }
+ }
+ return { order: nextOrder, changed };
+}
+
+export function useLoomImportJob(
+ initial: LoomImportSnapshot,
+ { watchForUpgrade }: { watchForUpgrade: boolean },
+) {
+ const itemsRef = useRef | null>(null);
+ const orderRef = useRef([]);
+ const cursorRef = useRef(initial.cursor);
+ const samplesRef = useRef([]);
+ if (itemsRef.current === null) {
+ itemsRef.current = new Map(initial.items.map((item) => [item.id, item]));
+ orderRef.current = initial.items.map((item) => item.id);
+ }
+
+ const [items, setItems] = useState(initial.items);
+ const [summary, setSummary] = useState(() =>
+ toSummary(initial),
+ );
+ const [rate, setRate] = useState(null);
+
+ const apply = useCallback((snapshot: LoomImportSnapshot) => {
+ const map = itemsRef.current;
+ if (!map) return;
+ const merged = mergeLoomImportItems(map, orderRef.current, snapshot);
+ orderRef.current = merged.order;
+ cursorRef.current = Math.max(cursorRef.current, snapshot.cursor);
+
+ const next = toSummary(snapshot);
+ const now = Date.now();
+ const samples = samplesRef.current;
+ samples.push({ at: now, settled: settledCount(next) });
+ while (
+ samples.length > 2 &&
+ (samples[0]?.at ?? now) < now - RATE_WINDOW_MS
+ ) {
+ samples.shift();
+ }
+ const first = samples[0];
+ const last = samples[samples.length - 1];
+ const span = first && last ? last.at - first.at : 0;
+ const perMinute =
+ first && last && span >= RATE_MIN_SPAN_MS
+ ? ((last.settled - first.settled) / span) * 60_000
+ : 0;
+
+ setSummary(next);
+ setRate(perMinute > 0 ? perMinute : null);
+ if (merged.changed) {
+ const list: LoomImportItemView[] = [];
+ for (const id of merged.order) {
+ const item = map.get(id);
+ if (item) list.push(item);
+ }
+ setItems(list);
+ }
+ }, []);
+
+ const fetchSnapshot = useCallback(
+ async (full = false) => {
+ const params = new URLSearchParams({ jobId: initial.job.id });
+ if (!full) params.set("since", String(cursorRef.current));
+ const response = await fetch(`/api/import/loom/jobs?${params}`, {
+ cache: "no-store",
+ credentials: "same-origin",
+ });
+ if (!response.ok) throw new Error(`Snapshot failed: ${response.status}`);
+ apply((await response.json()) as LoomImportSnapshot);
+ },
+ [apply, initial.job.id],
+ );
+
+ const status = summary.job.status;
+ const shouldPoll =
+ isLoomImportJobActive(status) ||
+ (status === "awaiting_upgrade" && watchForUpgrade);
+
+ useEffect(() => {
+ if (!shouldPoll) return;
+ let stopped = false;
+ let timer: number | undefined;
+ let hiddenAt: number | null = null;
+
+ const schedule = () => {
+ if (stopped) return;
+ const wait = document.hidden
+ ? HIDDEN_POLL_MS
+ : status === "awaiting_upgrade"
+ ? UPGRADE_POLL_MS
+ : ACTIVE_POLL_MS;
+ timer = window.setTimeout(tick, wait);
+ };
+
+ const tick = () => {
+ fetchSnapshot()
+ .catch(() => undefined)
+ .finally(schedule);
+ };
+
+ const onVisibility = () => {
+ if (document.hidden) {
+ hiddenAt = Date.now();
+ return;
+ }
+ const full = hiddenAt !== null && Date.now() - hiddenAt > 60_000;
+ hiddenAt = null;
+ window.clearTimeout(timer);
+ fetchSnapshot(full)
+ .catch(() => undefined)
+ .finally(schedule);
+ };
+
+ document.addEventListener("visibilitychange", onVisibility);
+ schedule();
+ return () => {
+ stopped = true;
+ window.clearTimeout(timer);
+ document.removeEventListener("visibilitychange", onVisibility);
+ };
+ }, [shouldPoll, status, fetchSnapshot]);
+
+ const refresh = useCallback(() => fetchSnapshot(true), [fetchSnapshot]);
+
+ return { summary, items, rate, refresh };
+}
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
new file mode 100644
index 00000000000..95cae03dbf3
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
@@ -0,0 +1,668 @@
+"use client";
+
+import { Button, Select } from "@cap/ui";
+import type { Organisation } from "@cap/web-domain";
+import clsx from "clsx";
+import { useRouter } from "next/navigation";
+import {
+ type ChangeEvent,
+ type DragEvent,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ useTransition,
+} from "react";
+import { toast } from "sonner";
+import { createLoomImportJobAction } from "@/actions/loom-import";
+import {
+ buildLoomImportPlan,
+ type CsvTable,
+ countFilledRows,
+ detectLoomImportMapping,
+ LOOM_CSV_TEMPLATE,
+ LOOM_IMPORT_ISSUE_LABELS,
+ LOOM_IMPORT_MAX_ROWS,
+ type LoomImportField,
+ type LoomImportMapping,
+ parseCsv,
+ tableFromPastedLinks,
+} from "@/lib/loom-import/csv";
+import { Doodle, InkArrow, StepMark } from "./doodles";
+import { HowImportWorks } from "./how-import-works";
+import "./loom-import.css";
+
+const NOT_IN_FILE = "__not_in_file__";
+const MAX_FILE_BYTES = 8 * 1024 * 1024;
+const numberFormat = new Intl.NumberFormat("en-US");
+const plural = (count: number, one: string, many: string) =>
+ `${numberFormat.format(count)} ${count === 1 ? one : many}`;
+
+type Source = {
+ name: string;
+ table: CsvTable;
+ pasted: boolean;
+};
+
+const GUIDE = [
+ {
+ doodle: "sheet" as const,
+ title: "Get your list from Loom",
+ body: "Admins can export every video from Settings, Workspace, Data, Export. Or just copy your Loom links.",
+ },
+ {
+ doodle: "drop" as const,
+ title: "Drop it here",
+ body: "We find the Loom links, owners and spaces on our own. You check them before anything starts.",
+ },
+ {
+ doodle: "move" as const,
+ title: "Follow along live",
+ body: "Videos keep their titles and recording dates. Close the tab any time, the import keeps going.",
+ },
+];
+
+export const ImportGuide = ({ onHowItWorks }: { onHowItWorks: () => void }) => (
+
+
+ {GUIDE.map((step, index) => (
+
+
+
+
+
+
+
+
{step.title}
+
+ {step.body}
+
+
+
+
+ ))}
+
+
+ How does importing work?
+
+
+);
+
+function downloadTemplate() {
+ const blob = new Blob([LOOM_CSV_TEMPLATE], {
+ type: "text/csv;charset=utf-8",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "cap-loom-import-template.csv";
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+const SourcePicker = ({ onSource }: { onSource: (source: Source) => void }) => {
+ const inputRef = useRef(null);
+ const [isOver, setIsOver] = useState(false);
+ const [pasteOpen, setPasteOpen] = useState(false);
+ const [pasteText, setPasteText] = useState("");
+ const pasteId = useId();
+
+ const readFile = async (file: File) => {
+ if (file.size > MAX_FILE_BYTES) {
+ toast.error("That file is over 8 MB. Split it into smaller CSVs.");
+ return;
+ }
+ try {
+ const table = parseCsv(await file.text());
+ if (countFilledRows(table) === 0) {
+ toast.error("That CSV has a header but no rows.");
+ return;
+ }
+ onSource({ name: file.name, table, pasted: false });
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "We couldn't read that CSV.",
+ );
+ }
+ };
+
+ const onDrop = (event: DragEvent) => {
+ event.preventDefault();
+ setIsOver(false);
+ const file = event.dataTransfer.files[0];
+ if (file) void readFile(file);
+ };
+
+ const onFile = (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) void readFile(file);
+ event.target.value = "";
+ };
+
+ const usePasted = () => {
+ const table = tableFromPastedLinks(pasteText);
+ if (table.rows.length === 0) {
+ toast.error("We couldn't find any Loom links in that text.");
+ return;
+ }
+ onSource({ name: "Pasted Loom links", table, pasted: true });
+ };
+
+ return (
+
+
{
+ event.preventDefault();
+ setIsOver(true);
+ }}
+ onDragLeave={() => setIsOver(false)}
+ onDrop={onDrop}
+ className={clsx(
+ "li-dropzone relative flex flex-col items-center gap-4 rounded-2xl px-6 py-10 text-center transition-colors duration-200 sm:flex-row sm:gap-8 sm:px-10 sm:text-left",
+ isOver ? "is-over bg-blue-2" : "bg-gray-1",
+ )}
+ >
+
+
+
+ {isOver ? "Drop to read your CSV" : "Drop your Loom CSV here"}
+
+
+ Any CSV with Loom links works, including Loom's own export. Up to{" "}
+ {numberFormat.format(LOOM_IMPORT_MAX_ROWS)} videos per file.
+
+
+ inputRef.current?.click()}
+ >
+ Choose a CSV
+
+ setPasteOpen((open) => !open)}
+ >
+ Paste links instead
+
+
+
+
+
+
+ {pasteOpen && (
+
+ )}
+
+
+
+ Got more than {numberFormat.format(LOOM_IMPORT_MAX_ROWS)} videos?
+ Split them into a few CSVs and import each one. They can run side by
+ side.
+
+
+ Download a template
+
+
+
+ );
+};
+
+const FIELD_COPY: Record<
+ LoomImportField,
+ { label: string; found: string; missing: string }
+> = {
+ loomUrl: {
+ label: "Loom links",
+ found: "Each row becomes one video.",
+ missing: "Pick the column that holds the Loom links.",
+ },
+ ownerEmail: {
+ label: "Owners",
+ found:
+ "Each video goes to this person's library. New emails join your organization without an invite email.",
+ missing: "Without owners, every video goes to your library.",
+ },
+ spaceName: {
+ label: "Spaces",
+ found: "Videos are added to these spaces. Missing spaces are created.",
+ missing: "Optional. Leave it out to keep videos in each owner's library.",
+ },
+};
+
+const MappingRow = ({
+ field,
+ table,
+ value,
+ onChange,
+ optional,
+}: {
+ field: LoomImportField;
+ table: CsvTable;
+ value: number | undefined;
+ onChange: (value: number | undefined) => void;
+ optional: boolean;
+}) => {
+ const copy = FIELD_COPY[field];
+ const example =
+ value === undefined
+ ? null
+ : (table.rows.find((row) => row[value]?.trim())?.[value] ?? null);
+ const options = [
+ ...(optional ? [{ value: NOT_IN_FILE, label: "Not in this file" }] : []),
+ ...table.headers.map((header, index) => ({
+ value: String(index),
+ label: header || `Column ${index + 1}`,
+ })),
+ ];
+
+ return (
+
+
{copy.label}
+
+ onChange(next === NOT_IN_FILE ? undefined : Number(next))
+ }
+ options={options}
+ placeholder="Choose a column"
+ />
+
+ {example ? (
+ <>
+
+
+ {example}
+
+ >
+ ) : (
+ {copy.missing}
+ )}
+
+
+ );
+};
+
+const Stat = ({
+ value,
+ label,
+ warn = false,
+}: {
+ value: number;
+ label: string;
+ warn?: boolean;
+}) => (
+
+ 0
+ ? "text-[#b4690e] dark:text-[#f3c675]"
+ : "text-gray-12",
+ )}
+ >
+ {numberFormat.format(value)}
+
+ {label}
+
+);
+
+const ReviewCard = ({
+ source,
+ isAdmin,
+ isPro,
+ orgId,
+ onReset,
+}: {
+ source: Source;
+ isAdmin: boolean;
+ isPro: boolean;
+ orgId: Organisation.OrganisationId;
+ onReset: () => void;
+}) => {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [showIssues, setShowIssues] = useState(false);
+ const [mapping, setMapping] = useState(() =>
+ detectLoomImportMapping(source.table),
+ );
+ const plan = useMemo(
+ () => buildLoomImportPlan(source.table, mapping, { allowOwners: isAdmin }),
+ [source.table, mapping, isAdmin],
+ );
+ const problems = plan.issues.filter((issue) => issue.reason !== "duplicate");
+ const duplicates = plan.issues.length - problems.length;
+ const ready = plan.rows.length;
+ const preview = plan.rows.slice(0, 5);
+ const canStart =
+ ready > 0 && !plan.overLimit && mapping.loomUrl !== undefined;
+
+ const setField = (field: LoomImportField) => (value: number | undefined) =>
+ setMapping((current) => ({ ...current, [field]: value }));
+
+ const start = () => {
+ startTransition(async () => {
+ const result = await createLoomImportJobAction({
+ orgId,
+ fileName: source.name,
+ rows: plan.rows,
+ });
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ router.push(`/dashboard/import/loom/${result.jobId}`);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {source.name}
+
+
+ {plural(
+ source.table.rows.length,
+ source.pasted ? "link" : "row",
+ source.pasted ? "links" : "rows",
+ )}
+
+
+
+
+ {source.pasted ? "Start over" : "Choose another file"}
+
+
+
+
+
+
What we found
+
+ {isAdmin
+ ? "Change any column if we picked the wrong one."
+ : "Every video goes to your library. Only organization admins can import for teammates."}
+
+
+
+ {isAdmin && !source.pasted && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+ 1 ? "owners" : "owner"}
+ />
+
+
+
+
+ {(problems.length > 0 || duplicates > 0) && (
+
+
+
+ {problems.length > 0 &&
+ `${plural(problems.length, "row", "rows")} will be left out. `}
+ {duplicates > 0 &&
+ `${plural(duplicates, "duplicate is", "duplicates are")} skipped.`}
+
+
setShowIssues((open) => !open)}
+ aria-expanded={showIssues}
+ className="text-gray-11 underline decoration-gray-6 underline-offset-4 hover:text-gray-12"
+ >
+ {showIssues ? "Hide rows" : "Show rows"}
+
+
+ {showIssues && (
+
+ {plan.issues.slice(0, 200).map((issue) => (
+
+
+ Row {issue.rowNumber}
+
+
+ {issue.value || "Empty"}
+
+
+ {issue.reason === "duplicate" && issue.duplicateOf
+ ? `Same as row ${issue.duplicateOf}`
+ : LOOM_IMPORT_ISSUE_LABELS[issue.reason]}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {plan.overLimit && (
+
+
+
+
+ This file has {numberFormat.format(ready)} videos. Each import
+ holds up to {numberFormat.format(LOOM_IMPORT_MAX_ROWS)}.
+
+
+ Split it into {Math.ceil(ready / LOOM_IMPORT_MAX_ROWS)} CSVs and
+ import them one after another. They can run at the same time.
+
+
+
+ )}
+
+ {preview.length > 0 && (
+
+
+
+
+ Row
+ Loom link
+ {isAdmin && !source.pasted && (
+ <>
+
+ Owner
+
+
+ Space
+
+ >
+ )}
+
+
+
+ {preview.map((row) => (
+
+
+ {row.rowNumber}
+
+
+ {row.loomUrl}
+
+ {isAdmin && !source.pasted && (
+ <>
+
+ {row.ownerEmail ?? "You"}
+
+
+ {row.spaceName ?? "None"}
+
+ >
+ )}
+
+ ))}
+
+
+ {ready > preview.length && (
+
+ And{" "}
+ {plural(ready - preview.length, "more video", "more videos")}
+
+ )}
+
+ )}
+
+
+
+
+ );
+};
+
+export const BulkImport = ({
+ orgId,
+ isAdmin,
+ isPro,
+}: {
+ orgId: Organisation.OrganisationId;
+ isAdmin: boolean;
+ isPro: boolean;
+}) => {
+ const [source, setSource] = useState(null);
+ const [howOpen, setHowOpen] = useState(false);
+
+ return (
+
+ {source ? (
+ setSource(null)}
+ />
+ ) : (
+ <>
+ setHowOpen(true)} />
+
+ >
+ )}
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/doodles.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/doodles.tsx
new file mode 100644
index 00000000000..0a63a19702e
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/doodles.tsx
@@ -0,0 +1,495 @@
+"use client";
+
+import clsx from "clsx";
+import {
+ type CSSProperties,
+ type ReactNode,
+ useId,
+ useSyncExternalStore,
+} from "react";
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribeReducedMotion(onChange: () => void) {
+ const query = window.matchMedia(REDUCED_MOTION_QUERY);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+}
+
+export function useReducedMotion() {
+ return useSyncExternalStore(
+ subscribeReducedMotion,
+ () => window.matchMedia(REDUCED_MOTION_QUERY).matches,
+ () => false,
+ );
+}
+
+export const delay = (seconds: number) =>
+ ({ "--d": `${seconds}s` }) as CSSProperties;
+
+export const Boil = ({
+ children,
+ scale = 2.4,
+}: {
+ children: ReactNode;
+ scale?: number;
+}) => {
+ const id = `li-boil-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
+ const reducedMotion = useReducedMotion();
+ return (
+ <>
+
+
+
+ {!reducedMotion && (
+
+ )}
+
+
+
+
+ {children}
+ >
+ );
+};
+
+const Sparks = ({ at = 0.6 }: { at?: number }) => (
+ <>
+
+
+
+ >
+);
+
+const SHEET = "M 34 10 L 70 10 L 86 26 L 86 82 L 34 82 Z";
+const SHEET_FOLD = "M 70 10 L 70 26 L 86 26";
+
+export type DoodleKind =
+ | "sheet"
+ | "drop"
+ | "move"
+ | "check"
+ | "magnify"
+ | "done"
+ | "error"
+ | "unlock"
+ | "watch"
+ | "stop";
+
+export const Doodle = ({
+ kind,
+ className,
+}: {
+ kind: DoodleKind;
+ className?: string;
+}) => (
+
+
+ {kind === "sheet" && (
+ <>
+
+
+ {[40, 52, 64].map((y, index) => (
+
+ ))}
+
+ >
+ )}
+ {kind === "drop" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {kind === "move" && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {kind === "check" &&
+ [18, 42, 66].map((y, index) => (
+
+
+
+
+
+ ))}
+ {kind === "magnify" && (
+ <>
+ {[22, 40, 58, 76].map((y, index) => (
+
+ ))}
+
+
+
+
+
+ >
+ )}
+ {kind === "done" && (
+ <>
+
+
+ >
+ )}
+ {kind === "error" && (
+ <>
+
+
+ >
+ )}
+ {kind === "stop" && (
+ <>
+
+
+ >
+ )}
+ {kind === "unlock" && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {kind === "watch" && (
+ <>
+
+
+ {[70, 80].map((y, index) => (
+
+ ))}
+
+ >
+ )}
+
+
+);
+
+const SQUIGGLE =
+ "M 4 14 q 6 -7 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0";
+
+export const Squiggle = ({
+ progress,
+ done = false,
+ className,
+}: {
+ progress: number | null;
+ done?: boolean;
+ className?: string;
+}) => (
+
+
+
+
+);
+
+export const StepMark = ({ value }: { value: number }) => (
+
+
+
+
+
+ {value}
+
+
+);
+
+export const InkArrow = ({ className }: { className?: string }) => (
+
+
+
+
+
+);
+
+export const LoomMark = ({ size = 18 }: { size?: number }) => (
+
+
+
+);
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/how-import-works.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/how-import-works.tsx
new file mode 100644
index 00000000000..071f11d4e31
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/how-import-works.tsx
@@ -0,0 +1,578 @@
+"use client";
+
+import { Dialog, DialogContent, DialogTitle } from "@cap/ui";
+import clsx from "clsx";
+import { type CSSProperties, useCallback, useEffect, useState } from "react";
+import { Boil, delay, LoomMark, useReducedMotion } from "./doodles";
+import "./loom-import.css";
+
+const Chip = ({
+ x,
+ y,
+ width,
+ label,
+ accent = false,
+ at,
+}: {
+ x: number;
+ y: number;
+ width: number;
+ label: string;
+ accent?: boolean;
+ at: number;
+}) => (
+
+
+
+ {label}
+
+
+);
+
+const Sheet = ({ x, y, at = 0 }: { x: number; y: number; at?: number }) => (
+
+
+
+
+
+);
+
+const ExportScene = () => (
+
+
+
+ {[58, 72, 86].map((cx) => (
+
+ ))}
+
+
+
+
+ {[66, 84, 102, 120].map((y) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ your CSV
+
+
+);
+
+const DropScene = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ we find
+
+
+);
+
+const CHECK_ROWS = [
+ { title: 148, ok: true },
+ { title: 112, ok: true },
+ { title: 168, ok: true },
+ { title: 126, ok: false },
+ { title: 140, ok: true },
+] as const;
+
+const CheckScene = () => (
+
+ {CHECK_ROWS.map((row, index) => {
+ const y = 30 + index * 38;
+ return (
+
+
+
+
+
+ );
+ })}
+
+ {CHECK_ROWS.map((row, index) => {
+ const y = 30 + index * 38;
+ const at = 0.9 + index * 0.45;
+ return row.ok ? (
+
+ ) : (
+
+
+
+ );
+ })}
+
+
+ private
+
+
+);
+
+const COPY_PATH = "M 116 112 C 190 34 290 34 352 104";
+
+const CopyScene = () => {
+ const reducedMotion = useReducedMotion();
+ return (
+
+
+
+
+
+
+
+
+ {!reducedMotion &&
+ [0, 0.8, 1.6].map((begin) => (
+
+
+
+
+ ))}
+
+ {[0, 1, 2, 3, 4, 5].map((index) => (
+
+ ))}
+
+ your Cap library
+
+
+ titles and dates come along
+
+
+ );
+};
+
+const WatchScene = () => (
+
+
+
+
+
+
+
+
+
+ Summary
+
+
+ {[78, 94, 110, 126].map((y, index) => (
+
+ ))}
+
+
+
+
+
+);
+
+export const IMPORT_STEPS = [
+ {
+ title: "Get your list from Loom",
+ body: "Loom admins can export every video from Settings, Workspace, Data, Export. No CSV? Paste your Loom links instead.",
+ Scene: ExportScene,
+ },
+ {
+ title: "Drop it into Cap",
+ body: "We spot the column with Loom links on our own, plus owner emails and spaces if your list has them. Up to 2,000 videos per CSV.",
+ Scene: DropScene,
+ },
+ {
+ title: "We check every link first",
+ body: "Titles, recording dates and lengths come straight from Loom. Private or deleted videos are flagged before anything is copied.",
+ Scene: CheckScene,
+ },
+ {
+ title: "Videos copy over in the background",
+ body: "A few at a time, each into its owner's library with its original title and date. Close the tab whenever you like, it keeps going.",
+ Scene: CopyScene,
+ },
+ {
+ title: "AI starts when someone watches",
+ body: "Transcripts, summaries and chapters are made the first time a video is opened, so nothing gets processed that nobody watches.",
+ Scene: WatchScene,
+ },
+] as const;
+
+const AUTO_ADVANCE_MS = 6500;
+
+export const HowImportWorks = ({
+ open,
+ onOpenChange,
+ initialStep = 0,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initialStep?: number;
+}) => {
+ const [step, setStep] = useState(initialStep);
+ const [auto, setAuto] = useState(true);
+ const last = IMPORT_STEPS.length - 1;
+
+ useEffect(() => {
+ if (!open) return;
+ setStep(initialStep);
+ setAuto(true);
+ }, [open, initialStep]);
+
+ const go = useCallback(
+ (next: number) => {
+ setAuto(false);
+ setStep(Math.max(0, Math.min(last, next)));
+ },
+ [last],
+ );
+
+ useEffect(() => {
+ if (!open || !auto || step === last) return;
+ const timer = window.setTimeout(() => setStep(step + 1), AUTO_ADVANCE_MS);
+ return () => window.clearTimeout(timer);
+ }, [open, auto, step, last]);
+
+ const current = IMPORT_STEPS[step] ?? IMPORT_STEPS[0];
+ const Scene = current.Scene;
+
+ return (
+
+ {
+ if (event.key === "ArrowRight") go(step + 1);
+ else if (event.key === "ArrowLeft") go(step - 1);
+ }}
+ >
+
+
+
+ {step + 1} of {IMPORT_STEPS.length}
+
+
+ {current.title}
+
+
+ {current.body}
+
+
+
+ {IMPORT_STEPS.map((item, index) => (
+ go(index)}
+ className="relative h-1.5 overflow-hidden rounded-full bg-gray-4 transition-[width] duration-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-9"
+ style={{ width: index === step ? 28 : 6 }}
+ >
+ {index < step && (
+
+ )}
+ {index === step && (
+
+ )}
+
+ ))}
+
+
+ {step > 0 && (
+ go(step - 1)}
+ >
+ Back
+
+ )}
+ {step < last ? (
+ go(step + 1)}
+ >
+ Next
+
+ ) : (
+ onOpenChange(false)}
+ >
+ Got it
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css b/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css
new file mode 100644
index 00000000000..290e45d1dcd
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css
@@ -0,0 +1,260 @@
+.li-scope {
+ --li-ink: var(--gray-12);
+ --li-ink-2: var(--gray-11);
+ --li-ink-3: var(--gray-9);
+ --li-paper: var(--gray-1);
+ --li-paper-2: var(--gray-2);
+ --li-line: var(--gray-4);
+ --li-line-strong: var(--gray-7);
+ --li-accent: var(--blue-9);
+ --li-accent-soft: color-mix(in srgb, var(--blue-9) 14%, transparent);
+ --li-green: #2f9e69;
+ --li-green-soft: color-mix(in srgb, #2f9e69 14%, transparent);
+ --li-red: var(--red-9);
+ --li-red-soft: color-mix(in srgb, var(--red-9) 12%, transparent);
+ --li-amber: #d6870b;
+ --li-loom: #625df5;
+ --li-loom-soft: color-mix(in srgb, #625df5 14%, transparent);
+}
+
+.li-ink {
+ fill: none;
+ stroke: var(--li-ink);
+ stroke-width: 2.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.li-ink.is-thin {
+ stroke-width: 1.6;
+}
+.li-ink.is-soft {
+ stroke: var(--li-ink-3);
+}
+.li-ink.is-accent {
+ stroke: var(--li-accent);
+}
+.li-ink.is-green {
+ stroke: var(--li-green);
+}
+.li-ink.is-red {
+ stroke: var(--li-red);
+}
+.li-ink.is-loom {
+ stroke: var(--li-loom);
+}
+
+.li-draw {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: li-draw 0.9s cubic-bezier(0.65, 0, 0.35, 1) var(--d, 0s) forwards;
+}
+.li-march {
+ stroke-dasharray: 2 5.2;
+ animation: li-march 1.1s linear infinite;
+}
+.li-march.is-paused {
+ animation-play-state: paused;
+}
+.li-spark {
+ fill: none;
+ stroke: var(--li-accent);
+ stroke-width: 2.2;
+ stroke-linecap: round;
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: li-spark 0.5s ease var(--d, 0.6s) forwards;
+}
+.li-bob {
+ animation: li-bob 1.9s ease-in-out infinite;
+}
+.li-rise {
+ animation: li-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) var(--d, 0s) both;
+}
+.li-fade {
+ animation: li-fade 0.35s ease var(--d, 0s) both;
+}
+.li-pulse {
+ animation: li-pulse 1.6s ease-in-out infinite;
+}
+.li-pop {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: li-pop 0.45s cubic-bezier(0.2, 0.8, 0.2, 1.4) var(--d, 0s) both;
+}
+
+.li-squiggle-track {
+ fill: none;
+ stroke: var(--li-line-strong);
+ stroke-width: 2.4;
+ stroke-linecap: round;
+ stroke-dasharray: 0.45 1.15;
+}
+.li-squiggle-fill {
+ fill: none;
+ stroke: var(--li-accent);
+ stroke-width: 3.2;
+ stroke-linecap: round;
+ stroke-dasharray: 100;
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+.li-squiggle-fill.is-done {
+ stroke: var(--li-green);
+}
+.li-squiggle-fill.is-waiting {
+ stroke: var(--li-ink-3);
+ stroke-dasharray: 2 4.2;
+ stroke-dashoffset: 0;
+ animation: li-march 1.2s linear infinite;
+}
+
+.li-bar {
+ transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.li-dropzone {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23999' stroke-width='2' stroke-dasharray='7 9' stroke-linecap='round'/%3E%3C/svg%3E");
+}
+.li-dropzone.is-over {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%230090ff' stroke-width='2.5' stroke-dasharray='7 9' stroke-linecap='round'/%3E%3C/svg%3E");
+}
+
+.li-how-fill {
+ animation: li-how-fill var(--li-how-ms, 6500ms) linear both;
+}
+
+.li-row {
+ contain: layout paint style;
+}
+
+.li-scene-slide {
+ animation: li-scene-slide 1.8s cubic-bezier(0.65, 0, 0.35, 1) 0.3s both;
+}
+.li-scene-cursor {
+ animation: li-scene-cursor 1.5s cubic-bezier(0.45, 0, 0.2, 1) both;
+}
+
+@keyframes li-scene-slide {
+ from {
+ transform: translate(0, 0) rotate(-4deg);
+ }
+ to {
+ transform: translate(252px, 4px) rotate(0deg);
+ }
+}
+@keyframes li-scene-cursor {
+ 0% {
+ transform: translate(300px, 200px);
+ }
+ 70% {
+ transform: translate(152px, 100px);
+ }
+ 82% {
+ transform: translate(152px, 100px) scale(0.85);
+ }
+ 100% {
+ transform: translate(152px, 100px) scale(1);
+ }
+}
+
+@keyframes li-draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+@keyframes li-march {
+ to {
+ stroke-dashoffset: -7.2;
+ }
+}
+@keyframes li-spark {
+ from {
+ opacity: 0;
+ transform: scale(0.2);
+ }
+ 60% {
+ opacity: 1;
+ transform: scale(1.2);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+@keyframes li-bob {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-4px);
+ }
+}
+@keyframes li-rise {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+@keyframes li-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@keyframes li-pulse {
+ 50% {
+ opacity: 0.35;
+ }
+}
+@keyframes li-pop {
+ from {
+ opacity: 0;
+ transform: scale(0.4);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+@keyframes li-how-fill {
+ from {
+ width: 0;
+ }
+ to {
+ width: 100%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .li-draw {
+ animation: none;
+ stroke-dashoffset: 0;
+ }
+ .li-march,
+ .li-bob,
+ .li-pulse,
+ .li-squiggle-fill.is-waiting {
+ animation: none;
+ }
+ .li-scene-slide {
+ animation: none;
+ transform: translate(252px, 4px);
+ }
+ .li-scene-cursor {
+ animation: none;
+ transform: translate(152px, 100px);
+ }
+ .li-spark,
+ .li-pop,
+ .li-rise,
+ .li-fade {
+ animation-duration: 0.01s;
+ }
+}
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx
new file mode 100644
index 00000000000..0d73bb33353
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import clsx from "clsx";
+import { formatDistanceToNowStrict } from "date-fns";
+import Link from "next/link";
+import { useId } from "react";
+import type { LoomImportJobSummary } from "@/lib/loom-import/jobs";
+
+const numberFormat = new Intl.NumberFormat("en-US");
+
+function describe(job: LoomImportJobSummary) {
+ switch (job.status) {
+ case "checking":
+ return "Checking links";
+ case "awaiting_upgrade":
+ return "Ready, waiting for Cap Pro";
+ case "importing":
+ return `${numberFormat.format(job.imported)} of ${numberFormat.format(job.totalCount)} imported`;
+ case "completed":
+ return job.failed > 0
+ ? `${numberFormat.format(job.imported)} imported, ${numberFormat.format(job.failed)} failed`
+ : `${numberFormat.format(job.imported)} imported`;
+ case "cancelled":
+ return `Stopped after ${numberFormat.format(job.imported)}`;
+ default:
+ return "";
+ }
+}
+
+export const RecentImports = ({ jobs }: { jobs: LoomImportJobSummary[] }) => {
+ const headingId = useId();
+ if (jobs.length === 0) return null;
+ return (
+
+
+ Your recent imports
+
+
+ {jobs.map((job) => {
+ const progress =
+ job.totalCount > 0 ? Math.min(1, job.settled / job.totalCount) : 0;
+ const active =
+ job.status === "importing" || job.status === "checking";
+ return (
+
+
+
+
+ {job.fileName}
+
+
+ {numberFormat.format(job.totalCount)}{" "}
+ {job.totalCount === 1 ? "video" : "videos"} ·{" "}
+ {formatDistanceToNowStrict(new Date(job.createdAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+ {describe(job)}
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/import/loom/page.tsx b/apps/web/app/(org)/dashboard/import/loom/page.tsx
index 86841acb03e..3a8590a93d8 100644
--- a/apps/web/app/(org)/dashboard/import/loom/page.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/page.tsx
@@ -1,4 +1,6 @@
+import { getCurrentUser } from "@cap/database/auth/session";
import type { Metadata } from "next";
+import { listLoomImportJobs } from "@/lib/loom-import/jobs";
import {
loomImportDestinationFromSearchParams,
loomImportPageHref,
@@ -14,11 +16,19 @@ export default async function Page({
}: {
searchParams: Promise>;
}) {
- const destination = loomImportDestinationFromSearchParams(await searchParams);
+ const [params, user] = await Promise.all([searchParams, getCurrentUser()]);
+ const destination = loomImportDestinationFromSearchParams(params);
+ const recentJobs = user?.activeOrganizationId
+ ? await listLoomImportJobs({
+ userId: user.id,
+ orgId: user.activeOrganizationId,
+ })
+ : [];
return (
);
}
diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts
index 397662e77a2..a807c6845f3 100644
--- a/apps/web/app/api/settings/billing/subscribe/route.ts
+++ b/apps/web/app/api/settings/billing/subscribe/route.ts
@@ -8,10 +8,17 @@ import type { NextRequest } from "next/server";
import type Stripe from "stripe";
import { trackServerEvent } from "@/lib/server-analytics";
+function dashboardReturnPath(value: unknown) {
+ if (typeof value !== "string" || value.length > 200) return null;
+ if (!/^\/dashboard\/[A-Za-z0-9/_-]*$/.test(value)) return null;
+ return value.includes("//") ? null : value;
+}
+
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
let customerId = user?.stripeCustomerId;
- const { priceId, quantity, isOnBoarding } = await request.json();
+ const { priceId, quantity, isOnBoarding, returnTo } = await request.json();
+ const returnPath = isOnBoarding ? null : dashboardReturnPath(returnTo);
if (!priceId) {
console.error("Price ID not found");
@@ -69,10 +76,12 @@ export async function POST(request: NextRequest) {
mode: "subscription",
success_url: isOnBoarding
? `${serverEnv().WEB_URL}/dashboard/settings/organization?upgrade=true&session_id={CHECKOUT_SESSION_ID}`
- : `${serverEnv().WEB_URL}/dashboard/caps?upgrade=true&session_id={CHECKOUT_SESSION_ID}`,
+ : `${serverEnv().WEB_URL}${returnPath ?? "/dashboard/caps"}?upgrade=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: isOnBoarding
? `${serverEnv().WEB_URL}/onboarding`
- : `${serverEnv().WEB_URL}/pricing`,
+ : returnPath
+ ? `${serverEnv().WEB_URL}${returnPath}`
+ : `${serverEnv().WEB_URL}/pricing`,
allow_promotion_codes: true,
metadata: {
platform: "web",
From 8c3c0315954a1847b7210682d86bccf89a21b3d5 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 05:14:05 +0000
Subject: [PATCH 05/39] improve: keep the Loom import page fast at 2,000 videos
Polls now return only rows changed since the previous poll, the first
render ships one screen of rows and loads the rest right after, and the
list holds off loading new thumbnails until scrolling settles.
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 9 ++++-
.../import/loom/[jobId]/LoomImportJobView.tsx | 6 +++-
.../import/loom/[jobId]/import-list.tsx | 36 +++++++++++++++----
.../dashboard/import/loom/[jobId]/page.tsx | 9 ++++-
.../loom/[jobId]/use-loom-import-job.ts | 9 +++++
.../import/loom/_components/bulk-import.tsx | 4 +--
apps/web/lib/loom-import/snapshot.ts | 7 ++--
7 files changed, 65 insertions(+), 15 deletions(-)
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index f950919c4d0..f097d5f6202 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -439,11 +439,18 @@ describe.runIf(Boolean(databaseUrl))(
.set({ updatedAt: earlier })
.where(eq(videoUploads.videoId, item.videoId));
}
+ const pollStartedAt = Date.now();
const settledView = await getLoomImportSnapshot({
jobId,
userId: ownerId,
});
- expect(settledView?.cursor).toBeLessThan(Date.now() - 50_000);
+ expect(settledView?.cursor).toBeGreaterThanOrEqual(pollStartedAt);
+ const quiet = await getLoomImportSnapshot({
+ jobId,
+ userId: ownerId,
+ since: (settledView?.cursor ?? 0) + 6_000,
+ });
+ expect(quiet?.items).toEqual([]);
const lastPoll = Date.now() - 1_000;
await database()
.update(videoUploads)
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
index 235e4acd66d..3b100c9d4e2 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
@@ -520,6 +520,10 @@ export const LoomImportJobView = ({
>
{FILTERS.map((option) => {
const count = filterCount(counts, option.value);
+ const label =
+ option.value === "active" && job.status === "awaiting_upgrade"
+ ? "Ready"
+ : option.label;
return (
- {option.label}
+ {label}
();
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "numeric",
@@ -43,12 +45,20 @@ function shortLoomUrl(url: string) {
export function useVirtualWindow(count: number, viewport: number) {
const ref = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
+ const [scrolling, setScrolling] = useState(false);
const frame = useRef(null);
+ const settle = useRef(undefined);
const onScroll = useCallback(() => {
+ window.clearTimeout(settle.current);
+ settle.current = window.setTimeout(
+ () => setScrolling(false),
+ SCROLL_SETTLE_MS,
+ );
if (frame.current !== null) return;
frame.current = window.requestAnimationFrame(() => {
frame.current = null;
+ setScrolling(true);
setScrollTop(ref.current?.scrollTop ?? 0);
});
}, []);
@@ -56,6 +66,7 @@ export function useVirtualWindow(count: number, viewport: number) {
useEffect(
() => () => {
if (frame.current !== null) window.cancelAnimationFrame(frame.current);
+ window.clearTimeout(settle.current);
},
[],
);
@@ -75,7 +86,7 @@ export function useVirtualWindow(count: number, viewport: number) {
count,
Math.ceil((scrollTop + viewport) / ROW_HEIGHT) + OVERSCAN,
);
- return { ref, onScroll, start, end };
+ return { ref, onScroll, start, end, scrolling };
}
const STATUS_TONE: Record = {
@@ -185,9 +196,15 @@ const StatusCell = ({ item }: { item: LoomImportItemView }) => {
);
};
-const Thumb = ({ src }: { src: string | null }) => (
+const Thumb = ({
+ src,
+ deferred,
+}: {
+ src: string | null;
+ deferred: boolean;
+}) => (
- {src && (
+ {src && (!deferred || loadedThumbs.has(src)) && (
(
draggable={false}
className="size-full object-cover"
referrerPolicy="no-referrer"
+ onLoad={() => loadedThumbs.add(src)}
/>
)}
@@ -204,9 +222,11 @@ const Thumb = ({ src }: { src: string | null }) => (
export const ImportRow = memo(function ImportRow({
item,
showOwner,
+ scrolling,
}: {
item: LoomImportItemView;
showOwner: boolean;
+ scrolling: boolean;
}) {
const meta = [
item.recordedAt
@@ -218,7 +238,7 @@ export const ImportRow = memo(function ImportRow({
].filter(Boolean);
return (
-
+
{
- const { ref, onScroll, start, end } = useVirtualWindow(
+ const { ref, onScroll, start, end, scrolling } = useVirtualWindow(
items.length,
viewport,
);
@@ -288,7 +308,11 @@ export const VirtualImportList = ({
transform: `translateY(${(start + index) * ROW_HEIGHT}px)`,
}}
>
-
+
))}
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx
index 1a4627ca545..80cfe736e68 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
title: "Loom import — Cap",
};
+const FIRST_SCREEN_ROWS = 60;
+
export default async function Page({
params,
searchParams,
@@ -25,10 +27,15 @@ export default async function Page({
const snapshot = await getLoomImportSnapshot({ jobId, userId: user.id });
if (!snapshot) notFound();
+ const firstScreen = snapshot.items.slice(0, FIRST_SCREEN_ROWS);
return (
);
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index 20b1b5c3890..4cf87c6a1e9 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -132,6 +132,15 @@ export function useLoomImportJob(
[apply, initial.job.id],
);
+ const loadedRest = useRef(initial.full);
+ useEffect(() => {
+ if (loadedRest.current) return;
+ loadedRest.current = true;
+ fetchSnapshot(true).catch(() => {
+ loadedRest.current = false;
+ });
+ }, [fetchSnapshot]);
+
const status = summary.job.status;
const shouldPoll =
isLoomImportJobActive(status) ||
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
index 95cae03dbf3..0b2ff46fb1f 100644
--- a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
@@ -51,9 +51,9 @@ const GUIDE = [
body: "Admins can export every video from Settings, Workspace, Data, Export. Or just copy your Loom links.",
},
{
- doodle: "drop" as const,
+ doodle: "check" as const,
title: "Drop it here",
- body: "We find the Loom links, owners and spaces on our own. You check them before anything starts.",
+ body: "We find the Loom links, owners and spaces on our own, and check every video before anything starts.",
},
{
doodle: "move" as const,
diff --git a/apps/web/lib/loom-import/snapshot.ts b/apps/web/lib/loom-import/snapshot.ts
index c12b44cad24..ad789620a3b 100644
--- a/apps/web/lib/loom-import/snapshot.ts
+++ b/apps/web/lib/loom-import/snapshot.ts
@@ -16,7 +16,7 @@ import {
type LoomImportSnapshot,
} from "./status";
-const CURSOR_OVERLAP_MS = 5_000;
+const CURSOR_OVERLAP_MS = 3_000;
function time(value: Date | null | undefined) {
return value ? value.getTime() : 0;
@@ -31,6 +31,7 @@ export async function getLoomImportSnapshot({
userId: User.UserId;
since?: number;
}): Promise {
+ const queriedAt = Date.now();
const found = await getLoomImportJobForUser(jobId, userId);
if (!found) return null;
const { job, isAdmin } = found;
@@ -113,12 +114,10 @@ export async function getLoomImportSnapshot({
};
});
- let cursor = 0;
let totalDuration = 0;
let importedDuration = 0;
const owners = new Set();
for (const item of all) {
- cursor = Math.max(cursor, item.v);
if (item.email) owners.add(item.email);
if (
item.duration &&
@@ -155,7 +154,7 @@ export async function getLoomImportSnapshot({
items: full
? all
: all.filter((item) => item.v > (since as number) - CURSOR_OVERLAP_MS),
- cursor,
+ cursor: queriedAt,
full,
};
}
From 72f07db771864daf693e41c39f99e9fa8488018b Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 05:14:40 +0000
Subject: [PATCH 06/39] test: tie the Loom import delta test to the poll
overlap window
Co-authored-by: Richie McIlroy
---
.../web/__tests__/integration/loom-import-jobs.test.ts | 10 +++++++---
apps/web/lib/loom-import/snapshot.ts | 6 ++++--
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index f097d5f6202..ed3f72d544f 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -86,7 +86,10 @@ import {
resetFailedLoomImportItems,
resolveLoomImportJob,
} from "@/lib/loom-import/jobs";
-import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot";
+import {
+ getLoomImportSnapshot,
+ LOOM_IMPORT_CURSOR_OVERLAP_MS,
+} from "@/lib/loom-import/snapshot";
const databaseUrl = process.env.CAP_LOOM_IMPORT_TEST_DATABASE_URL;
@@ -448,7 +451,8 @@ describe.runIf(Boolean(databaseUrl))(
const quiet = await getLoomImportSnapshot({
jobId,
userId: ownerId,
- since: (settledView?.cursor ?? 0) + 6_000,
+ since:
+ (settledView?.cursor ?? 0) + LOOM_IMPORT_CURSOR_OVERLAP_MS + 1_000,
});
expect(quiet?.items).toEqual([]);
const lastPoll = Date.now() - 1_000;
@@ -459,7 +463,7 @@ describe.runIf(Boolean(databaseUrl))(
const delta = await getLoomImportSnapshot({
jobId,
userId: ownerId,
- since: lastPoll + 5_000,
+ since: lastPoll + LOOM_IMPORT_CURSOR_OVERLAP_MS,
});
expect(delta?.full).toBe(false);
expect(
diff --git a/apps/web/lib/loom-import/snapshot.ts b/apps/web/lib/loom-import/snapshot.ts
index ad789620a3b..b9c8a41cc73 100644
--- a/apps/web/lib/loom-import/snapshot.ts
+++ b/apps/web/lib/loom-import/snapshot.ts
@@ -16,7 +16,7 @@ import {
type LoomImportSnapshot,
} from "./status";
-const CURSOR_OVERLAP_MS = 3_000;
+export const LOOM_IMPORT_CURSOR_OVERLAP_MS = 3_000;
function time(value: Date | null | undefined) {
return value ? value.getTime() : 0;
@@ -153,7 +153,9 @@ export async function getLoomImportSnapshot({
owners: Math.max(owners.size, 1),
items: full
? all
- : all.filter((item) => item.v > (since as number) - CURSOR_OVERLAP_MS),
+ : all.filter(
+ (item) => item.v > (since as number) - LOOM_IMPORT_CURSOR_OVERLAP_MS,
+ ),
cursor: queriedAt,
full,
};
From 99c9b80691581c5d323097f99981c6f08cfe336e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 05:55:52 +0000
Subject: [PATCH 07/39] improve: count only importable videos in the Loom
import heading
Co-authored-by: Richie McIlroy
---
.../(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx | 4 +++-
.../dashboard/import/loom/[jobId]/use-loom-import-job.ts | 4 ++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
index 3b100c9d4e2..976004a4ac2 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
@@ -242,6 +242,8 @@ export const LoomImportJobView = ({
const settled =
counts.imported + counts.failed + counts.skipped + counts.cancelled;
+ const importable =
+ counts.total - counts.failed - counts.skipped - counts.cancelled;
const importingProgress = useMemo(
() =>
items.reduce(
@@ -419,7 +421,7 @@ export const LoomImportJobView = ({
- of {numberFormat.format(counts.total)} videos are in Cap
+ of {numberFormat.format(importable)} videos are in Cap
}
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index 4cf87c6a1e9..c0a24a7e0fe 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -10,8 +10,8 @@ import {
const ACTIVE_POLL_MS = 1500;
const HIDDEN_POLL_MS = 15_000;
const UPGRADE_POLL_MS = 2500;
-const RATE_WINDOW_MS = 90_000;
-const RATE_MIN_SPAN_MS = 10_000;
+const RATE_WINDOW_MS = 180_000;
+const RATE_MIN_SPAN_MS = 20_000;
export type LoomImportSummary = Omit<
LoomImportSnapshot,
From a9aee2af7ed4d1f29cea0b875ca1d06bd276a1c1 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 05:55:52 +0000
Subject: [PATCH 08/39] docs: describe the 2,000 video Loom import and AI on
first view
Co-authored-by: Richie McIlroy
---
apps/web/__tests__/unit/agent-docs.test.ts | 7 +++--
.../pages/seo/MigrateFromLoomPage.tsx | 11 ++++----
.../pages/seo/migrate-from-loom-content.ts | 8 +++---
.../web/2026-10-07-loom-import-jobs.mdx | 16 +++++++++++
apps/web/content/docs/migrating-to-cap.mdx | 28 ++++++++++---------
apps/web/public/llms-full.txt | 2 +-
apps/web/public/llms.txt | 2 +-
7 files changed, 48 insertions(+), 26 deletions(-)
create mode 100644 apps/web/content/changelog/web/2026-10-07-loom-import-jobs.mdx
diff --git a/apps/web/__tests__/unit/agent-docs.test.ts b/apps/web/__tests__/unit/agent-docs.test.ts
index 38d5f24f4a1..c9e88344dfa 100644
--- a/apps/web/__tests__/unit/agent-docs.test.ts
+++ b/apps/web/__tests__/unit/agent-docs.test.ts
@@ -102,7 +102,10 @@ describe("Cap for Agents docs", () => {
expect(drive).toContain("organization manages storage");
const migration = getDocBySlug("migrating-to-cap")?.content ?? "";
- expect(migration).toContain("no more than 500 data rows");
+ expect(migration).toContain("no more than 2,000 videos");
+ expect(migration).toContain(
+ "generated the first time each video is opened",
+ );
expect(migration).toContain("one Cap space label");
expect(migration).toContain("cap jobs wait --json");
expect(migration).toContain("hundreds of users");
@@ -294,7 +297,7 @@ describe("Cap for Agents docs", () => {
expect(llms).toContain("explicit confirmation before every mutation");
expect(llms).toContain("https://cap.so/docs/teams/google-drive");
expect(llms).toContain("https://cap.so/docs/migrating-to-cap");
- expect(llms).toContain("up to 500 rows per self-serve batch");
+ expect(llms).toContain("up to 2,000 rows per self-serve batch");
});
it("keeps the reviewed documentation free of em dashes", () => {
diff --git a/apps/web/components/pages/seo/MigrateFromLoomPage.tsx b/apps/web/components/pages/seo/MigrateFromLoomPage.tsx
index eea402cc37e..41e68dfcd41 100644
--- a/apps/web/components/pages/seo/MigrateFromLoomPage.tsx
+++ b/apps/web/components/pages/seo/MigrateFromLoomPage.tsx
@@ -306,11 +306,12 @@ const Teams = () => (
- Upload a CSV of up to 500 Loom links mapped to teammate emails and
+ Upload a CSV of up to 2,000 Loom links mapped to teammate emails and
spaces, and Cap imports each video for the right owner. Bigger
- libraries run in controlled batches through the Cap CLI or MCP server,
- or we run the migration with you. We have moved organizations with
- hundreds of users and tens of thousands of recordings.
+ libraries split across a few CSVs, run in controlled batches through
+ the Cap CLI or MCP server, or we run the migration with you. We have
+ moved organizations with hundreds of users and tens of thousands of
+ recordings.
@@ -335,7 +336,7 @@ const Teams = () => (
- Up to 500 rows
+ Up to 2,000 rows
Import > Loom** |
-| Up to 500 mapped videos | Use the organization CSV importer |
-| More than 500 videos or a complex hierarchy | Run controlled batches with an agent or contact [hello@cap.so](mailto:hello@cap.so) for a managed migration |
+| Up to 2,000 videos | Use the CSV importer |
+| More than 2,000 videos | Split the list into several CSVs and import each one. Imports can run side by side |
+| A complex hierarchy | Run controlled batches with an agent or contact [hello@cap.so](mailto:hello@cap.so) for a managed migration |
-Loom import is a Cap Pro feature. Bulk CSV import is available to organization owners and admins.
+Anyone can upload a CSV and have Cap check the whole library for free. Copying the videos into Cap needs Cap Pro, and the import starts as soon as the upgrade is confirmed. Organization owners and admins can import for teammates and spaces; everyone else imports into their own library.
## Prepare the source inventory
-Export or prepare one row per unique Loom recording. The CSV importer expects:
+Loom workspace admins can export every video from **Settings > Workspace > Data > Export engagement insights**. You can also prepare one row per unique Loom recording yourself:
```csv
loom_video_url,user_email,space_name
https://www.loom.com/share/example,owner@example.com,Customer Success
```
-`loom_video_url` and `user_email` are required. `space_name` is optional.
+Only the Loom link column is required. The importer finds it by its contents, so Loom's own export, comma, semicolon or tab separated files, and a plain list of links all work. `user_email` and `space_name` are optional. Rows without an owner are imported into your own library.
The importer treats `space_name` as one Cap space label. It does not interpret slashes or other separators as a nested folder hierarchy. If your source library has nested folders, agree on an explicit mapping before importing.
@@ -35,13 +36,14 @@ Cap can provision a missing organization user during a mapped migration without
## Run a self-serve CSV migration
-1. Open **Dashboard > Import > Loom**.
-2. Select the CSV option.
-3. Upload a CSV with no more than 500 data rows.
-4. Map the Loom URL, owner email, and optional space-name columns.
-5. Review invalid rows and the preview.
-6. Confirm the import.
-7. Keep the result report. The UI runs imports in controlled batches and reports completed and failed rows.
+1. Open **Dashboard > Import > Loom** and choose **Bulk Import**.
+2. Drop a CSV with up to 2,000 videos, or paste Loom links.
+3. Check the columns Cap picked for Loom links, owners and spaces. Rows that are not Loom links and duplicate videos are listed before anything starts.
+4. Start the import. Cap checks every link with Loom first, so private, password-protected and deleted videos are flagged straight away with the reason.
+5. Follow each video on the import page. Videos copy a few at a time in the background, keep their Loom title and original recording date, and land in their owner's library. You can close the tab.
+6. Retry failed videos from the import page, then download the Loom to Cap link list to update links in your docs and wikis.
+
+Transcripts, AI titles, summaries and chapters for imported videos are generated the first time each video is opened.
Private, expired, password-protected, deleted, or otherwise unavailable Loom URLs cannot be imported until the source is accessible. Keep those rows in a separate exception list instead of silently dropping them.
@@ -89,7 +91,7 @@ This has allowed Cap to move organizations with hundreds of users and libraries
- Every owner email has an intentional mapping
- Every `space_name` is one Cap space label
- Nested folders have a separate explicit plan
-- Batches contain no more than 500 rows in the self-serve UI
+- Each self-serve CSV holds no more than 2,000 videos
- Every accepted import reaches a terminal result
- Completed totals are reconciled with failures and unavailable sources
- A playback and permissions sample passes before the source system is retired
diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt
index c7413553ea0..f41586166ee 100644
--- a/apps/web/public/llms-full.txt
+++ b/apps/web/public/llms-full.txt
@@ -82,7 +82,7 @@ Core principles:
- Viewer analytics and engagement (24h / 7d / 30d / lifetime).
- Password-protected shares and custom domains (cap.yourdomain.com).
- Organizations with member roles (Admin/Member), team workspaces, public/private spaces, and color-coded folders.
-- Built-in Loom video importer (single URL or CSV bulk import up to 500 videos).
+- Built-in Loom video importer (single URL or CSV bulk import up to 2,000 videos per CSV).
- Browser-based web recorder available in the dashboard; referral program at /dashboard/refer.
## Storage & Privacy
diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt
index 99ce7cd0edd..9ffee0a6733 100644
--- a/apps/web/public/llms.txt
+++ b/apps/web/public/llms.txt
@@ -25,7 +25,7 @@ Cap does not assign global recording shortcuts by default. Users configure suppo
- Signed-in timestamped comments, replies, and emoji reactions when effective viewer settings allow them.
- Organization roles, members, spaces, folders, branding, viewer settings, and aggregate analytics.
- Cap-managed storage, Google Drive for new uploads, and S3-compatible storage. Provider changes do not automatically move existing files.
-- Single Loom imports, organization CSV imports up to 500 rows per self-serve batch, and a durable per-video agent import workflow.
+- Single Loom imports, CSV imports up to 2,000 rows per self-serve batch, and a durable per-video agent import workflow.
- A developer REST API plus `@cap/sdk-recorder` and `@cap/sdk-embed`. A public customer webhook subscription API is not currently documented.
Captions, transcripts, titles, summaries, and chapters depend on the recording, processing state, plan, and effective organization settings. Do not promise that every recording has every AI field immediately.
From 04d0ebf054082c8673c9fb3b2222ae4acc43e71e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:07:55 +0000
Subject: [PATCH 09/39] improve: tidy Loom import actions, dark mode tints and
hydration
Job actions live in the status card instead of crowding the list toolbar,
accent tints use theme-safe colors so the active stage reads in dark mode,
and relative times no longer trip hydration warnings.
Co-authored-by: Richie McIlroy
---
.../import/loom/[jobId]/LoomImportJobView.tsx | 113 ++++++++----------
.../import/loom/[jobId]/import-list.tsx | 4 +-
.../import/loom/_components/bulk-import.tsx | 6 +-
.../import/loom/_components/loom-import.css | 5 +
.../loom/_components/recent-imports.tsx | 2 +-
apps/web/lib/loom-import/jobs.ts | 2 +-
6 files changed, 62 insertions(+), 70 deletions(-)
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
index 976004a4ac2..a1456a66f94 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
@@ -164,7 +164,7 @@ const PipelineStrip = ({
className={clsx(
"relative flex flex-col gap-0.5 rounded-xl border px-4 py-3 transition-colors",
stage.key === "importing" && value > 0
- ? "border-blue-6 bg-blue-2"
+ ? "li-accent-surface"
: "border-gray-3 bg-gray-1",
)}
>
@@ -345,6 +345,35 @@ export const LoomImportJobView = ({
counts.failed > 0 &&
(job.status === "importing" || job.status === "completed");
+ const retryButton = canRetry && (
+
+ Retry {numberFormat.format(counts.failed)} failed
+
+ );
+ const stopButton = canCancel && (
+ setCancelOpen(true)}
+ >
+ Stop import
+
+ );
+ const reportButton = (label: string) => (
+
+ {label}
+
+ );
+
return (
@@ -364,7 +393,7 @@ export const LoomImportJobView = ({
{job.fileName}
-
+
{numberFormat.format(job.totalCount)}{" "}
{job.totalCount === 1 ? "video" : "videos"} · added{" "}
{formatDistanceToNowStrict(new Date(job.createdAt), {
@@ -397,6 +426,9 @@ export const LoomImportJobView = ({
{numberFormat.format(counts.total)} checked
+ {stopButton && (
+
{stopButton}
+ )}
)}
@@ -450,6 +482,11 @@ export const LoomImportJobView = ({
)}
+
+ {settled > 0 && reportButton("Download results")}
+ {retryButton}
+ {stopButton}
+
)}
@@ -472,14 +509,8 @@ export const LoomImportJobView = ({
Open my Caps
-
- Download Loom to Cap links
-
+ {reportButton("Download Loom to Cap links")}
+ {retryButton}
)}
@@ -491,14 +522,7 @@ export const LoomImportJobView = ({
body={`${numberFormat.format(counts.imported)} ${counts.imported === 1 ? "video" : "videos"} made it into Cap before you stopped. The rest were left in Loom.`}
>
-
- Download Loom to Cap links
-
+ {reportButton("Download Loom to Cap links")}
)}
@@ -553,51 +577,14 @@ export const LoomImportJobView = ({
);
})}
-
- setQuery(event.target.value)}
- placeholder="Search titles, owners, links"
- aria-label="Search videos"
- className="h-8 w-full rounded-full border border-gray-4 bg-gray-2 px-3 text-sm text-gray-12 outline-none transition-colors placeholder:text-gray-9 focus:border-blue-8 sm:w-56"
- />
- {canRetry && (
-
- Retry {numberFormat.format(counts.failed)} failed
-
- )}
- {settled > 0 &&
- job.status !== "completed" &&
- job.status !== "cancelled" && (
-
- Download results
-
- )}
- {canCancel && (
- setCancelOpen(true)}
- >
- Stop import
-
- )}
-
+
setQuery(event.target.value)}
+ placeholder="Search titles, owners, links"
+ aria-label="Search videos"
+ className="h-8 w-full rounded-full border border-gray-4 bg-gray-2 px-3 text-sm text-gray-12 outline-none transition-colors placeholder:text-gray-9 focus:border-blue-9 lg:w-60"
+ />
= {
checking: "text-gray-10",
ready: "text-gray-11",
queued: "text-gray-10",
- importing: "text-blue-11",
+ importing: "text-[var(--li-accent)]",
imported: "text-[#218358] dark:text-[#3dd68c]",
failed: "text-red-11",
skipped: "text-gray-10",
@@ -248,7 +248,7 @@ export const ImportRow = memo(function ImportRow({
>
{item.title ?? shortLoomUrl(item.url)}
-
+
Row {item.row}
{meta.length > 0 && ` · ${meta.join(" · ")}`}
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
index 0b2ff46fb1f..f40f22533cf 100644
--- a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
@@ -168,7 +168,7 @@ const SourcePicker = ({ onSource }: { onSource: (source: Source) => void }) => {
onDrop={onDrop}
className={clsx(
"li-dropzone relative flex flex-col items-center gap-4 rounded-2xl px-6 py-10 text-center transition-colors duration-200 sm:flex-row sm:gap-8 sm:px-10 sm:text-left",
- isOver ? "is-over bg-blue-2" : "bg-gray-1",
+ isOver ? "is-over li-accent-surface" : "bg-gray-1",
)}
>
@@ -223,7 +223,7 @@ const SourcePicker = ({ onSource }: { onSource: (source: Source) => void }) => {
placeholder={
"https://www.loom.com/share/…\nhttps://www.loom.com/share/…"
}
- className="w-full resize-y rounded-lg border border-gray-4 bg-gray-2 px-3 py-2 font-mono text-[13px] text-gray-12 outline-none transition-colors placeholder:text-gray-8 focus:border-blue-8"
+ className="w-full resize-y rounded-lg border border-gray-4 bg-gray-2 px-3 py-2 font-mono text-[13px] text-gray-12 outline-none transition-colors placeholder:text-gray-8 focus:border-blue-9"
/>
@@ -551,7 +551,7 @@ const ReviewCard = ({
Split it into {Math.ceil(ready / LOOM_IMPORT_MAX_ROWS)} CSVs and
- import them one after another. They can run at the same time.
+ start an import for each. They can run at the same time.
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css b/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css
index 290e45d1dcd..0bd73de49bf 100644
--- a/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css
@@ -258,3 +258,8 @@
animation-duration: 0.01s;
}
}
+
+.li-accent-surface {
+ background-color: var(--li-accent-soft);
+ border-color: color-mix(in srgb, var(--li-accent) 40%, transparent);
+}
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx
index 0d73bb33353..27c727384da 100644
--- a/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx
@@ -51,7 +51,7 @@ export const RecentImports = ({ jobs }: { jobs: LoomImportJobSummary[] }) => {
{job.fileName}
-
+
{numberFormat.format(job.totalCount)}{" "}
{job.totalCount === 1 ? "video" : "videos"} ·{" "}
{formatDistanceToNowStrict(new Date(job.createdAt), {
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
index 9d2f0c5c0b2..1a778dc4151 100644
--- a/apps/web/lib/loom-import/jobs.ts
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -44,7 +44,7 @@ const IN_CHUNK = 500;
export class LoomImportError extends Error {}
-export const LOOM_IMPORT_LIMIT_MESSAGE = `Each CSV can hold up to ${LOOM_IMPORT_MAX_ROWS.toLocaleString("en-US")} videos. Split your list into more than one CSV and import them one after another.`;
+export const LOOM_IMPORT_LIMIT_MESSAGE = `Each CSV can hold up to ${LOOM_IMPORT_MAX_ROWS.toLocaleString("en-US")} videos. Split your list into a few CSVs and start an import for each.`;
type ItemInsert = typeof loomImportJobItems.$inferInsert;
From fb71e35cccccaca8f7c227dd7086dc6826773335 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:12:57 +0000
Subject: [PATCH 10/39] fix: keep the Loom import workflows inside the lean
workflow runtime
The Loom import job workflow is now covered by the workflow runtime
boundary test. It checks Pro through a shared workflow-safe helper instead
of @cap/utils, and dispatch drops the @cap/utils short-link call, which was
a no-op stub.
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 14 ++++++-----
.../unit/workflow-runtime-boundary.test.ts | 1 +
apps/web/lib/ai-generation-entitlement.ts | 23 +++----------------
apps/web/lib/loom-import/dispatch.ts | 22 +-----------------
apps/web/lib/loom-import/jobs.ts | 4 ++--
apps/web/lib/loom-import/snapshot.ts | 4 ++--
apps/web/lib/pro-subscription.ts | 22 ++++++++++++++++++
7 files changed, 39 insertions(+), 51 deletions(-)
create mode 100644 apps/web/lib/pro-subscription.ts
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index ed3f72d544f..0f5016bd1c8 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -122,7 +122,7 @@ function share(loomId: string) {
return `https://www.loom.com/share/${loomId}`;
}
-const loomFetch = vi.fn(
+const loomFetchMock = vi.fn(
async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as {
variables: Record;
@@ -148,7 +148,8 @@ const loomFetch = vi.fn(
}
return Response.json({ data });
},
-) as unknown as typeof fetch;
+);
+const loomFetch = loomFetchMock as unknown as typeof fetch;
async function makeOrganization({ pro }: { pro: boolean }) {
const ownerId = User.UserId.make(id());
@@ -236,7 +237,7 @@ describe.runIf(Boolean(databaseUrl))(
beforeEach(() => {
fixture.start.mockReset().mockResolvedValue({ runId: "run" });
fixture.concurrency = "2";
- loomFetch.mockClear();
+ loomFetchMock.mockClear();
});
it("checks every link, waits for Pro on the free plan, then imports with original dates in a bounded window", async () => {
@@ -268,7 +269,7 @@ describe.runIf(Boolean(databaseUrl))(
expect(totalCount).toBe(8);
await resolveLoomImportJob(jobId, { fetchImpl: loomFetch });
- expect(loomFetch).toHaveBeenCalledTimes(1);
+ expect(loomFetchMock).toHaveBeenCalledTimes(1);
const resolved = await items(jobId);
expect(resolved.map((item) => [item.rowNumber, item.status])).toEqual([
[2, "ready"],
@@ -333,11 +334,12 @@ describe.runIf(Boolean(databaseUrl))(
.from(spaces)
.where(eq(spaces.organizationId, orgId));
expect(otherSpaces).toEqual([]);
- expect(salesSpace?.name).toBe("Sales");
+ if (!salesSpace) throw new Error("Expected the Sales space to exist.");
+ expect(salesSpace.name).toBe("Sales");
const members = await database()
.select({ userId: spaceMembers.userId, role: spaceMembers.role })
.from(spaceMembers)
- .where(eq(spaceMembers.spaceId, salesSpace?.id ?? ""));
+ .where(eq(spaceMembers.spaceId, salesSpace.id));
expect(members).toEqual(
expect.arrayContaining([
{ userId: ownerId, role: "admin" },
diff --git a/apps/web/__tests__/unit/workflow-runtime-boundary.test.ts b/apps/web/__tests__/unit/workflow-runtime-boundary.test.ts
index cc9afdcdb00..dbcbfdddd2c 100644
--- a/apps/web/__tests__/unit/workflow-runtime-boundary.test.ts
+++ b/apps/web/__tests__/unit/workflow-runtime-boundary.test.ts
@@ -11,6 +11,7 @@ const workflowEntries = [
"workflows/admin-reprocess-video.ts",
"workflows/import-loom-video.ts",
"workflows/agent-cap-operation.ts",
+ "workflows/loom-import-job.ts",
];
const forbiddenModules = new Set([
diff --git a/apps/web/lib/ai-generation-entitlement.ts b/apps/web/lib/ai-generation-entitlement.ts
index 5d495199d00..34ff4a53e68 100644
--- a/apps/web/lib/ai-generation-entitlement.ts
+++ b/apps/web/lib/ai-generation-entitlement.ts
@@ -1,24 +1,7 @@
-import { buildEnv } from "@cap/env";
+import { hasProSubscription, type ProSubscriptionUser } from "./pro-subscription";
-export type AiGenerationEntitlementUser = {
- stripeSubscriptionStatus?: string | null;
- thirdPartyStripeSubscriptionId?: string | null;
-};
+export type AiGenerationEntitlementUser = ProSubscriptionUser;
export const isAiGenerationEnabledForUser = (
user?: AiGenerationEntitlementUser | null,
-) => {
- if (!buildEnv.NEXT_PUBLIC_IS_CAP) return true;
- if (!user) return false;
- if (user.thirdPartyStripeSubscriptionId) return true;
-
- // Mirrors userIsPro in @cap/utils: past_due keeps entitlement during
- // Stripe's dunning window.
- return (
- user.stripeSubscriptionStatus === "active" ||
- user.stripeSubscriptionStatus === "trialing" ||
- user.stripeSubscriptionStatus === "complete" ||
- user.stripeSubscriptionStatus === "paid" ||
- user.stripeSubscriptionStatus === "past_due"
- );
-};
+) => hasProSubscription(user);
diff --git a/apps/web/lib/loom-import/dispatch.ts b/apps/web/lib/loom-import/dispatch.ts
index a6132bdb403..9f7f75d3a7e 100644
--- a/apps/web/lib/loom-import/dispatch.ts
+++ b/apps/web/lib/loom-import/dispatch.ts
@@ -9,8 +9,7 @@ import {
videoUploads,
} from "@cap/database/schema";
import { getNewVideoPublic } from "@cap/database/video-sharing-default";
-import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
-import { dub } from "@cap/utils";
+import { serverEnv } from "@cap/env";
import { Storage } from "@cap/web-backend/src/Storage/index";
import { type Organisation, type User, Video } from "@cap/web-domain";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
@@ -39,7 +38,6 @@ type Launch = {
bucketId: string | null;
loomVideoId: string;
reuseExistingRawUpload: boolean;
- created: boolean;
};
export function loomImportConcurrency() {
@@ -263,7 +261,6 @@ export async function dispatchLoomImportJob(jobId: string) {
bucketId: existing.bucket,
loomVideoId: item.loomVideoId,
reuseExistingRawUpload: true,
- created: false,
});
continue;
}
@@ -375,7 +372,6 @@ export async function dispatchLoomImportJob(jobId: string) {
bucketId: writable.bucketId,
loomVideoId,
reuseExistingRawUpload: false,
- created: true,
});
}
}
@@ -450,22 +446,6 @@ export async function dispatchLoomImportJob(jobId: string) {
}
}
- if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") {
- await Promise.all(
- outcome.launches
- .filter((launch) => launch.created)
- .map((launch) =>
- dub()
- .links.create({
- url: `${serverEnv().WEB_URL}/s/${launch.videoId}`,
- domain: "cap.link",
- key: launch.videoId,
- })
- .catch(() => undefined),
- ),
- );
- }
-
return { started: outcome.launches.length, completed: outcome.completed };
}
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
index 1a778dc4151..7249179c566 100644
--- a/apps/web/lib/loom-import/jobs.ts
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -12,7 +12,6 @@ import {
videos,
videoUploads,
} from "@cap/database/schema";
-import { userIsPro } from "@cap/utils";
import {
type Organisation,
Space,
@@ -24,6 +23,7 @@ import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { getOrganizationAccess } from "@/actions/organization/authorization";
import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
import { canManageOrganizationSettings } from "@/lib/permissions/roles";
+import { hasProSubscription } from "@/lib/pro-subscription";
import {
extractLoomVideoId,
isValidImportEmail,
@@ -565,7 +565,7 @@ export async function prepareLoomImportJob(
.where(eq(users.id, job.createdById))
.limit(1);
- if (!creator || !userIsPro(creator)) {
+ if (!creator || !hasProSubscription(creator)) {
await db()
.update(loomImportJobs)
.set({ status: "awaiting_upgrade", updatedAt: new Date() })
diff --git a/apps/web/lib/loom-import/snapshot.ts b/apps/web/lib/loom-import/snapshot.ts
index b9c8a41cc73..c3975a5ad8e 100644
--- a/apps/web/lib/loom-import/snapshot.ts
+++ b/apps/web/lib/loom-import/snapshot.ts
@@ -5,9 +5,9 @@ import {
videos,
videoUploads,
} from "@cap/database/schema";
-import { userIsPro } from "@cap/utils";
import type { User } from "@cap/web-domain";
import { asc, eq, sql } from "drizzle-orm";
+import { hasProSubscription } from "@/lib/pro-subscription";
import { getLoomImportJobForUser } from "./jobs";
import {
countLoomImportItems,
@@ -129,7 +129,7 @@ export async function getLoomImportSnapshot({
}
}
- const isPro = userIsPro(viewer ?? null);
+ const isPro = hasProSubscription(viewer ?? null);
const createdByMe = job.createdById === userId;
const full = since === undefined || !Number.isFinite(since);
diff --git a/apps/web/lib/pro-subscription.ts b/apps/web/lib/pro-subscription.ts
new file mode 100644
index 00000000000..6cdd20b2647
--- /dev/null
+++ b/apps/web/lib/pro-subscription.ts
@@ -0,0 +1,22 @@
+import { buildEnv } from "@cap/env";
+
+export type ProSubscriptionUser = {
+ stripeSubscriptionStatus?: string | null;
+ thirdPartyStripeSubscriptionId?: string | null;
+};
+
+// Mirrors userIsPro in @cap/utils, which workflow code cannot import: past_due
+// keeps Pro during Stripe's dunning window.
+export const hasProSubscription = (user?: ProSubscriptionUser | null) => {
+ if (!buildEnv.NEXT_PUBLIC_IS_CAP) return true;
+ if (!user) return false;
+ if (user.thirdPartyStripeSubscriptionId) return true;
+
+ return (
+ user.stripeSubscriptionStatus === "active" ||
+ user.stripeSubscriptionStatus === "trialing" ||
+ user.stripeSubscriptionStatus === "complete" ||
+ user.stripeSubscriptionStatus === "paid" ||
+ user.stripeSubscriptionStatus === "past_due"
+ );
+};
From e0b4aaec45a18c0f49a4e9c3f8889cd8f40e92a4 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:14:39 +0000
Subject: [PATCH 11/39] chore: format AI entitlement helper
Co-authored-by: Richie McIlroy
---
apps/web/lib/ai-generation-entitlement.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/web/lib/ai-generation-entitlement.ts b/apps/web/lib/ai-generation-entitlement.ts
index 34ff4a53e68..a5f0a362996 100644
--- a/apps/web/lib/ai-generation-entitlement.ts
+++ b/apps/web/lib/ai-generation-entitlement.ts
@@ -1,4 +1,7 @@
-import { hasProSubscription, type ProSubscriptionUser } from "./pro-subscription";
+import {
+ hasProSubscription,
+ type ProSubscriptionUser,
+} from "./pro-subscription";
export type AiGenerationEntitlementUser = ProSubscriptionUser;
From 7d04f8c6eca64d49865124f38698726e990aa5d1 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:28:59 +0000
Subject: [PATCH 12/39] improve: hide Loom thumbnails that fail to load
Co-authored-by: Richie McIlroy
---
.../app/(org)/dashboard/import/loom/[jobId]/import-list.tsx | 3 +++
.../app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx | 3 +++
2 files changed, 6 insertions(+)
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
index 175e374b34e..91aef87f0d5 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
@@ -214,6 +214,9 @@ const Thumb = ({
className="size-full object-cover"
referrerPolicy="no-referrer"
onLoad={() => loadedThumbs.add(src)}
+ onError={(event) => {
+ event.currentTarget.style.visibility = "hidden";
+ }}
/>
)}
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx
index 728ea042dfa..fab51104b83 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx
@@ -200,6 +200,9 @@ export const UpgradePanel = ({
decoding="async"
referrerPolicy="no-referrer"
className="size-full object-cover"
+ onError={(event) => {
+ event.currentTarget.style.visibility = "hidden";
+ }}
/>
))}
From a35e5665a3ca3b88a5430df9e4a766f38a488a19 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:32:19 +0000
Subject: [PATCH 13/39] chore: format generated Loom import migration metadata
Co-authored-by: Richie McIlroy
---
.../migrations/meta/0050_snapshot.json | 10983 ++++++++--------
.../database/migrations/meta/_journal.json | 724 +-
2 files changed, 5612 insertions(+), 6095 deletions(-)
diff --git a/packages/database/migrations/meta/0050_snapshot.json b/packages/database/migrations/meta/0050_snapshot.json
index 76994f3a1b6..e6157b868e2 100644
--- a/packages/database/migrations/meta/0050_snapshot.json
+++ b/packages/database/migrations/meta/0050_snapshot.json
@@ -1,5734 +1,5251 @@
{
- "version": "5",
- "dialect": "mysql",
- "id": "b3eb8041-9563-45df-99f3-649946dfd1e6",
- "prevId": "7db9e8b2-ba9e-416d-a243-4b79b5d4ec5d",
- "tables": {
- "accounts": {
- "name": "accounts",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "type": {
- "name": "type",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "provider": {
- "name": "provider",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "providerAccountId": {
- "name": "providerAccountId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "access_token": {
- "name": "access_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "expires_in": {
- "name": "expires_in",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "id_token": {
- "name": "id_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "refresh_token": {
- "name": "refresh_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "refresh_token_expires_in": {
- "name": "refresh_token_expires_in",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "scope": {
- "name": "scope",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "token_type": {
- "name": "token_type",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "tempColumn": {
- "name": "tempColumn",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "user_id_idx": {
- "name": "user_id_idx",
- "columns": [
- "userId"
- ],
- "isUnique": false
- },
- "provider_account_id_idx": {
- "name": "provider_account_id_idx",
- "columns": [
- "providerAccountId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "accounts_id": {
- "name": "accounts_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "agent_api_authorization_codes": {
- "name": "agent_api_authorization_codes",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "codeHash": {
- "name": "codeHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "codeChallenge": {
- "name": "codeChallenge",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "redirectUri": {
- "name": "redirectUri",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "scopes": {
- "name": "scopes",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expiresAt": {
- "name": "expiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "consumedAt": {
- "name": "consumedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "code_hash_idx": {
- "name": "code_hash_idx",
- "columns": [
- "codeHash"
- ],
- "isUnique": true
- },
- "expires_at_idx": {
- "name": "expires_at_idx",
- "columns": [
- "expiresAt"
- ],
- "isUnique": false
- },
- "user_created_at_idx": {
- "name": "user_created_at_idx",
- "columns": [
- "userId",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "agent_api_authorization_codes_id": {
- "name": "agent_api_authorization_codes_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "agent_api_idempotency": {
- "name": "agent_api_idempotency",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "operation": {
- "name": "operation",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "keyHash": {
- "name": "keyHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "requestHash": {
- "name": "requestHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "state": {
- "name": "state",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'pending'"
- },
- "statusCode": {
- "name": "statusCode",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "response": {
- "name": "response",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "expiresAt": {
- "name": "expiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "user_operation_key_idx": {
- "name": "user_operation_key_idx",
- "columns": [
- "userId",
- "operation",
- "keyHash"
- ],
- "isUnique": true
- },
- "expires_at_idx": {
- "name": "expires_at_idx",
- "columns": [
- "expiresAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "agent_api_idempotency_id": {
- "name": "agent_api_idempotency_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "agent_api_keys": {
- "name": "agent_api_keys",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "tokenHash": {
- "name": "tokenHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(100)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'Cap CLI'"
- },
- "scopes": {
- "name": "scopes",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expiresAt": {
- "name": "expiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "revokedAt": {
- "name": "revokedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "lastUsedAt": {
- "name": "lastUsedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "token_hash_idx": {
- "name": "token_hash_idx",
- "columns": [
- "tokenHash"
- ],
- "isUnique": true
- },
- "user_created_at_idx": {
- "name": "user_created_at_idx",
- "columns": [
- "userId",
- "createdAt"
- ],
- "isUnique": false
- },
- "expires_at_idx": {
- "name": "expires_at_idx",
- "columns": [
- "expiresAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "agent_api_keys_id": {
- "name": "agent_api_keys_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "agent_api_operations": {
- "name": "agent_api_operations",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "kind": {
- "name": "kind",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "resourceId": {
- "name": "resourceId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "resultResourceId": {
- "name": "resultResourceId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "state": {
- "name": "state",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'queued'"
- },
- "payload": {
- "name": "payload",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "result": {
- "name": "result",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "errorCode": {
- "name": "errorCode",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "errorMessage": {
- "name": "errorMessage",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "completedAt": {
- "name": "completedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "user_created_at_idx": {
- "name": "user_created_at_idx",
- "columns": [
- "userId",
- "createdAt"
- ],
- "isUnique": false
- },
- "state_updated_at_idx": {
- "name": "state_updated_at_idx",
- "columns": [
- "state",
- "updatedAt"
- ],
- "isUnique": false
- },
- "resource_id_idx": {
- "name": "resource_id_idx",
- "columns": [
- "resourceId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "agent_api_operations_id": {
- "name": "agent_api_operations_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "auth_api_keys": {
- "name": "auth_api_keys",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(36)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "source": {
- "name": "source",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'unknown'"
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "user_id_created_at_idx": {
- "name": "user_id_created_at_idx",
- "columns": [
- "userId",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "auth_api_keys_id": {
- "name": "auth_api_keys_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "comments": {
- "name": "comments",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "type": {
- "name": "type",
- "type": "varchar(6)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "content": {
- "name": "content",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "timestamp": {
- "name": "timestamp",
- "type": "float",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "authorId": {
- "name": "authorId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "videoId": {
- "name": "videoId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "parentCommentId": {
- "name": "parentCommentId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "mediaKey": {
- "name": "mediaKey",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "mediaDuration": {
- "name": "mediaDuration",
- "type": "float",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "mediaMeta": {
- "name": "mediaMeta",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "video_type_created_idx": {
- "name": "video_type_created_idx",
- "columns": [
- "videoId",
- "type",
- "createdAt",
- "id"
- ],
- "isUnique": false
- },
- "author_id_idx": {
- "name": "author_id_idx",
- "columns": [
- "authorId"
- ],
- "isUnique": false
- },
- "parent_comment_id_idx": {
- "name": "parent_comment_id_idx",
- "columns": [
- "parentCommentId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "comments_id": {
- "name": "comments_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "developer_api_keys": {
- "name": "developer_api_keys",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "appId": {
- "name": "appId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "keyType": {
- "name": "keyType",
- "type": "varchar(8)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "keyPrefix": {
- "name": "keyPrefix",
- "type": "varchar(12)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "keyHash": {
- "name": "keyHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encryptedKey": {
- "name": "encryptedKey",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "lastUsedAt": {
- "name": "lastUsedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "revokedAt": {
- "name": "revokedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "key_hash_idx": {
- "name": "key_hash_idx",
- "columns": [
- "keyHash"
- ],
- "isUnique": true
- },
- "app_key_type_idx": {
- "name": "app_key_type_idx",
- "columns": [
- "appId",
- "keyType"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "developer_api_keys_appId_developer_apps_id_fk": {
- "name": "developer_api_keys_appId_developer_apps_id_fk",
- "tableFrom": "developer_api_keys",
- "tableTo": "developer_apps",
- "columnsFrom": [
- "appId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "developer_api_keys_id": {
- "name": "developer_api_keys_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "developer_app_domains": {
- "name": "developer_app_domains",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "appId": {
- "name": "appId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "domain": {
- "name": "domain",
- "type": "varchar(253)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {},
- "foreignKeys": {
- "developer_app_domains_appId_developer_apps_id_fk": {
- "name": "developer_app_domains_appId_developer_apps_id_fk",
- "tableFrom": "developer_app_domains",
- "tableTo": "developer_apps",
- "columnsFrom": [
- "appId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "developer_app_domains_id": {
- "name": "developer_app_domains_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {
- "app_domain_unique": {
- "name": "app_domain_unique",
- "columns": [
- "appId",
- "domain"
- ]
- }
- },
- "checkConstraint": {}
- },
- "developer_apps": {
- "name": "developer_apps",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "environment": {
- "name": "environment",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "logoUrl": {
- "name": "logoUrl",
- "type": "varchar(1024)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "deletedAt": {
- "name": "deletedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "owner_deleted_idx": {
- "name": "owner_deleted_idx",
- "columns": [
- "ownerId",
- "deletedAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "developer_apps_id": {
- "name": "developer_apps_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "developer_credit_accounts": {
- "name": "developer_credit_accounts",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "appId": {
- "name": "appId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "balanceMicroCredits": {
- "name": "balanceMicroCredits",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "stripeCustomerId": {
- "name": "stripeCustomerId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripePaymentMethodId": {
- "name": "stripePaymentMethodId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "autoTopUpEnabled": {
- "name": "autoTopUpEnabled",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "autoTopUpThresholdMicroCredits": {
- "name": "autoTopUpThresholdMicroCredits",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "autoTopUpAmountCents": {
- "name": "autoTopUpAmountCents",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "app_id_unique": {
- "name": "app_id_unique",
- "columns": [
- "appId"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "developer_credit_accounts_appId_developer_apps_id_fk": {
- "name": "developer_credit_accounts_appId_developer_apps_id_fk",
- "tableFrom": "developer_credit_accounts",
- "tableTo": "developer_apps",
- "columnsFrom": [
- "appId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "developer_credit_accounts_id": {
- "name": "developer_credit_accounts_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "developer_credit_transactions": {
- "name": "developer_credit_transactions",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "accountId": {
- "name": "accountId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "type": {
- "name": "type",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "amountMicroCredits": {
- "name": "amountMicroCredits",
- "type": "bigint",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "balanceAfterMicroCredits": {
- "name": "balanceAfterMicroCredits",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "referenceId": {
- "name": "referenceId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "referenceType": {
- "name": "referenceType",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "metadata": {
- "name": "metadata",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "account_type_created_idx": {
- "name": "account_type_created_idx",
- "columns": [
- "accountId",
- "type",
- "createdAt"
- ],
- "isUnique": false
- },
- "account_ref_dedup_idx": {
- "name": "account_ref_dedup_idx",
- "columns": [
- "accountId",
- "referenceId",
- "referenceType"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "dev_credit_txn_account_fk": {
- "name": "dev_credit_txn_account_fk",
- "tableFrom": "developer_credit_transactions",
- "tableTo": "developer_credit_accounts",
- "columnsFrom": [
- "accountId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "developer_credit_transactions_id": {
- "name": "developer_credit_transactions_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "developer_daily_storage_snapshots": {
- "name": "developer_daily_storage_snapshots",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "appId": {
- "name": "appId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "snapshotDate": {
- "name": "snapshotDate",
- "type": "varchar(10)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "totalDurationMinutes": {
- "name": "totalDurationMinutes",
- "type": "float",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "videoCount": {
- "name": "videoCount",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "microCreditsCharged": {
- "name": "microCreditsCharged",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "processedAt": {
- "name": "processedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {},
- "foreignKeys": {
- "developer_daily_storage_snapshots_appId_developer_apps_id_fk": {
- "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk",
- "tableFrom": "developer_daily_storage_snapshots",
- "tableTo": "developer_apps",
- "columnsFrom": [
- "appId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "developer_daily_storage_snapshots_id": {
- "name": "developer_daily_storage_snapshots_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {
- "app_date_unique": {
- "name": "app_date_unique",
- "columns": [
- "appId",
- "snapshotDate"
- ]
- }
- },
- "checkConstraint": {}
- },
- "developer_videos": {
- "name": "developer_videos",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "appId": {
- "name": "appId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "externalUserId": {
- "name": "externalUserId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'Untitled'"
- },
- "duration": {
- "name": "duration",
- "type": "float",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "width": {
- "name": "width",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "height": {
- "name": "height",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "fps": {
- "name": "fps",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "s3Key": {
- "name": "s3Key",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "transcriptionStatus": {
- "name": "transcriptionStatus",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "metadata": {
- "name": "metadata",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "deletedAt": {
- "name": "deletedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "app_created_idx": {
- "name": "app_created_idx",
- "columns": [
- "appId",
- "createdAt"
- ],
- "isUnique": false
- },
- "app_user_idx": {
- "name": "app_user_idx",
- "columns": [
- "appId",
- "externalUserId"
- ],
- "isUnique": false
- },
- "app_deleted_idx": {
- "name": "app_deleted_idx",
- "columns": [
- "appId",
- "deletedAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "developer_videos_appId_developer_apps_id_fk": {
- "name": "developer_videos_appId_developer_apps_id_fk",
- "tableFrom": "developer_videos",
- "tableTo": "developer_apps",
- "columnsFrom": [
- "appId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "developer_videos_id": {
- "name": "developer_videos_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "folders": {
- "name": "folders",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "color": {
- "name": "color",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'normal'"
- },
- "public": {
- "name": "public",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "settings": {
- "name": "settings",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdById": {
- "name": "createdById",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "parentId": {
- "name": "parentId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "spaceId": {
- "name": "spaceId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "organization_id_idx": {
- "name": "organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": false
- },
- "created_by_id_idx": {
- "name": "created_by_id_idx",
- "columns": [
- "createdById"
- ],
- "isUnique": false
- },
- "parent_id_idx": {
- "name": "parent_id_idx",
- "columns": [
- "parentId"
- ],
- "isUnique": false
- },
- "space_id_idx": {
- "name": "space_id_idx",
- "columns": [
- "spaceId"
- ],
- "isUnique": false
- },
- "public_parent_id_idx": {
- "name": "public_parent_id_idx",
- "columns": [
- "public",
- "parentId"
- ],
- "isUnique": false
- },
- "public_space_parent_id_idx": {
- "name": "public_space_parent_id_idx",
- "columns": [
- "public",
- "spaceId",
- "parentId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "folders_id": {
- "name": "folders_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "imported_videos": {
- "name": "imported_videos",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "orgId": {
- "name": "orgId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "source": {
- "name": "source",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "source_id": {
- "name": "source_id",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "id_idx": {
- "name": "id_idx",
- "columns": [
- "id"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "imported_videos_orgId_source_source_id_pk": {
- "name": "imported_videos_orgId_source_source_id_pk",
- "columns": [
- "orgId",
- "source",
- "source_id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "integration_installations": {
- "name": "integration_installations",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "provider": {
- "name": "provider",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "externalId": {
- "name": "externalId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "displayName": {
- "name": "displayName",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "installedByUserId": {
- "name": "installedByUserId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encryptedCredentials": {
- "name": "encryptedCredentials",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "metadata": {
- "name": "metadata",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "provider_external_id_idx": {
- "name": "provider_external_id_idx",
- "columns": [
- "provider",
- "externalId"
- ],
- "isUnique": true
- },
- "organization_provider_display_name_idx": {
- "name": "organization_provider_display_name_idx",
- "columns": [
- "organizationId",
- "provider",
- "displayName"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "integration_installations_id": {
- "name": "integration_installations_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "loom_import_job_items": {
- "name": "loom_import_job_items",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "job_id": {
- "name": "job_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "csv_row": {
- "name": "csv_row",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "loom_url": {
- "name": "loom_url",
- "type": "varchar(1024)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "loom_video_id": {
- "name": "loom_video_id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "owner_email": {
- "name": "owner_email",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "space_name": {
- "name": "space_name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'pending'"
- },
- "owner_id": {
- "name": "owner_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "space_id": {
- "name": "space_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "video_id": {
- "name": "video_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "title": {
- "name": "title",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "loom_created_at": {
- "name": "loom_created_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "duration_seconds": {
- "name": "duration_seconds",
- "type": "float",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "width": {
- "name": "width",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "height": {
- "name": "height",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "thumbnail_url": {
- "name": "thumbnail_url",
- "type": "varchar(1024)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "error": {
- "name": "error",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "job_row_idx": {
- "name": "job_row_idx",
- "columns": [
- "job_id",
- "csv_row"
- ],
- "isUnique": true
- },
- "job_status_row_idx": {
- "name": "job_status_row_idx",
- "columns": [
- "job_id",
- "status",
- "csv_row"
- ],
- "isUnique": false
- },
- "video_id_idx": {
- "name": "video_id_idx",
- "columns": [
- "video_id"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "loom_import_job_items_id": {
- "name": "loom_import_job_items_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "loom_import_jobs": {
- "name": "loom_import_jobs",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "org_id": {
- "name": "org_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_by_id": {
- "name": "created_by_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "file_name": {
- "name": "file_name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'checking'"
- },
- "total_count": {
- "name": "total_count",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "started_at": {
- "name": "started_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "completed_at": {
- "name": "completed_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "org_creator_created_idx": {
- "name": "org_creator_created_idx",
- "columns": [
- "org_id",
- "created_by_id",
- "created_at"
- ],
- "isUnique": false
- },
- "status_updated_idx": {
- "name": "status_updated_idx",
- "columns": [
- "status",
- "updated_at"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "loom_import_jobs_id": {
- "name": "loom_import_jobs_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "loops_sync_jobs": {
- "name": "loops_sync_jobs",
- "columns": {
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "revision": {
- "name": "revision",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 1
- },
- "nextAttemptAt": {
- "name": "nextAttemptAt",
- "type": "datetime",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "leaseToken": {
- "name": "leaseToken",
- "type": "varchar(36)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "leaseUntil": {
- "name": "leaseUntil",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "failures": {
- "name": "failures",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "lastError": {
- "name": "lastError",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "profileHash": {
- "name": "profileHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "teammateJoinedAt": {
- "name": "teammateJoinedAt",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "syncedEmail": {
- "name": "syncedEmail",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "lastSyncedAt": {
- "name": "lastSyncedAt",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "loops_sync_due_idx": {
- "name": "loops_sync_due_idx",
- "columns": [
- "nextAttemptAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "loops_sync_jobs_userId": {
- "name": "loops_sync_jobs_userId",
- "columns": [
- "userId"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "mcp_oauth_clients": {
- "name": "mcp_oauth_clients",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "clientId": {
- "name": "clientId",
- "type": "varchar(128)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "clientName": {
- "name": "clientName",
- "type": "varchar(100)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "redirectUris": {
- "name": "redirectUris",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "activatedAt": {
- "name": "activatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "client_id_idx": {
- "name": "client_id_idx",
- "columns": [
- "clientId"
- ],
- "isUnique": true
- },
- "inactive_created_at_idx": {
- "name": "inactive_created_at_idx",
- "columns": [
- "activatedAt",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "mcp_oauth_clients_id": {
- "name": "mcp_oauth_clients_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "mcp_oauth_codes": {
- "name": "mcp_oauth_codes",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "clientId": {
- "name": "clientId",
- "type": "varchar(128)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "codeHash": {
- "name": "codeHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "codeChallenge": {
- "name": "codeChallenge",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "redirectUri": {
- "name": "redirectUri",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "resource": {
- "name": "resource",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expiresAt": {
- "name": "expiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "consumedAt": {
- "name": "consumedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "code_hash_idx": {
- "name": "code_hash_idx",
- "columns": [
- "codeHash"
- ],
- "isUnique": true
- },
- "expires_at_idx": {
- "name": "expires_at_idx",
- "columns": [
- "expiresAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "mcp_oauth_codes_id": {
- "name": "mcp_oauth_codes_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "mcp_oauth_registration_quotas": {
- "name": "mcp_oauth_registration_quotas",
- "columns": {
- "windowId": {
- "name": "windowId",
- "type": "varchar(10)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "registrations": {
- "name": "registrations",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "expiresAt": {
- "name": "expiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "expires_at_idx": {
- "name": "expires_at_idx",
- "columns": [
- "expiresAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "mcp_oauth_registration_quotas_windowId": {
- "name": "mcp_oauth_registration_quotas_windowId",
- "columns": [
- "windowId"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "mcp_oauth_tokens": {
- "name": "mcp_oauth_tokens",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "clientId": {
- "name": "clientId",
- "type": "varchar(128)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "familyId": {
- "name": "familyId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "resource": {
- "name": "resource",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "accessHash": {
- "name": "accessHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "refreshHash": {
- "name": "refreshHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "accessExpiresAt": {
- "name": "accessExpiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "refreshExpiresAt": {
- "name": "refreshExpiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "revokedAt": {
- "name": "revokedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "access_hash_idx": {
- "name": "access_hash_idx",
- "columns": [
- "accessHash"
- ],
- "isUnique": true
- },
- "refresh_hash_idx": {
- "name": "refresh_hash_idx",
- "columns": [
- "refreshHash"
- ],
- "isUnique": true
- },
- "family_id_idx": {
- "name": "family_id_idx",
- "columns": [
- "familyId"
- ],
- "isUnique": false
- },
- "client_active_idx": {
- "name": "client_active_idx",
- "columns": [
- "clientId",
- "revokedAt",
- "refreshExpiresAt"
- ],
- "isUnique": false
- },
- "refresh_expires_at_idx": {
- "name": "refresh_expires_at_idx",
- "columns": [
- "refreshExpiresAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "mcp_oauth_tokens_id": {
- "name": "mcp_oauth_tokens_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "media_processing_budgets": {
- "name": "media_processing_budgets",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "reserved_bytes": {
- "name": "reserved_bytes",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "limit_bytes": {
- "name": "limit_bytes",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expires_at": {
- "name": "expires_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "media_budget_expiry_idx": {
- "name": "media_budget_expiry_idx",
- "columns": [
- "expires_at"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "media_processing_budgets_id": {
- "name": "media_processing_budgets_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "messenger_conversations": {
- "name": "messenger_conversations",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "agent": {
- "name": "agent",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "mode": {
- "name": "mode",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'agent'"
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "anonymousId": {
- "name": "anonymousId",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "takeoverByUserId": {
- "name": "takeoverByUserId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "takeoverAt": {
- "name": "takeoverAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "lastMessageAt": {
- "name": "lastMessageAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "user_last_message_idx": {
- "name": "user_last_message_idx",
- "columns": [
- "userId",
- "lastMessageAt"
- ],
- "isUnique": false
- },
- "anonymous_last_message_idx": {
- "name": "anonymous_last_message_idx",
- "columns": [
- "anonymousId",
- "lastMessageAt"
- ],
- "isUnique": false
- },
- "mode_last_message_idx": {
- "name": "mode_last_message_idx",
- "columns": [
- "mode",
- "lastMessageAt"
- ],
- "isUnique": false
- },
- "updated_at_idx": {
- "name": "updated_at_idx",
- "columns": [
- "updatedAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "messenger_conversations_id": {
- "name": "messenger_conversations_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "messenger_messages": {
- "name": "messenger_messages",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "conversationId": {
- "name": "conversationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "role": {
- "name": "role",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "content": {
- "name": "content",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "anonymousId": {
- "name": "anonymousId",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "conversation_created_at_idx": {
- "name": "conversation_created_at_idx",
- "columns": [
- "conversationId",
- "createdAt"
- ],
- "isUnique": false
- },
- "role_created_at_idx": {
- "name": "role_created_at_idx",
- "columns": [
- "role",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "messenger_messages_conversationId_messenger_conversations_id_fk": {
- "name": "messenger_messages_conversationId_messenger_conversations_id_fk",
- "tableFrom": "messenger_messages",
- "tableTo": "messenger_conversations",
- "columnsFrom": [
- "conversationId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "messenger_messages_id": {
- "name": "messenger_messages_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "messenger_support_emails": {
- "name": "messenger_support_emails",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "conversationId": {
- "name": "conversationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userEmail": {
- "name": "userEmail",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "subject": {
- "name": "subject",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "message": {
- "name": "message",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "support_email_user_created_at_idx": {
- "name": "support_email_user_created_at_idx",
- "columns": [
- "userId",
- "createdAt"
- ],
- "isUnique": false
- },
- "support_email_conversation_created_at_idx": {
- "name": "support_email_conversation_created_at_idx",
- "columns": [
- "conversationId",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "support_email_conversation_fk": {
- "name": "support_email_conversation_fk",
- "tableFrom": "messenger_support_emails",
- "tableTo": "messenger_conversations",
- "columnsFrom": [
- "conversationId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "messenger_support_emails_id": {
- "name": "messenger_support_emails_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "notifications": {
- "name": "notifications",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "orgId": {
- "name": "orgId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "recipientId": {
- "name": "recipientId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "type": {
- "name": "type",
- "type": "varchar(16)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "data": {
- "name": "data",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "videoId": {
- "name": "videoId",
- "type": "varchar(50)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "dedupKey": {
- "name": "dedupKey",
- "type": "varchar(128)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "readAt": {
- "name": "readAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "org_id_idx": {
- "name": "org_id_idx",
- "columns": [
- "orgId"
- ],
- "isUnique": false
- },
- "type_idx": {
- "name": "type_idx",
- "columns": [
- "type"
- ],
- "isUnique": false
- },
- "read_at_idx": {
- "name": "read_at_idx",
- "columns": [
- "readAt"
- ],
- "isUnique": false
- },
- "created_at_idx": {
- "name": "created_at_idx",
- "columns": [
- "createdAt"
- ],
- "isUnique": false
- },
- "recipient_read_idx": {
- "name": "recipient_read_idx",
- "columns": [
- "recipientId",
- "readAt"
- ],
- "isUnique": false
- },
- "recipient_created_idx": {
- "name": "recipient_created_idx",
- "columns": [
- "recipientId",
- "createdAt"
- ],
- "isUnique": false
- },
- "dedup_key_idx": {
- "name": "dedup_key_idx",
- "columns": [
- "dedupKey"
- ],
- "isUnique": true
- },
- "type_recipient_created_idx": {
- "name": "type_recipient_created_idx",
- "columns": [
- "type",
- "recipientId",
- "createdAt"
- ],
- "isUnique": false
- },
- "type_recipient_video_created_idx": {
- "name": "type_recipient_video_created_idx",
- "columns": [
- "type",
- "recipientId",
- "videoId",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "notifications_id": {
- "name": "notifications_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "organization_invites": {
- "name": "organization_invites",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "invitedEmail": {
- "name": "invitedEmail",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "invitedByUserId": {
- "name": "invitedByUserId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "role": {
- "name": "role",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'pending'"
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "expiresAt": {
- "name": "expiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "organization_id_idx": {
- "name": "organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": false
- },
- "invited_email_idx": {
- "name": "invited_email_idx",
- "columns": [
- "invitedEmail"
- ],
- "isUnique": false
- },
- "invited_by_user_id_idx": {
- "name": "invited_by_user_id_idx",
- "columns": [
- "invitedByUserId"
- ],
- "isUnique": false
- },
- "status_idx": {
- "name": "status_idx",
- "columns": [
- "status"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "organization_invites_id": {
- "name": "organization_invites_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "organization_members": {
- "name": "organization_members",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "role": {
- "name": "role",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "hasProSeat": {
- "name": "hasProSeat",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "organization_id_idx": {
- "name": "organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": false
- },
- "user_id_organization_id_idx": {
- "name": "user_id_organization_id_idx",
- "columns": [
- "userId",
- "organizationId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "organization_members_id": {
- "name": "organization_members_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "organization_sso": {
- "name": "organization_sso",
- "columns": {
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "purchasedByUserId": {
- "name": "purchasedByUserId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "stripeCustomerId": {
- "name": "stripeCustomerId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripeSubscriptionId": {
- "name": "stripeSubscriptionId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripePriceId": {
- "name": "stripePriceId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'unpaid'"
- },
- "paidThrough": {
- "name": "paidThrough",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "currentPeriodEnd": {
- "name": "currentPeriodEnd",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "cancelAtPeriodEnd": {
- "name": "cancelAtPeriodEnd",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "checkoutAttemptId": {
- "name": "checkoutAttemptId",
- "type": "varchar(36)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "checkoutCurrency": {
- "name": "checkoutCurrency",
- "type": "varchar(3)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "checkoutPriceId": {
- "name": "checkoutPriceId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "checkoutSessionId": {
- "name": "checkoutSessionId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "checkoutStartedAt": {
- "name": "checkoutStartedAt",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "sso_stripe_subscription_id_idx": {
- "name": "sso_stripe_subscription_id_idx",
- "columns": [
- "stripeSubscriptionId"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "organization_sso_organizationId": {
- "name": "organization_sso_organizationId",
- "columns": [
- "organizationId"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "organizations": {
- "name": "organizations",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "metadata": {
- "name": "metadata",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "tombstoneAt": {
- "name": "tombstoneAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "allowedEmailDomain": {
- "name": "allowedEmailDomain",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "defaultVideoVisibility": {
- "name": "defaultVideoVisibility",
- "type": "varchar(7)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "customDomain": {
- "name": "customDomain",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "domainVerified": {
- "name": "domainVerified",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "settings": {
- "name": "settings",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "iconUrl": {
- "name": "iconUrl",
- "type": "varchar(1024)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "shareableLinkIconUrl": {
- "name": "shareableLinkIconUrl",
- "type": "varchar(1024)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "workosOrganizationId": {
- "name": "workosOrganizationId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "workosConnectionId": {
- "name": "workosConnectionId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "owner_id_tombstone_idx": {
- "name": "owner_id_tombstone_idx",
- "columns": [
- "ownerId",
- "tombstoneAt"
- ],
- "isUnique": false
- },
- "custom_domain_idx": {
- "name": "custom_domain_idx",
- "columns": [
- "customDomain"
- ],
- "isUnique": false
- },
- "workos_organization_id_idx": {
- "name": "workos_organization_id_idx",
- "columns": [
- "workosOrganizationId"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "organizations_id": {
- "name": "organizations_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "s3_buckets": {
- "name": "s3_buckets",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "region": {
- "name": "region",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "endpoint": {
- "name": "endpoint",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "bucketName": {
- "name": "bucketName",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "accessKeyId": {
- "name": "accessKeyId",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "secretAccessKey": {
- "name": "secretAccessKey",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "provider": {
- "name": "provider",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "('aws')"
- },
- "active": {
- "name": "active",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": true
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "owner_organization_idx": {
- "name": "owner_organization_idx",
- "columns": [
- "ownerId",
- "organizationId"
- ],
- "isUnique": false
- },
- "organization_id_idx": {
- "name": "organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": false
- },
- "organization_active_idx": {
- "name": "organization_active_idx",
- "columns": [
- "organizationId",
- "active",
- "updatedAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "s3_buckets_id": {
- "name": "s3_buckets_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "sessions": {
- "name": "sessions",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "sessionToken": {
- "name": "sessionToken",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expires": {
- "name": "expires",
- "type": "datetime",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "session_token_idx": {
- "name": "session_token_idx",
- "columns": [
- "sessionToken"
- ],
- "isUnique": true
- },
- "user_id_idx": {
- "name": "user_id_idx",
- "columns": [
- "userId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "sessions_id": {
- "name": "sessions_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "shared_videos": {
- "name": "shared_videos",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "videoId": {
- "name": "videoId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "folderId": {
- "name": "folderId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "sharedByUserId": {
- "name": "sharedByUserId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "sharedAt": {
- "name": "sharedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "folder_id_idx": {
- "name": "folder_id_idx",
- "columns": [
- "folderId"
- ],
- "isUnique": false
- },
- "organization_id_idx": {
- "name": "organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": false
- },
- "shared_by_user_id_idx": {
- "name": "shared_by_user_id_idx",
- "columns": [
- "sharedByUserId"
- ],
- "isUnique": false
- },
- "video_id_organization_id_idx": {
- "name": "video_id_organization_id_idx",
- "columns": [
- "videoId",
- "organizationId"
- ],
- "isUnique": false
- },
- "video_id_folder_id_idx": {
- "name": "video_id_folder_id_idx",
- "columns": [
- "videoId",
- "folderId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "shared_videos_id": {
- "name": "shared_videos_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "signed_baas": {
- "name": "signed_baas",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'pending'"
- },
- "stripeSubscriptionId": {
- "name": "stripeSubscriptionId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "entityName": {
- "name": "entityName",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "entityType": {
- "name": "entityType",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "entityAddress": {
- "name": "entityAddress",
- "type": "varchar(500)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "signerName": {
- "name": "signerName",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "signerTitle": {
- "name": "signerTitle",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "noticesEmail": {
- "name": "noticesEmail",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "signatureData": {
- "name": "signatureData",
- "type": "longtext",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "signedAt": {
- "name": "signedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "emailSentAt": {
- "name": "emailSentAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "signed_baa_organization_id_idx": {
- "name": "signed_baa_organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": true
- },
- "signed_baa_stripe_subscription_idx": {
- "name": "signed_baa_stripe_subscription_idx",
- "columns": [
- "stripeSubscriptionId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "signed_baas_id": {
- "name": "signed_baas_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "space_members": {
- "name": "space_members",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "spaceId": {
- "name": "spaceId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "userId": {
- "name": "userId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "role": {
- "name": "role",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'member'"
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "user_id_idx": {
- "name": "user_id_idx",
- "columns": [
- "userId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "space_members_id": {
- "name": "space_members_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {
- "space_id_user_id_unique": {
- "name": "space_id_user_id_unique",
- "columns": [
- "spaceId",
- "userId"
- ]
- }
- },
- "checkConstraint": {}
- },
- "space_videos": {
- "name": "space_videos",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "spaceId": {
- "name": "spaceId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "folderId": {
- "name": "folderId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "videoId": {
- "name": "videoId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "addedById": {
- "name": "addedById",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "addedAt": {
- "name": "addedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- }
- },
- "indexes": {
- "folder_id_idx": {
- "name": "folder_id_idx",
- "columns": [
- "folderId"
- ],
- "isUnique": false
- },
- "video_id_idx": {
- "name": "video_id_idx",
- "columns": [
- "videoId"
- ],
- "isUnique": false
- },
- "added_by_id_idx": {
- "name": "added_by_id_idx",
- "columns": [
- "addedById"
- ],
- "isUnique": false
- },
- "space_id_video_id_idx": {
- "name": "space_id_video_id_idx",
- "columns": [
- "spaceId",
- "videoId"
- ],
- "isUnique": false
- },
- "space_id_folder_id_idx": {
- "name": "space_id_folder_id_idx",
- "columns": [
- "spaceId",
- "folderId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "space_videos_id": {
- "name": "space_videos_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "spaces": {
- "name": "spaces",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "primary": {
- "name": "primary",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdById": {
- "name": "createdById",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "iconUrl": {
- "name": "iconUrl",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "description": {
- "name": "description",
- "type": "varchar(1000)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "settings": {
- "name": "settings",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "privacy": {
- "name": "privacy",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'Private'"
- },
- "public": {
- "name": "public",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- }
- },
- "indexes": {
- "organization_id_idx": {
- "name": "organization_id_idx",
- "columns": [
- "organizationId"
- ],
- "isUnique": false
- },
- "created_by_id_idx": {
- "name": "created_by_id_idx",
- "columns": [
- "createdById"
- ],
- "isUnique": false
- },
- "public_organization_id_idx": {
- "name": "public_organization_id_idx",
- "columns": [
- "public",
- "organizationId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "spaces_id": {
- "name": "spaces_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "storage_integrations": {
- "name": "storage_integrations",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "organizationId": {
- "name": "organizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "provider": {
- "name": "provider",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "displayName": {
- "name": "displayName",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'active'"
- },
- "active": {
- "name": "active",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "encryptedConfig": {
- "name": "encryptedConfig",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "googleDriveAccessToken": {
- "name": "googleDriveAccessToken",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "googleDriveAccessTokenExpiresAt": {
- "name": "googleDriveAccessTokenExpiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "googleDriveTokenRefreshLeaseId": {
- "name": "googleDriveTokenRefreshLeaseId",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "googleDriveTokenRefreshLeaseExpiresAt": {
- "name": "googleDriveTokenRefreshLeaseExpiresAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "googleDriveStorageQuotaCache": {
- "name": "googleDriveStorageQuotaCache",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "owner_provider_idx": {
- "name": "owner_provider_idx",
- "columns": [
- "ownerId",
- "provider"
- ],
- "isUnique": false
- },
- "owner_active_idx": {
- "name": "owner_active_idx",
- "columns": [
- "ownerId",
- "active"
- ],
- "isUnique": false
- },
- "organization_provider_idx": {
- "name": "organization_provider_idx",
- "columns": [
- "organizationId",
- "provider"
- ],
- "isUnique": false
- },
- "organization_active_idx": {
- "name": "organization_active_idx",
- "columns": [
- "organizationId",
- "active",
- "status"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "storage_integrations_id": {
- "name": "storage_integrations_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "storage_objects": {
- "name": "storage_objects",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "integrationId": {
- "name": "integrationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "videoId": {
- "name": "videoId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "objectKey": {
- "name": "objectKey",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "objectKeyHash": {
- "name": "objectKeyHash",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "providerObjectId": {
- "name": "providerObjectId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "uploadSessionUrl": {
- "name": "uploadSessionUrl",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "uploadStatus": {
- "name": "uploadStatus",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'pending'"
- },
- "contentType": {
- "name": "contentType",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "contentLength": {
- "name": "contentLength",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "metadata": {
- "name": "metadata",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {
- "integration_key_hash_idx": {
- "name": "integration_key_hash_idx",
- "columns": [
- "integrationId",
- "objectKeyHash"
- ],
- "isUnique": true
- },
- "integration_status_idx": {
- "name": "integration_status_idx",
- "columns": [
- "integrationId",
- "uploadStatus"
- ],
- "isUnique": false
- },
- "integration_object_key_prefix_idx": {
- "name": "integration_object_key_prefix_idx",
- "columns": [
- "integrationId",
- "`objectKey`(191)"
- ],
- "isUnique": false
- },
- "video_id_idx": {
- "name": "video_id_idx",
- "columns": [
- "videoId"
- ],
- "isUnique": false
- },
- "owner_id_idx": {
- "name": "owner_id_idx",
- "columns": [
- "ownerId"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "storage_objects_integrationId_storage_integrations_id_fk": {
- "name": "storage_objects_integrationId_storage_integrations_id_fk",
- "tableFrom": "storage_objects",
- "tableTo": "storage_integrations",
- "columnsFrom": [
- "integrationId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "restrict",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "storage_objects_id": {
- "name": "storage_objects_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "users": {
- "name": "users",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "lastName": {
- "name": "lastName",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "email": {
- "name": "email",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "emailVerified": {
- "name": "emailVerified",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "image": {
- "name": "image",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripeCustomerId": {
- "name": "stripeCustomerId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripeSubscriptionId": {
- "name": "stripeSubscriptionId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "thirdPartyStripeSubscriptionId": {
- "name": "thirdPartyStripeSubscriptionId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripeSubscriptionStatus": {
- "name": "stripeSubscriptionStatus",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "stripeSubscriptionPriceId": {
- "name": "stripeSubscriptionPriceId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "preferences": {
- "name": "preferences",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false,
- "default": "('null')"
- },
- "activeOrganizationId": {
- "name": "activeOrganizationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "onboardingSteps": {
- "name": "onboardingSteps",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "onboarding_completed_at": {
- "name": "onboarding_completed_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "customBucket": {
- "name": "customBucket",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "inviteQuota": {
- "name": "inviteQuota",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 1
- },
- "defaultOrgId": {
- "name": "defaultOrgId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "authSessionVersion": {
- "name": "authSessionVersion",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "marketingOrigin": {
- "name": "marketingOrigin",
- "type": "varchar(20)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'unknown'"
- }
- },
- "indexes": {
- "email_idx": {
- "name": "email_idx",
- "columns": [
- "email"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "users_id": {
- "name": "users_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "verification_tokens": {
- "name": "verification_tokens",
- "columns": {
- "identifier": {
- "name": "identifier",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "token": {
- "name": "token",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expires": {
- "name": "expires",
- "type": "datetime",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "verification_tokens_identifier": {
- "name": "verification_tokens_identifier",
- "columns": [
- "identifier"
- ]
- }
- },
- "uniqueConstraints": {
- "verification_tokens_token_unique": {
- "name": "verification_tokens_token_unique",
- "columns": [
- "token"
- ]
- }
- },
- "checkConstraint": {}
- },
- "video_edits": {
- "name": "video_edits",
- "columns": {
- "videoId": {
- "name": "videoId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "sourceKey": {
- "name": "sourceKey",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "editSpec": {
- "name": "editSpec",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- }
- },
- "indexes": {},
- "foreignKeys": {
- "video_edits_videoId_videos_id_fk": {
- "name": "video_edits_videoId_videos_id_fk",
- "tableFrom": "video_edits",
- "tableTo": "videos",
- "columnsFrom": [
- "videoId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "video_edits_videoId": {
- "name": "video_edits_videoId",
- "columns": [
- "videoId"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "video_processing_jobs": {
- "name": "video_processing_jobs",
- "columns": {
- "video_id": {
- "name": "video_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "owner_id": {
- "name": "owner_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "generation": {
- "name": "generation",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "manifest_sha256": {
- "name": "manifest_sha256",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "state": {
- "name": "state",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'committing'"
- },
- "attempt_id": {
- "name": "attempt_id",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "attempt_count": {
- "name": "attempt_count",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "lease_expires_at": {
- "name": "lease_expires_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "next_retry_at": {
- "name": "next_retry_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "workflow_run_id": {
- "name": "workflow_run_id",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "remote_job_id": {
- "name": "remote_job_id",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "source": {
- "name": "source",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "verification": {
- "name": "verification",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "output": {
- "name": "output",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "error_code": {
- "name": "error_code",
- "type": "varchar(64)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "error_message": {
- "name": "error_message",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "datetime(3)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "processing_state_retry_video_idx": {
- "name": "processing_state_retry_video_idx",
- "columns": [
- "state",
- "next_retry_at",
- "video_id"
- ],
- "isUnique": false
- },
- "processing_state_lease_video_idx": {
- "name": "processing_state_lease_video_idx",
- "columns": [
- "state",
- "lease_expires_at",
- "video_id"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "video_processing_jobs_video_id": {
- "name": "video_processing_jobs_video_id",
- "columns": [
- "video_id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "video_uploads": {
- "name": "video_uploads",
- "columns": {
- "video_id": {
- "name": "video_id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "uploaded": {
- "name": "uploaded",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "total": {
- "name": "total",
- "type": "bigint unsigned",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "started_at": {
- "name": "started_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "mode": {
- "name": "mode",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "phase": {
- "name": "phase",
- "type": "varchar(32)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'uploading'"
- },
- "processing_progress": {
- "name": "processing_progress",
- "type": "int",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": 0
- },
- "processing_message": {
- "name": "processing_message",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "processing_error": {
- "name": "processing_error",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "raw_file_key": {
- "name": "raw_file_key",
- "type": "varchar(512)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "phase_updated_at_video_id_idx": {
- "name": "phase_updated_at_video_id_idx",
- "columns": [
- "phase",
- "updated_at",
- "video_id"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {
- "video_uploads_video_id": {
- "name": "video_uploads_video_id",
- "columns": [
- "video_id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "video_viewer_grants": {
- "name": "video_viewer_grants",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "videoId": {
- "name": "videoId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "email": {
- "name": "email",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "invitedByUserId": {
- "name": "invitedByUserId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "revokedAt": {
- "name": "revokedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "video_email_idx": {
- "name": "video_email_idx",
- "columns": [
- "videoId",
- "email"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "video_viewer_grants_videoId_videos_id_fk": {
- "name": "video_viewer_grants_videoId_videos_id_fk",
- "tableFrom": "video_viewer_grants",
- "tableTo": "videos",
- "columnsFrom": [
- "videoId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "video_viewer_grants_id": {
- "name": "video_viewer_grants_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- },
- "videos": {
- "name": "videos",
- "columns": {
- "id": {
- "name": "id",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "ownerId": {
- "name": "ownerId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "orgId": {
- "name": "orgId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'My Video'"
- },
- "bucket": {
- "name": "bucket",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "storageIntegrationId": {
- "name": "storageIntegrationId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "duration": {
- "name": "duration",
- "type": "float",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "width": {
- "name": "width",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "height": {
- "name": "height",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "fps": {
- "name": "fps",
- "type": "int",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "metadata": {
- "name": "metadata",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "public": {
- "name": "public",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": true
- },
- "settings": {
- "name": "settings",
- "type": "json",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "transcriptionStatus": {
- "name": "transcriptionStatus",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "source": {
- "name": "source",
- "type": "json",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "('{\"type\":\"MediaConvert\"}')"
- },
- "folderId": {
- "name": "folderId",
- "type": "varchar(15)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "createdAt": {
- "name": "createdAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "(now())"
- },
- "effectiveCreatedAt": {
- "name": "effectiveCreatedAt",
- "type": "datetime",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false,
- "generated": {
- "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )",
- "type": "stored"
- }
- },
- "updatedAt": {
- "name": "updatedAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "onUpdate": true,
- "default": "(now())"
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "xStreamInfo": {
- "name": "xStreamInfo",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "firstViewEmailSentAt": {
- "name": "firstViewEmailSentAt",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "isScreenshot": {
- "name": "isScreenshot",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- },
- "awsRegion": {
- "name": "awsRegion",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "awsBucket": {
- "name": "awsBucket",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "videoStartTime": {
- "name": "videoStartTime",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "audioStartTime": {
- "name": "audioStartTime",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "jobId": {
- "name": "jobId",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "jobStatus": {
- "name": "jobStatus",
- "type": "varchar(255)",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "skipProcessing": {
- "name": "skipProcessing",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": false
- }
- },
- "indexes": {
- "owner_id_idx": {
- "name": "owner_id_idx",
- "columns": [
- "ownerId"
- ],
- "isUnique": false
- },
- "owner_updated_id_idx": {
- "name": "owner_updated_id_idx",
- "columns": [
- "ownerId",
- "updatedAt",
- "id"
- ],
- "isUnique": false
- },
- "is_public_idx": {
- "name": "is_public_idx",
- "columns": [
- "public"
- ],
- "isUnique": false
- },
- "folder_id_idx": {
- "name": "folder_id_idx",
- "columns": [
- "folderId"
- ],
- "isUnique": false
- },
- "storage_integration_id_idx": {
- "name": "storage_integration_id_idx",
- "columns": [
- "storageIntegrationId"
- ],
- "isUnique": false
- },
- "org_owner_folder_idx": {
- "name": "org_owner_folder_idx",
- "columns": [
- "orgId",
- "ownerId",
- "folderId"
- ],
- "isUnique": false
- },
- "org_effective_created_idx": {
- "name": "org_effective_created_idx",
- "columns": [
- "orgId",
- "effectiveCreatedAt"
- ],
- "isUnique": false
- },
- "screenshot_transcription_created_idx": {
- "name": "screenshot_transcription_created_idx",
- "columns": [
- "isScreenshot",
- "transcriptionStatus",
- "createdAt"
- ],
- "isUnique": false
- }
- },
- "foreignKeys": {
- "videos_storageIntegrationId_storage_integrations_id_fk": {
- "name": "videos_storageIntegrationId_storage_integrations_id_fk",
- "tableFrom": "videos",
- "tableTo": "storage_integrations",
- "columnsFrom": [
- "storageIntegrationId"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "restrict",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "videos_id": {
- "name": "videos_id",
- "columns": [
- "id"
- ]
- }
- },
- "uniqueConstraints": {},
- "checkConstraint": {}
- }
- },
- "views": {},
- "_meta": {
- "schemas": {},
- "tables": {},
- "columns": {}
- },
- "internal": {
- "tables": {},
- "indexes": {
- "integration_object_key_prefix_idx": {
- "columns": {
- "`objectKey`(191)": {
- "isExpression": true
- }
- }
- }
- }
- }
-}
\ No newline at end of file
+ "version": "5",
+ "dialect": "mysql",
+ "id": "b3eb8041-9563-45df-99f3-649946dfd1e6",
+ "prevId": "7db9e8b2-ba9e-416d-a243-4b79b5d4ec5d",
+ "tables": {
+ "accounts": {
+ "name": "accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_in": {
+ "name": "expires_in",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_in": {
+ "name": "refresh_token_expires_in",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "tempColumn": {
+ "name": "tempColumn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ },
+ "provider_account_id_idx": {
+ "name": "provider_account_id_idx",
+ "columns": ["providerAccountId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "accounts_id": {
+ "name": "accounts_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_authorization_codes": {
+ "name": "agent_api_authorization_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": ["codeHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_authorization_codes_id": {
+ "name": "agent_api_authorization_codes_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_idempotency": {
+ "name": "agent_api_idempotency",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestHash": {
+ "name": "requestHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "response": {
+ "name": "response",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_operation_key_idx": {
+ "name": "user_operation_key_idx",
+ "columns": ["userId", "operation", "keyHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_idempotency_id": {
+ "name": "agent_api_idempotency_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_keys": {
+ "name": "agent_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tokenHash": {
+ "name": "tokenHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Cap CLI'"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "token_hash_idx": {
+ "name": "token_hash_idx",
+ "columns": ["tokenHash"],
+ "isUnique": true
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_keys_id": {
+ "name": "agent_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_operations": {
+ "name": "agent_api_operations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resourceId": {
+ "name": "resourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resultResourceId": {
+ "name": "resultResourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'queued'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorCode": {
+ "name": "errorCode",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "state_updated_at_idx": {
+ "name": "state_updated_at_idx",
+ "columns": ["state", "updatedAt"],
+ "isUnique": false
+ },
+ "resource_id_idx": {
+ "name": "resource_id_idx",
+ "columns": ["resourceId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_operations_id": {
+ "name": "agent_api_operations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "auth_api_keys": {
+ "name": "auth_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_created_at_idx": {
+ "name": "user_id_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "auth_api_keys_id": {
+ "name": "auth_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "parentCommentId": {
+ "name": "parentCommentId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaKey": {
+ "name": "mediaKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaDuration": {
+ "name": "mediaDuration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaMeta": {
+ "name": "mediaMeta",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_type_created_idx": {
+ "name": "video_type_created_idx",
+ "columns": ["videoId", "type", "createdAt", "id"],
+ "isUnique": false
+ },
+ "author_id_idx": {
+ "name": "author_id_idx",
+ "columns": ["authorId"],
+ "isUnique": false
+ },
+ "parent_comment_id_idx": {
+ "name": "parent_comment_id_idx",
+ "columns": ["parentCommentId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "comments_id": {
+ "name": "comments_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_api_keys": {
+ "name": "developer_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyType": {
+ "name": "keyType",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyPrefix": {
+ "name": "keyPrefix",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedKey": {
+ "name": "encryptedKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "key_hash_idx": {
+ "name": "key_hash_idx",
+ "columns": ["keyHash"],
+ "isUnique": true
+ },
+ "app_key_type_idx": {
+ "name": "app_key_type_idx",
+ "columns": ["appId", "keyType"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "developer_api_keys_appId_developer_apps_id_fk": {
+ "name": "developer_api_keys_appId_developer_apps_id_fk",
+ "tableFrom": "developer_api_keys",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_api_keys_id": {
+ "name": "developer_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_app_domains": {
+ "name": "developer_app_domains",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "varchar(253)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "developer_app_domains_appId_developer_apps_id_fk": {
+ "name": "developer_app_domains_appId_developer_apps_id_fk",
+ "tableFrom": "developer_app_domains",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_app_domains_id": {
+ "name": "developer_app_domains_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "app_domain_unique": {
+ "name": "app_domain_unique",
+ "columns": ["appId", "domain"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "developer_apps": {
+ "name": "developer_apps",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment": {
+ "name": "environment",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logoUrl": {
+ "name": "logoUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletedAt": {
+ "name": "deletedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_deleted_idx": {
+ "name": "owner_deleted_idx",
+ "columns": ["ownerId", "deletedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "developer_apps_id": {
+ "name": "developer_apps_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_credit_accounts": {
+ "name": "developer_credit_accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "balanceMicroCredits": {
+ "name": "balanceMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripePaymentMethodId": {
+ "name": "stripePaymentMethodId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoTopUpEnabled": {
+ "name": "autoTopUpEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "autoTopUpThresholdMicroCredits": {
+ "name": "autoTopUpThresholdMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "autoTopUpAmountCents": {
+ "name": "autoTopUpAmountCents",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "app_id_unique": {
+ "name": "app_id_unique",
+ "columns": ["appId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "developer_credit_accounts_appId_developer_apps_id_fk": {
+ "name": "developer_credit_accounts_appId_developer_apps_id_fk",
+ "tableFrom": "developer_credit_accounts",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_credit_accounts_id": {
+ "name": "developer_credit_accounts_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_credit_transactions": {
+ "name": "developer_credit_transactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accountId": {
+ "name": "accountId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amountMicroCredits": {
+ "name": "amountMicroCredits",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "balanceAfterMicroCredits": {
+ "name": "balanceAfterMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referenceId": {
+ "name": "referenceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referenceType": {
+ "name": "referenceType",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "account_type_created_idx": {
+ "name": "account_type_created_idx",
+ "columns": ["accountId", "type", "createdAt"],
+ "isUnique": false
+ },
+ "account_ref_dedup_idx": {
+ "name": "account_ref_dedup_idx",
+ "columns": ["accountId", "referenceId", "referenceType"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "dev_credit_txn_account_fk": {
+ "name": "dev_credit_txn_account_fk",
+ "tableFrom": "developer_credit_transactions",
+ "tableTo": "developer_credit_accounts",
+ "columnsFrom": ["accountId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_credit_transactions_id": {
+ "name": "developer_credit_transactions_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_daily_storage_snapshots": {
+ "name": "developer_daily_storage_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshotDate": {
+ "name": "snapshotDate",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totalDurationMinutes": {
+ "name": "totalDurationMinutes",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "videoCount": {
+ "name": "videoCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "microCreditsCharged": {
+ "name": "microCreditsCharged",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processedAt": {
+ "name": "processedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "developer_daily_storage_snapshots_appId_developer_apps_id_fk": {
+ "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk",
+ "tableFrom": "developer_daily_storage_snapshots",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_daily_storage_snapshots_id": {
+ "name": "developer_daily_storage_snapshots_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "app_date_unique": {
+ "name": "app_date_unique",
+ "columns": ["appId", "snapshotDate"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "developer_videos": {
+ "name": "developer_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalUserId": {
+ "name": "externalUserId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Untitled'"
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "s3Key": {
+ "name": "s3Key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletedAt": {
+ "name": "deletedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "app_created_idx": {
+ "name": "app_created_idx",
+ "columns": ["appId", "createdAt"],
+ "isUnique": false
+ },
+ "app_user_idx": {
+ "name": "app_user_idx",
+ "columns": ["appId", "externalUserId"],
+ "isUnique": false
+ },
+ "app_deleted_idx": {
+ "name": "app_deleted_idx",
+ "columns": ["appId", "deletedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "developer_videos_appId_developer_apps_id_fk": {
+ "name": "developer_videos_appId_developer_apps_id_fk",
+ "tableFrom": "developer_videos",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_videos_id": {
+ "name": "developer_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "folders": {
+ "name": "folders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'normal'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "created_by_id_idx": {
+ "name": "created_by_id_idx",
+ "columns": ["createdById"],
+ "isUnique": false
+ },
+ "parent_id_idx": {
+ "name": "parent_id_idx",
+ "columns": ["parentId"],
+ "isUnique": false
+ },
+ "space_id_idx": {
+ "name": "space_id_idx",
+ "columns": ["spaceId"],
+ "isUnique": false
+ },
+ "public_parent_id_idx": {
+ "name": "public_parent_id_idx",
+ "columns": ["public", "parentId"],
+ "isUnique": false
+ },
+ "public_space_parent_id_idx": {
+ "name": "public_space_parent_id_idx",
+ "columns": ["public", "spaceId", "parentId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "folders_id": {
+ "name": "folders_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "imported_videos": {
+ "name": "imported_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "id_idx": {
+ "name": "id_idx",
+ "columns": ["id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "imported_videos_orgId_source_source_id_pk": {
+ "name": "imported_videos_orgId_source_source_id_pk",
+ "columns": ["orgId", "source", "source_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_installations": {
+ "name": "integration_installations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalId": {
+ "name": "externalId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "installedByUserId": {
+ "name": "installedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedCredentials": {
+ "name": "encryptedCredentials",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "provider_external_id_idx": {
+ "name": "provider_external_id_idx",
+ "columns": ["provider", "externalId"],
+ "isUnique": true
+ },
+ "organization_provider_display_name_idx": {
+ "name": "organization_provider_display_name_idx",
+ "columns": ["organizationId", "provider", "displayName"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "integration_installations_id": {
+ "name": "integration_installations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_job_items": {
+ "name": "loom_import_job_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "csv_row": {
+ "name": "csv_row",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "loom_url": {
+ "name": "loom_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "loom_video_id": {
+ "name": "loom_video_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "owner_email": {
+ "name": "owner_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "space_name": {
+ "name": "space_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "space_id": {
+ "name": "space_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loom_created_at": {
+ "name": "loom_created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_url": {
+ "name": "thumbnail_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "job_row_idx": {
+ "name": "job_row_idx",
+ "columns": ["job_id", "csv_row"],
+ "isUnique": true
+ },
+ "job_status_row_idx": {
+ "name": "job_status_row_idx",
+ "columns": ["job_id", "status", "csv_row"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_job_items_id": {
+ "name": "loom_import_job_items_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_jobs": {
+ "name": "loom_import_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'checking'"
+ },
+ "total_count": {
+ "name": "total_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_creator_created_idx": {
+ "name": "org_creator_created_idx",
+ "columns": ["org_id", "created_by_id", "created_at"],
+ "isUnique": false
+ },
+ "status_updated_idx": {
+ "name": "status_updated_idx",
+ "columns": ["status", "updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_jobs_id": {
+ "name": "loom_import_jobs_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loops_sync_jobs": {
+ "name": "loops_sync_jobs",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "nextAttemptAt": {
+ "name": "nextAttemptAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "leaseToken": {
+ "name": "leaseToken",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "leaseUntil": {
+ "name": "leaseUntil",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failures": {
+ "name": "failures",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "profileHash": {
+ "name": "profileHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teammateJoinedAt": {
+ "name": "teammateJoinedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "syncedEmail": {
+ "name": "syncedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastSyncedAt": {
+ "name": "lastSyncedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "loops_sync_due_idx": {
+ "name": "loops_sync_due_idx",
+ "columns": ["nextAttemptAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loops_sync_jobs_userId": {
+ "name": "loops_sync_jobs_userId",
+ "columns": ["userId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_clients": {
+ "name": "mcp_oauth_clients",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientName": {
+ "name": "clientName",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUris": {
+ "name": "redirectUris",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activatedAt": {
+ "name": "activatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "client_id_idx": {
+ "name": "client_id_idx",
+ "columns": ["clientId"],
+ "isUnique": true
+ },
+ "inactive_created_at_idx": {
+ "name": "inactive_created_at_idx",
+ "columns": ["activatedAt", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_clients_id": {
+ "name": "mcp_oauth_clients_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_codes": {
+ "name": "mcp_oauth_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": ["codeHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_codes_id": {
+ "name": "mcp_oauth_codes_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_registration_quotas": {
+ "name": "mcp_oauth_registration_quotas",
+ "columns": {
+ "windowId": {
+ "name": "windowId",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "registrations": {
+ "name": "registrations",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_registration_quotas_windowId": {
+ "name": "mcp_oauth_registration_quotas_windowId",
+ "columns": ["windowId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_tokens": {
+ "name": "mcp_oauth_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "familyId": {
+ "name": "familyId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessHash": {
+ "name": "accessHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshHash": {
+ "name": "refreshHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessExpiresAt": {
+ "name": "accessExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshExpiresAt": {
+ "name": "refreshExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "access_hash_idx": {
+ "name": "access_hash_idx",
+ "columns": ["accessHash"],
+ "isUnique": true
+ },
+ "refresh_hash_idx": {
+ "name": "refresh_hash_idx",
+ "columns": ["refreshHash"],
+ "isUnique": true
+ },
+ "family_id_idx": {
+ "name": "family_id_idx",
+ "columns": ["familyId"],
+ "isUnique": false
+ },
+ "client_active_idx": {
+ "name": "client_active_idx",
+ "columns": ["clientId", "revokedAt", "refreshExpiresAt"],
+ "isUnique": false
+ },
+ "refresh_expires_at_idx": {
+ "name": "refresh_expires_at_idx",
+ "columns": ["refreshExpiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_tokens_id": {
+ "name": "mcp_oauth_tokens_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media_processing_budgets": {
+ "name": "media_processing_budgets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reserved_bytes": {
+ "name": "reserved_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "limit_bytes": {
+ "name": "limit_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "media_budget_expiry_idx": {
+ "name": "media_budget_expiry_idx",
+ "columns": ["expires_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "media_processing_budgets_id": {
+ "name": "media_processing_budgets_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_conversations": {
+ "name": "messenger_conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent": {
+ "name": "agent",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'agent'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "anonymousId": {
+ "name": "anonymousId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "takeoverByUserId": {
+ "name": "takeoverByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "takeoverAt": {
+ "name": "takeoverAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastMessageAt": {
+ "name": "lastMessageAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_last_message_idx": {
+ "name": "user_last_message_idx",
+ "columns": ["userId", "lastMessageAt"],
+ "isUnique": false
+ },
+ "anonymous_last_message_idx": {
+ "name": "anonymous_last_message_idx",
+ "columns": ["anonymousId", "lastMessageAt"],
+ "isUnique": false
+ },
+ "mode_last_message_idx": {
+ "name": "mode_last_message_idx",
+ "columns": ["mode", "lastMessageAt"],
+ "isUnique": false
+ },
+ "updated_at_idx": {
+ "name": "updated_at_idx",
+ "columns": ["updatedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "messenger_conversations_id": {
+ "name": "messenger_conversations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_messages": {
+ "name": "messenger_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversationId": {
+ "name": "conversationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "anonymousId": {
+ "name": "anonymousId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "conversation_created_at_idx": {
+ "name": "conversation_created_at_idx",
+ "columns": ["conversationId", "createdAt"],
+ "isUnique": false
+ },
+ "role_created_at_idx": {
+ "name": "role_created_at_idx",
+ "columns": ["role", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messenger_messages_conversationId_messenger_conversations_id_fk": {
+ "name": "messenger_messages_conversationId_messenger_conversations_id_fk",
+ "tableFrom": "messenger_messages",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": ["conversationId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_messages_id": {
+ "name": "messenger_messages_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_support_emails": {
+ "name": "messenger_support_emails",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversationId": {
+ "name": "conversationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userEmail": {
+ "name": "userEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "support_email_user_created_at_idx": {
+ "name": "support_email_user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "support_email_conversation_created_at_idx": {
+ "name": "support_email_conversation_created_at_idx",
+ "columns": ["conversationId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "support_email_conversation_fk": {
+ "name": "support_email_conversation_fk",
+ "tableFrom": "messenger_support_emails",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": ["conversationId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_support_emails_id": {
+ "name": "messenger_support_emails_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipientId": {
+ "name": "recipientId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dedupKey": {
+ "name": "dedupKey",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readAt": {
+ "name": "readAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "org_id_idx": {
+ "name": "org_id_idx",
+ "columns": ["orgId"],
+ "isUnique": false
+ },
+ "type_idx": {
+ "name": "type_idx",
+ "columns": ["type"],
+ "isUnique": false
+ },
+ "read_at_idx": {
+ "name": "read_at_idx",
+ "columns": ["readAt"],
+ "isUnique": false
+ },
+ "created_at_idx": {
+ "name": "created_at_idx",
+ "columns": ["createdAt"],
+ "isUnique": false
+ },
+ "recipient_read_idx": {
+ "name": "recipient_read_idx",
+ "columns": ["recipientId", "readAt"],
+ "isUnique": false
+ },
+ "recipient_created_idx": {
+ "name": "recipient_created_idx",
+ "columns": ["recipientId", "createdAt"],
+ "isUnique": false
+ },
+ "dedup_key_idx": {
+ "name": "dedup_key_idx",
+ "columns": ["dedupKey"],
+ "isUnique": true
+ },
+ "type_recipient_created_idx": {
+ "name": "type_recipient_created_idx",
+ "columns": ["type", "recipientId", "createdAt"],
+ "isUnique": false
+ },
+ "type_recipient_video_created_idx": {
+ "name": "type_recipient_video_created_idx",
+ "columns": ["type", "recipientId", "videoId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "notifications_id": {
+ "name": "notifications_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_invites": {
+ "name": "organization_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "invited_email_idx": {
+ "name": "invited_email_idx",
+ "columns": ["invitedEmail"],
+ "isUnique": false
+ },
+ "invited_by_user_id_idx": {
+ "name": "invited_by_user_id_idx",
+ "columns": ["invitedByUserId"],
+ "isUnique": false
+ },
+ "status_idx": {
+ "name": "status_idx",
+ "columns": ["status"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_invites_id": {
+ "name": "organization_invites_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_members": {
+ "name": "organization_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "hasProSeat": {
+ "name": "hasProSeat",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "user_id_organization_id_idx": {
+ "name": "user_id_organization_id_idx",
+ "columns": ["userId", "organizationId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_members_id": {
+ "name": "organization_members_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_sso": {
+ "name": "organization_sso",
+ "columns": {
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "purchasedByUserId": {
+ "name": "purchasedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripePriceId": {
+ "name": "stripePriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "paidThrough": {
+ "name": "paidThrough",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "currentPeriodEnd": {
+ "name": "currentPeriodEnd",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "checkoutAttemptId": {
+ "name": "checkoutAttemptId",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutCurrency": {
+ "name": "checkoutCurrency",
+ "type": "varchar(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutPriceId": {
+ "name": "checkoutPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutSessionId": {
+ "name": "checkoutSessionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutStartedAt": {
+ "name": "checkoutStartedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "sso_stripe_subscription_id_idx": {
+ "name": "sso_stripe_subscription_id_idx",
+ "columns": ["stripeSubscriptionId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_sso_organizationId": {
+ "name": "organization_sso_organizationId",
+ "columns": ["organizationId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organizations": {
+ "name": "organizations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tombstoneAt": {
+ "name": "tombstoneAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "allowedEmailDomain": {
+ "name": "allowedEmailDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "defaultVideoVisibility": {
+ "name": "defaultVideoVisibility",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customDomain": {
+ "name": "customDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "domainVerified": {
+ "name": "domainVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shareableLinkIconUrl": {
+ "name": "shareableLinkIconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "workosOrganizationId": {
+ "name": "workosOrganizationId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workosConnectionId": {
+ "name": "workosConnectionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "owner_id_tombstone_idx": {
+ "name": "owner_id_tombstone_idx",
+ "columns": ["ownerId", "tombstoneAt"],
+ "isUnique": false
+ },
+ "custom_domain_idx": {
+ "name": "custom_domain_idx",
+ "columns": ["customDomain"],
+ "isUnique": false
+ },
+ "workos_organization_id_idx": {
+ "name": "workos_organization_id_idx",
+ "columns": ["workosOrganizationId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organizations_id": {
+ "name": "organizations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "s3_buckets": {
+ "name": "s3_buckets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bucketName": {
+ "name": "bucketName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessKeyId": {
+ "name": "accessKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secretAccessKey": {
+ "name": "secretAccessKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('aws')"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_organization_idx": {
+ "name": "owner_organization_idx",
+ "columns": ["ownerId", "organizationId"],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": ["organizationId", "active", "updatedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "s3_buckets_id": {
+ "name": "s3_buckets_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "session_token_idx": {
+ "name": "session_token_idx",
+ "columns": ["sessionToken"],
+ "isUnique": true
+ },
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sessions_id": {
+ "name": "sessions_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "shared_videos": {
+ "name": "shared_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedByUserId": {
+ "name": "sharedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedAt": {
+ "name": "sharedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "shared_by_user_id_idx": {
+ "name": "shared_by_user_id_idx",
+ "columns": ["sharedByUserId"],
+ "isUnique": false
+ },
+ "video_id_organization_id_idx": {
+ "name": "video_id_organization_id_idx",
+ "columns": ["videoId", "organizationId"],
+ "isUnique": false
+ },
+ "video_id_folder_id_idx": {
+ "name": "video_id_folder_id_idx",
+ "columns": ["videoId", "folderId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "shared_videos_id": {
+ "name": "shared_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "signed_baas": {
+ "name": "signed_baas",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entityName": {
+ "name": "entityName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityType": {
+ "name": "entityType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityAddress": {
+ "name": "entityAddress",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerName": {
+ "name": "signerName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerTitle": {
+ "name": "signerTitle",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "noticesEmail": {
+ "name": "noticesEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signatureData": {
+ "name": "signatureData",
+ "type": "longtext",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signedAt": {
+ "name": "signedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "emailSentAt": {
+ "name": "emailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "signed_baa_organization_id_idx": {
+ "name": "signed_baa_organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": true
+ },
+ "signed_baa_stripe_subscription_idx": {
+ "name": "signed_baa_stripe_subscription_idx",
+ "columns": ["stripeSubscriptionId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "signed_baas_id": {
+ "name": "signed_baas_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "space_members": {
+ "name": "space_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_members_id": {
+ "name": "space_members_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "space_id_user_id_unique": {
+ "name": "space_id_user_id_unique",
+ "columns": ["spaceId", "userId"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "space_videos": {
+ "name": "space_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedById": {
+ "name": "addedById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["videoId"],
+ "isUnique": false
+ },
+ "added_by_id_idx": {
+ "name": "added_by_id_idx",
+ "columns": ["addedById"],
+ "isUnique": false
+ },
+ "space_id_video_id_idx": {
+ "name": "space_id_video_id_idx",
+ "columns": ["spaceId", "videoId"],
+ "isUnique": false
+ },
+ "space_id_folder_id_idx": {
+ "name": "space_id_folder_id_idx",
+ "columns": ["spaceId", "folderId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_videos_id": {
+ "name": "space_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "spaces": {
+ "name": "spaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "primary": {
+ "name": "primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "privacy": {
+ "name": "privacy",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Private'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "created_by_id_idx": {
+ "name": "created_by_id_idx",
+ "columns": ["createdById"],
+ "isUnique": false
+ },
+ "public_organization_id_idx": {
+ "name": "public_organization_id_idx",
+ "columns": ["public", "organizationId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "spaces_id": {
+ "name": "spaces_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_integrations": {
+ "name": "storage_integrations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "encryptedConfig": {
+ "name": "encryptedConfig",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "googleDriveAccessToken": {
+ "name": "googleDriveAccessToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveAccessTokenExpiresAt": {
+ "name": "googleDriveAccessTokenExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseId": {
+ "name": "googleDriveTokenRefreshLeaseId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseExpiresAt": {
+ "name": "googleDriveTokenRefreshLeaseExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveStorageQuotaCache": {
+ "name": "googleDriveStorageQuotaCache",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_provider_idx": {
+ "name": "owner_provider_idx",
+ "columns": ["ownerId", "provider"],
+ "isUnique": false
+ },
+ "owner_active_idx": {
+ "name": "owner_active_idx",
+ "columns": ["ownerId", "active"],
+ "isUnique": false
+ },
+ "organization_provider_idx": {
+ "name": "organization_provider_idx",
+ "columns": ["organizationId", "provider"],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": ["organizationId", "active", "status"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "storage_integrations_id": {
+ "name": "storage_integrations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_objects": {
+ "name": "storage_objects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrationId": {
+ "name": "integrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "objectKey": {
+ "name": "objectKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "objectKeyHash": {
+ "name": "objectKeyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerObjectId": {
+ "name": "providerObjectId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploadSessionUrl": {
+ "name": "uploadSessionUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uploadStatus": {
+ "name": "uploadStatus",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentLength": {
+ "name": "contentLength",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "integration_key_hash_idx": {
+ "name": "integration_key_hash_idx",
+ "columns": ["integrationId", "objectKeyHash"],
+ "isUnique": true
+ },
+ "integration_status_idx": {
+ "name": "integration_status_idx",
+ "columns": ["integrationId", "uploadStatus"],
+ "isUnique": false
+ },
+ "integration_object_key_prefix_idx": {
+ "name": "integration_object_key_prefix_idx",
+ "columns": ["integrationId", "`objectKey`(191)"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["videoId"],
+ "isUnique": false
+ },
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": ["ownerId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "storage_objects_integrationId_storage_integrations_id_fk": {
+ "name": "storage_objects_integrationId_storage_integrations_id_fk",
+ "tableFrom": "storage_objects",
+ "tableTo": "storage_integrations",
+ "columnsFrom": ["integrationId"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "storage_objects_id": {
+ "name": "storage_objects_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastName": {
+ "name": "lastName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thirdPartyStripeSubscriptionId": {
+ "name": "thirdPartyStripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionStatus": {
+ "name": "stripeSubscriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionPriceId": {
+ "name": "stripeSubscriptionPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferences": {
+ "name": "preferences",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('null')"
+ },
+ "activeOrganizationId": {
+ "name": "activeOrganizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "onboardingSteps": {
+ "name": "onboardingSteps",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customBucket": {
+ "name": "customBucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inviteQuota": {
+ "name": "inviteQuota",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "defaultOrgId": {
+ "name": "defaultOrgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authSessionVersion": {
+ "name": "authSessionVersion",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "marketingOrigin": {
+ "name": "marketingOrigin",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {
+ "email_idx": {
+ "name": "email_idx",
+ "columns": ["email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verification_tokens": {
+ "name": "verification_tokens",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verification_tokens_identifier": {
+ "name": "verification_tokens_identifier",
+ "columns": ["identifier"]
+ }
+ },
+ "uniqueConstraints": {
+ "verification_tokens_token_unique": {
+ "name": "verification_tokens_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "video_edits": {
+ "name": "video_edits",
+ "columns": {
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceKey": {
+ "name": "sourceKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "editSpec": {
+ "name": "editSpec",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "video_edits_videoId_videos_id_fk": {
+ "name": "video_edits_videoId_videos_id_fk",
+ "tableFrom": "video_edits",
+ "tableTo": "videos",
+ "columnsFrom": ["videoId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_edits_videoId": {
+ "name": "video_edits_videoId",
+ "columns": ["videoId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_processing_jobs": {
+ "name": "video_processing_jobs",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manifest_sha256": {
+ "name": "manifest_sha256",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'committing'"
+ },
+ "attempt_id": {
+ "name": "attempt_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_retry_at": {
+ "name": "next_retry_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workflow_run_id": {
+ "name": "workflow_run_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_job_id": {
+ "name": "remote_job_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification": {
+ "name": "verification",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "output": {
+ "name": "output",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "processing_state_retry_video_idx": {
+ "name": "processing_state_retry_video_idx",
+ "columns": ["state", "next_retry_at", "video_id"],
+ "isUnique": false
+ },
+ "processing_state_lease_video_idx": {
+ "name": "processing_state_lease_video_idx",
+ "columns": ["state", "lease_expires_at", "video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_processing_jobs_video_id": {
+ "name": "video_processing_jobs_video_id",
+ "columns": ["video_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_uploads": {
+ "name": "video_uploads",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total": {
+ "name": "total",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'uploading'"
+ },
+ "processing_progress": {
+ "name": "processing_progress",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processing_message": {
+ "name": "processing_message",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processing_error": {
+ "name": "processing_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "raw_file_key": {
+ "name": "raw_file_key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "phase_updated_at_video_id_idx": {
+ "name": "phase_updated_at_video_id_idx",
+ "columns": ["phase", "updated_at", "video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_uploads_video_id": {
+ "name": "video_uploads_video_id",
+ "columns": ["video_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_viewer_grants": {
+ "name": "video_viewer_grants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_email_idx": {
+ "name": "video_email_idx",
+ "columns": ["videoId", "email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "video_viewer_grants_videoId_videos_id_fk": {
+ "name": "video_viewer_grants_videoId_videos_id_fk",
+ "tableFrom": "video_viewer_grants",
+ "tableTo": "videos",
+ "columnsFrom": ["videoId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_viewer_grants_id": {
+ "name": "video_viewer_grants_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "videos": {
+ "name": "videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'My Video'"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageIntegrationId": {
+ "name": "storageIntegrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"type\":\"MediaConvert\"}')"
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "effectiveCreatedAt": {
+ "name": "effectiveCreatedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "generated": {
+ "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )",
+ "type": "stored"
+ }
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "xStreamInfo": {
+ "name": "xStreamInfo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "firstViewEmailSentAt": {
+ "name": "firstViewEmailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "isScreenshot": {
+ "name": "isScreenshot",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "awsRegion": {
+ "name": "awsRegion",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "awsBucket": {
+ "name": "awsBucket",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoStartTime": {
+ "name": "videoStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "audioStartTime": {
+ "name": "audioStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobId": {
+ "name": "jobId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobStatus": {
+ "name": "jobStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "skipProcessing": {
+ "name": "skipProcessing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": ["ownerId"],
+ "isUnique": false
+ },
+ "owner_updated_id_idx": {
+ "name": "owner_updated_id_idx",
+ "columns": ["ownerId", "updatedAt", "id"],
+ "isUnique": false
+ },
+ "is_public_idx": {
+ "name": "is_public_idx",
+ "columns": ["public"],
+ "isUnique": false
+ },
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "storage_integration_id_idx": {
+ "name": "storage_integration_id_idx",
+ "columns": ["storageIntegrationId"],
+ "isUnique": false
+ },
+ "org_owner_folder_idx": {
+ "name": "org_owner_folder_idx",
+ "columns": ["orgId", "ownerId", "folderId"],
+ "isUnique": false
+ },
+ "org_effective_created_idx": {
+ "name": "org_effective_created_idx",
+ "columns": ["orgId", "effectiveCreatedAt"],
+ "isUnique": false
+ },
+ "screenshot_transcription_created_idx": {
+ "name": "screenshot_transcription_created_idx",
+ "columns": ["isScreenshot", "transcriptionStatus", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "videos_storageIntegrationId_storage_integrations_id_fk": {
+ "name": "videos_storageIntegrationId_storage_integrations_id_fk",
+ "tableFrom": "videos",
+ "tableTo": "storage_integrations",
+ "columnsFrom": ["storageIntegrationId"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "videos_id": {
+ "name": "videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {
+ "integration_object_key_prefix_idx": {
+ "columns": {
+ "`objectKey`(191)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json
index b72ca9d21f3..b20cd37dc8f 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -1,363 +1,363 @@
{
- "version": "5",
- "dialect": "mysql",
- "entries": [
- {
- "idx": 0,
- "version": "5",
- "when": 1743020179593,
- "tag": "0000_brown_sunfire",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "5",
- "when": 1749268354138,
- "tag": "0001_white_young_avengers",
- "breakpoints": true
- },
- {
- "idx": 2,
- "version": "5",
- "when": 1750935538683,
- "tag": "0002_dusty_maginty",
- "breakpoints": true
- },
- {
- "idx": 3,
- "version": "5",
- "when": 1761710697286,
- "tag": "0003_thin_gressill",
- "breakpoints": true
- },
- {
- "idx": 4,
- "version": "5",
- "when": 1761711378574,
- "tag": "0004_video-org-id",
- "breakpoints": true
- },
- {
- "idx": 5,
- "version": "5",
- "when": 1761711605408,
- "tag": "0005_video-org-id-required",
- "breakpoints": true
- },
- {
- "idx": 6,
- "version": "5",
- "when": 1762410865419,
- "tag": "0006_hesitant_stone_men",
- "breakpoints": true
- },
- {
- "idx": 7,
- "version": "5",
- "when": 1762428551824,
- "tag": "0007_public_toxin",
- "breakpoints": true
- },
- {
- "idx": 8,
- "version": "5",
- "when": 1762428905323,
- "tag": "0008_fat_ender_wiggin",
- "breakpoints": true
- },
- {
- "idx": 9,
- "version": "5",
- "when": 1768139432782,
- "tag": "0009_easy_ulik",
- "breakpoints": true
- },
- {
- "idx": 10,
- "version": "5",
- "when": 1738505600000,
- "tag": "0010_video_uploads_bigint",
- "breakpoints": true
- },
- {
- "idx": 11,
- "version": "5",
- "when": 1771325011010,
- "tag": "0011_public_inhumans",
- "breakpoints": true
- },
- {
- "idx": 12,
- "version": "5",
- "when": 1772534950328,
- "tag": "0012_lethal_lilith",
- "breakpoints": true
- },
- {
- "idx": 13,
- "version": "5",
- "when": 1772573402457,
- "tag": "0013_faithful_marvex",
- "breakpoints": true
- },
- {
- "idx": 14,
- "version": "5",
- "when": 1772576220593,
- "tag": "0014_modern_triton",
- "breakpoints": true
- },
- {
- "idx": 15,
- "version": "5",
- "when": 1772582468257,
- "tag": "0015_closed_tigra",
- "breakpoints": true
- },
- {
- "idx": 16,
- "version": "5",
- "when": 1772641549840,
- "tag": "0016_bouncy_hellcat",
- "breakpoints": true
- },
- {
- "idx": 17,
- "version": "5",
- "when": 1778099532336,
- "tag": "0017_productive_betty_brant",
- "breakpoints": true
- },
- {
- "idx": 18,
- "version": "5",
- "when": 1778153157657,
- "tag": "0018_loud_mongu",
- "breakpoints": true
- },
- {
- "idx": 19,
- "version": "5",
- "when": 1778154209547,
- "tag": "0019_solid_paibok",
- "breakpoints": true
- },
- {
- "idx": 20,
- "version": "5",
- "when": 1778157016497,
- "tag": "0020_orange_talkback",
- "breakpoints": true
- },
- {
- "idx": 21,
- "version": "5",
- "when": 1778249922872,
- "tag": "0021_glorious_khan",
- "breakpoints": true
- },
- {
- "idx": 22,
- "version": "5",
- "when": 1778252207674,
- "tag": "0022_dazzling_namor",
- "breakpoints": true
- },
- {
- "idx": 23,
- "version": "5",
- "when": 1778434170430,
- "tag": "0023_misty_luckman",
- "breakpoints": true
- },
- {
- "idx": 24,
- "version": "5",
- "when": 1778776694053,
- "tag": "0024_many_speed",
- "breakpoints": true
- },
- {
- "idx": 25,
- "version": "5",
- "when": 1778858762935,
- "tag": "0025_demonic_mother_askani",
- "breakpoints": true
- },
- {
- "idx": 26,
- "version": "5",
- "when": 1779283712737,
- "tag": "0026_pretty_the_professor",
- "breakpoints": true
- },
- {
- "idx": 27,
- "version": "5",
- "when": 1780932760351,
- "tag": "0027_giant_shinko_yamashiro",
- "breakpoints": true
- },
- {
- "idx": 28,
- "version": "5",
- "when": 1780937790068,
- "tag": "0028_nebulous_madame_masque",
- "breakpoints": true
- },
- {
- "idx": 29,
- "version": "5",
- "when": 1780937848351,
- "tag": "0029_blushing_pretty_boy",
- "breakpoints": true
- },
- {
- "idx": 30,
- "version": "5",
- "when": 1780940100362,
- "tag": "0030_add_folder_settings",
- "breakpoints": true
- },
- {
- "idx": 31,
- "version": "5",
- "when": 1781271269353,
- "tag": "0031_add_auth_api_keys_user_created_index",
- "breakpoints": true
- },
- {
- "idx": 32,
- "version": "5",
- "when": 1782850635482,
- "tag": "0032_past_lester",
- "breakpoints": true
- },
- {
- "idx": 33,
- "version": "5",
- "when": 1782853232527,
- "tag": "0033_fluffy_gamora",
- "breakpoints": true
- },
- {
- "idx": 34,
- "version": "5",
- "when": 1782854064505,
- "tag": "0034_fluffy_falcon",
- "breakpoints": true
- },
- {
- "idx": 35,
- "version": "5",
- "when": 1784393810560,
- "tag": "0035_agent_api",
- "breakpoints": true
- },
- {
- "idx": 36,
- "version": "5",
- "when": 1784402487715,
- "tag": "0036_premium_master_chief",
- "breakpoints": true
- },
- {
- "idx": 37,
- "version": "5",
- "when": 1785452585612,
- "tag": "0037_integration_installations",
- "breakpoints": true
- },
- {
- "idx": 38,
- "version": "5",
- "when": 1785892430217,
- "tag": "0038_mean_ozymandias",
- "breakpoints": true
- },
- {
- "idx": 39,
- "version": "5",
- "when": 1787096888361,
- "tag": "0039_magical_vermin",
- "breakpoints": true
- },
- {
- "idx": 40,
- "version": "5",
- "when": 1788366318415,
- "tag": "0040_recording_jobs",
- "breakpoints": true
- },
- {
- "idx": 41,
- "version": "5",
- "when": 1788384021541,
- "tag": "0041_saml_sso",
- "breakpoints": true
- },
- {
- "idx": 42,
- "version": "5",
- "when": 1788437051131,
- "tag": "0042_perf_indexes",
- "breakpoints": true
- },
- {
- "idx": 43,
- "version": "5",
- "when": 1788695529193,
- "tag": "0043_flimsy_caretaker",
- "breakpoints": true
- },
- {
- "idx": 44,
- "version": "5",
- "when": 1789135858521,
- "tag": "0044_awesome_sugar_man",
- "breakpoints": true
- },
- {
- "idx": 45,
- "version": "5",
- "when": 1789136779085,
- "tag": "0045_steep_forge",
- "breakpoints": true
- },
- {
- "idx": 46,
- "version": "5",
- "when": 1790071320615,
- "tag": "0046_mcp_oauth",
- "breakpoints": true
- },
- {
- "idx": 47,
- "version": "5",
- "when": 1790073320452,
- "tag": "0047_mcp_client_lifecycle",
- "breakpoints": true
- },
- {
- "idx": 48,
- "version": "5",
- "when": 1790073970527,
- "tag": "0048_mcp_registration_quota",
- "breakpoints": true
- },
- {
- "idx": 49,
- "version": "5",
- "when": 1790203121737,
- "tag": "0049_jittery_professor_monster",
- "breakpoints": true
- },
- {
- "idx": 50,
- "version": "5",
- "when": 1791345311370,
- "tag": "0050_productive_christian_walker",
- "breakpoints": true
- }
- ]
-}
\ No newline at end of file
+ "version": "5",
+ "dialect": "mysql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1743020179593,
+ "tag": "0000_brown_sunfire",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "5",
+ "when": 1749268354138,
+ "tag": "0001_white_young_avengers",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "5",
+ "when": 1750935538683,
+ "tag": "0002_dusty_maginty",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "5",
+ "when": 1761710697286,
+ "tag": "0003_thin_gressill",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "5",
+ "when": 1761711378574,
+ "tag": "0004_video-org-id",
+ "breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "5",
+ "when": 1761711605408,
+ "tag": "0005_video-org-id-required",
+ "breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "5",
+ "when": 1762410865419,
+ "tag": "0006_hesitant_stone_men",
+ "breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "5",
+ "when": 1762428551824,
+ "tag": "0007_public_toxin",
+ "breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "5",
+ "when": 1762428905323,
+ "tag": "0008_fat_ender_wiggin",
+ "breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "5",
+ "when": 1768139432782,
+ "tag": "0009_easy_ulik",
+ "breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "5",
+ "when": 1738505600000,
+ "tag": "0010_video_uploads_bigint",
+ "breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "5",
+ "when": 1771325011010,
+ "tag": "0011_public_inhumans",
+ "breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "5",
+ "when": 1772534950328,
+ "tag": "0012_lethal_lilith",
+ "breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "5",
+ "when": 1772573402457,
+ "tag": "0013_faithful_marvex",
+ "breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "5",
+ "when": 1772576220593,
+ "tag": "0014_modern_triton",
+ "breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "5",
+ "when": 1772582468257,
+ "tag": "0015_closed_tigra",
+ "breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "5",
+ "when": 1772641549840,
+ "tag": "0016_bouncy_hellcat",
+ "breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "5",
+ "when": 1778099532336,
+ "tag": "0017_productive_betty_brant",
+ "breakpoints": true
+ },
+ {
+ "idx": 18,
+ "version": "5",
+ "when": 1778153157657,
+ "tag": "0018_loud_mongu",
+ "breakpoints": true
+ },
+ {
+ "idx": 19,
+ "version": "5",
+ "when": 1778154209547,
+ "tag": "0019_solid_paibok",
+ "breakpoints": true
+ },
+ {
+ "idx": 20,
+ "version": "5",
+ "when": 1778157016497,
+ "tag": "0020_orange_talkback",
+ "breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "5",
+ "when": 1778249922872,
+ "tag": "0021_glorious_khan",
+ "breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "5",
+ "when": 1778252207674,
+ "tag": "0022_dazzling_namor",
+ "breakpoints": true
+ },
+ {
+ "idx": 23,
+ "version": "5",
+ "when": 1778434170430,
+ "tag": "0023_misty_luckman",
+ "breakpoints": true
+ },
+ {
+ "idx": 24,
+ "version": "5",
+ "when": 1778776694053,
+ "tag": "0024_many_speed",
+ "breakpoints": true
+ },
+ {
+ "idx": 25,
+ "version": "5",
+ "when": 1778858762935,
+ "tag": "0025_demonic_mother_askani",
+ "breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "5",
+ "when": 1779283712737,
+ "tag": "0026_pretty_the_professor",
+ "breakpoints": true
+ },
+ {
+ "idx": 27,
+ "version": "5",
+ "when": 1780932760351,
+ "tag": "0027_giant_shinko_yamashiro",
+ "breakpoints": true
+ },
+ {
+ "idx": 28,
+ "version": "5",
+ "when": 1780937790068,
+ "tag": "0028_nebulous_madame_masque",
+ "breakpoints": true
+ },
+ {
+ "idx": 29,
+ "version": "5",
+ "when": 1780937848351,
+ "tag": "0029_blushing_pretty_boy",
+ "breakpoints": true
+ },
+ {
+ "idx": 30,
+ "version": "5",
+ "when": 1780940100362,
+ "tag": "0030_add_folder_settings",
+ "breakpoints": true
+ },
+ {
+ "idx": 31,
+ "version": "5",
+ "when": 1781271269353,
+ "tag": "0031_add_auth_api_keys_user_created_index",
+ "breakpoints": true
+ },
+ {
+ "idx": 32,
+ "version": "5",
+ "when": 1782850635482,
+ "tag": "0032_past_lester",
+ "breakpoints": true
+ },
+ {
+ "idx": 33,
+ "version": "5",
+ "when": 1782853232527,
+ "tag": "0033_fluffy_gamora",
+ "breakpoints": true
+ },
+ {
+ "idx": 34,
+ "version": "5",
+ "when": 1782854064505,
+ "tag": "0034_fluffy_falcon",
+ "breakpoints": true
+ },
+ {
+ "idx": 35,
+ "version": "5",
+ "when": 1784393810560,
+ "tag": "0035_agent_api",
+ "breakpoints": true
+ },
+ {
+ "idx": 36,
+ "version": "5",
+ "when": 1784402487715,
+ "tag": "0036_premium_master_chief",
+ "breakpoints": true
+ },
+ {
+ "idx": 37,
+ "version": "5",
+ "when": 1785452585612,
+ "tag": "0037_integration_installations",
+ "breakpoints": true
+ },
+ {
+ "idx": 38,
+ "version": "5",
+ "when": 1785892430217,
+ "tag": "0038_mean_ozymandias",
+ "breakpoints": true
+ },
+ {
+ "idx": 39,
+ "version": "5",
+ "when": 1787096888361,
+ "tag": "0039_magical_vermin",
+ "breakpoints": true
+ },
+ {
+ "idx": 40,
+ "version": "5",
+ "when": 1788366318415,
+ "tag": "0040_recording_jobs",
+ "breakpoints": true
+ },
+ {
+ "idx": 41,
+ "version": "5",
+ "when": 1788384021541,
+ "tag": "0041_saml_sso",
+ "breakpoints": true
+ },
+ {
+ "idx": 42,
+ "version": "5",
+ "when": 1788437051131,
+ "tag": "0042_perf_indexes",
+ "breakpoints": true
+ },
+ {
+ "idx": 43,
+ "version": "5",
+ "when": 1788695529193,
+ "tag": "0043_flimsy_caretaker",
+ "breakpoints": true
+ },
+ {
+ "idx": 44,
+ "version": "5",
+ "when": 1789135858521,
+ "tag": "0044_awesome_sugar_man",
+ "breakpoints": true
+ },
+ {
+ "idx": 45,
+ "version": "5",
+ "when": 1789136779085,
+ "tag": "0045_steep_forge",
+ "breakpoints": true
+ },
+ {
+ "idx": 46,
+ "version": "5",
+ "when": 1790071320615,
+ "tag": "0046_mcp_oauth",
+ "breakpoints": true
+ },
+ {
+ "idx": 47,
+ "version": "5",
+ "when": 1790073320452,
+ "tag": "0047_mcp_client_lifecycle",
+ "breakpoints": true
+ },
+ {
+ "idx": 48,
+ "version": "5",
+ "when": 1790073970527,
+ "tag": "0048_mcp_registration_quota",
+ "breakpoints": true
+ },
+ {
+ "idx": 49,
+ "version": "5",
+ "when": 1790203121737,
+ "tag": "0049_jittery_professor_monster",
+ "breakpoints": true
+ },
+ {
+ "idx": 50,
+ "version": "5",
+ "when": 1791345311370,
+ "tag": "0050_productive_christian_walker",
+ "breakpoints": true
+ }
+ ]
+}
From 605a487234798437925710b87c3f944ed8dee807 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:34:24 +0000
Subject: [PATCH 14/39] fix: limit how many Loom imports one person can start
per hour
Each import checks every link with Loom from our servers, so cap new jobs
at 30 per person per organization each hour.
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 21 +++++++++++++++++++
apps/web/lib/loom-import/jobs.ts | 19 ++++++++++++++++-
2 files changed, 39 insertions(+), 1 deletion(-)
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index 0f5016bd1c8..4b593ed6f9f 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -574,6 +574,27 @@ describe.runIf(Boolean(databaseUrl))(
).toEqual([]);
});
+ it("limits how many imports one person can start in an hour", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: false });
+ const rows = [{ rowNumber: 2, loomUrl: share(LOOM.ok1) }];
+ for (let index = 0; index < 30; index++) {
+ await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: `${index}.csv`,
+ rows,
+ });
+ }
+ await expect(
+ createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "one-more.csv",
+ rows,
+ }),
+ ).rejects.toThrow("a lot of imports");
+ });
+
it("creates a 2,000 video import in one request", async () => {
const { ownerId, orgId } = await makeOrganization({ pro: true });
const rows = Array.from({ length: 2000 }, (_, index) => ({
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
index 7249179c566..d43d516f32e 100644
--- a/apps/web/lib/loom-import/jobs.ts
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -19,7 +19,7 @@ import {
User,
type Video,
} from "@cap/web-domain";
-import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
+import { and, asc, desc, eq, gte, inArray, isNull, sql } from "drizzle-orm";
import { getOrganizationAccess } from "@/actions/organization/authorization";
import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
import { canManageOrganizationSettings } from "@/lib/permissions/roles";
@@ -40,6 +40,7 @@ import {
} from "./loom-api";
const INSERT_CHUNK = 500;
+const LOOM_IMPORT_JOBS_PER_HOUR = 30;
const IN_CHUNK = 500;
export class LoomImportError extends Error {}
@@ -203,6 +204,22 @@ export async function createLoomImportJob({
if (!access)
throw new LoomImportError("You don't have access to this organization.");
+ const [recent] = await db()
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(loomImportJobs)
+ .where(
+ and(
+ eq(loomImportJobs.orgId, orgId),
+ eq(loomImportJobs.createdById, userId),
+ gte(loomImportJobs.createdAt, new Date(Date.now() - 60 * 60 * 1000)),
+ ),
+ );
+ if ((recent?.count ?? 0) >= LOOM_IMPORT_JOBS_PER_HOUR) {
+ throw new LoomImportError(
+ "You've started a lot of imports in the last hour. Try again in a little while.",
+ );
+ }
+
const jobId = nanoId();
const items = planLoomImportItems(jobId, rows, { isAdmin: access.isAdmin });
const now = new Date();
From 95caa402fa13008e89216f37e354c5ca80aaf1c7 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 06:49:09 +0000
Subject: [PATCH 15/39] fix: harden Loom import starts, dispatch locking and
client errors
Resolve storage for the next videos before taking the job lock, retry a
failed workflow start and roll a post-upgrade start back to waiting,
always release the busy state on the job page, guard the create call, and
retry loading the full list if it fails.
Co-authored-by: Richie McIlroy
---
apps/web/actions/loom-import.ts | 40 ++++++++--
.../import/loom/[jobId]/LoomImportJobView.tsx | 76 +++++++++++--------
.../loom/[jobId]/use-loom-import-job.ts | 22 ++++--
.../import/loom/_components/bulk-import.tsx | 24 +++---
apps/web/lib/loom-import/dispatch.ts | 14 ++++
apps/web/lib/loom-import/jobs.ts | 9 +++
apps/web/lib/loom-import/loom-api.ts | 12 ++-
7 files changed, 142 insertions(+), 55 deletions(-)
diff --git a/apps/web/actions/loom-import.ts b/apps/web/actions/loom-import.ts
index 999629875a6..f326d6b925d 100644
--- a/apps/web/actions/loom-import.ts
+++ b/apps/web/actions/loom-import.ts
@@ -11,6 +11,7 @@ import {
LoomImportError,
markLoomImportJobStarting,
resetFailedLoomImportItems,
+ revertLoomImportJobStart,
} from "@/lib/loom-import/jobs";
import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
@@ -20,6 +21,22 @@ type ActionResult> =
const GENERIC_ERROR = "Something went wrong. Please try again.";
+async function startJobWorkflow(jobId: string) {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ await start(loomImportJobWorkflow, [{ jobId }]);
+ return true;
+ } catch (error) {
+ console.error("[loom-import] Could not start import workflow", {
+ jobId,
+ attempt,
+ error,
+ });
+ }
+ }
+ return false;
+}
+
export async function createLoomImportJobAction({
orgId,
fileName,
@@ -32,21 +49,23 @@ export async function createLoomImportJobAction({
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Please sign in again." };
+ let jobId: string;
try {
- const { jobId } = await createLoomImportJob({
+ ({ jobId } = await createLoomImportJob({
userId: user.id,
orgId,
fileName: typeof fileName === "string" ? fileName : "",
rows,
- });
- await start(loomImportJobWorkflow, [{ jobId }]);
- return { ok: true, jobId };
+ }));
} catch (error) {
if (error instanceof LoomImportError)
return { ok: false, error: error.message };
console.error("[loom-import] Could not create import", error);
return { ok: false, error: GENERIC_ERROR };
}
+
+ await startJobWorkflow(jobId);
+ return { ok: true, jobId };
}
export async function startLoomImportJobAction(
@@ -66,9 +85,14 @@ export async function startLoomImportJobAction(
return { ok: false, error: "Importing from Loom needs Cap Pro." };
}
- const started = await markLoomImportJobStarting(jobId);
- if (started) await start(loomImportJobWorkflow, [{ jobId }]);
- return { ok: true, started };
+ if (!(await markLoomImportJobStarting(jobId))) {
+ return { ok: true, started: false };
+ }
+ if (!(await startJobWorkflow(jobId))) {
+ await revertLoomImportJobStart(jobId);
+ return { ok: false, error: "The import couldn't start. Please try again." };
+ }
+ return { ok: true, started: true };
}
export async function cancelLoomImportJobAction(
@@ -96,6 +120,6 @@ export async function retryLoomImportJobAction(
return { ok: false, error: "This import can't be retried." };
}
const retried = await resetFailedLoomImportItems(jobId);
- if (retried > 0) await start(loomImportJobWorkflow, [{ jobId }]);
+ if (retried > 0) await startJobWorkflow(jobId);
return { ok: true, retried };
}
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
index a1456a66f94..9f1b67f6cab 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx
@@ -9,6 +9,7 @@ import { formatDistanceToNowStrict } from "date-fns";
import Link from "next/link";
import {
type ReactNode,
+ useCallback,
useDeferredValue,
useEffect,
useMemo,
@@ -282,41 +283,56 @@ export const LoomImportJobView = ({
[items, filter, deferredQuery],
);
- const runStart = async (automatic: boolean) => {
- setBusy("start");
- const result = await startLoomImportJobAction(job.id);
- if (!result.ok) toast.error(result.error);
- else if (automatic)
- toast.success("You're on Cap Pro. Your import has started.");
- await refresh().catch(() => undefined);
- setBusy(null);
- };
+ const runAction = useCallback(
+ async (kind: "start" | "cancel" | "retry", action: () => Promise) => {
+ setBusy(kind);
+ try {
+ await action();
+ } catch {
+ toast.error("Something went wrong. Please try again.");
+ } finally {
+ await refresh().catch(() => undefined);
+ setBusy(null);
+ }
+ },
+ [refresh],
+ );
+
+ const jobId = job.id;
+ const runStart = useCallback(
+ (automatic: boolean) =>
+ runAction("start", async () => {
+ const result = await startLoomImportJobAction(jobId);
+ if (!result.ok) toast.error(result.error);
+ else if (automatic)
+ toast.success("You're on Cap Pro. Your import has started.");
+ }),
+ [runAction, jobId],
+ );
useEffect(() => {
if (!watchForUpgrade || !job.canStart || autoStarted.current) return;
autoStarted.current = true;
void runStart(true);
- });
-
- const runCancel = async () => {
- setBusy("cancel");
- const result = await cancelLoomImportJobAction(job.id);
- if (!result.ok) toast.error(result.error);
- setCancelOpen(false);
- await refresh().catch(() => undefined);
- setBusy(null);
- };
-
- const runRetry = async () => {
- setBusy("retry");
- const result = await retryLoomImportJobAction(job.id);
- if (!result.ok) toast.error(result.error);
- else if (result.retried === 0) toast("Nothing left to retry.");
- else
- toast.success(`Retrying ${numberFormat.format(result.retried)} videos.`);
- await refresh().catch(() => undefined);
- setBusy(null);
- };
+ }, [watchForUpgrade, job.canStart, runStart]);
+
+ const runCancel = () =>
+ runAction("cancel", async () => {
+ const result = await cancelLoomImportJobAction(jobId);
+ if (!result.ok) toast.error(result.error);
+ setCancelOpen(false);
+ });
+
+ const runRetry = () =>
+ runAction("retry", async () => {
+ const result = await retryLoomImportJobAction(jobId);
+ if (!result.ok) toast.error(result.error);
+ else if (result.retried === 0) toast("Nothing left to retry.");
+ else
+ toast.success(
+ `Retrying ${numberFormat.format(result.retried)} videos.`,
+ );
+ });
const downloadReport = () => {
const csv = buildLoomImportReport(items, window.location.origin);
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index c0a24a7e0fe..1bd68266b59 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -132,14 +132,22 @@ export function useLoomImportJob(
[apply, initial.job.id],
);
- const loadedRest = useRef(initial.full);
useEffect(() => {
- if (loadedRest.current) return;
- loadedRest.current = true;
- fetchSnapshot(true).catch(() => {
- loadedRest.current = false;
- });
- }, [fetchSnapshot]);
+ if (initial.full) return;
+ let stopped = false;
+ let timer: number | undefined;
+ const load = (attempt: number) => {
+ fetchSnapshot(true).catch(() => {
+ if (stopped || attempt >= 4) return;
+ timer = window.setTimeout(() => load(attempt + 1), 1000 * 2 ** attempt);
+ });
+ };
+ load(0);
+ return () => {
+ stopped = true;
+ window.clearTimeout(timer);
+ };
+ }, [initial.full, fetchSnapshot]);
const status = summary.job.status;
const shouldPoll =
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
index f40f22533cf..b2c8b41fed4 100644
--- a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
@@ -400,16 +400,22 @@ const ReviewCard = ({
const start = () => {
startTransition(async () => {
- const result = await createLoomImportJobAction({
- orgId,
- fileName: source.name,
- rows: plan.rows,
- });
- if (!result.ok) {
- toast.error(result.error);
- return;
+ try {
+ const result = await createLoomImportJobAction({
+ orgId,
+ fileName: source.name,
+ rows: plan.rows,
+ });
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ router.push(`/dashboard/import/loom/${result.jobId}`);
+ } catch {
+ toast.error(
+ "We couldn't start this import. Check your connection, or split the CSV into smaller files.",
+ );
}
- router.push(`/dashboard/import/loom/${result.jobId}`);
});
};
diff --git a/apps/web/lib/loom-import/dispatch.ts b/apps/web/lib/loom-import/dispatch.ts
index 9f7f75d3a7e..bff64848d3a 100644
--- a/apps/web/lib/loom-import/dispatch.ts
+++ b/apps/web/lib/loom-import/dispatch.ts
@@ -109,6 +109,20 @@ export async function dispatchLoomImportJob(jobId: string) {
const limit = loomImportConcurrency();
const isPublic = await getNewVideoPublic(job.orgId);
const storage = new Map();
+ const upcoming = await db()
+ .select({ ownerId: loomImportJobItems.ownerId })
+ .from(loomImportJobItems)
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "ready"),
+ ),
+ )
+ .orderBy(asc(loomImportJobItems.rowNumber))
+ .limit(limit * 2);
+ for (const ownerId of new Set(upcoming.map((row) => row.ownerId))) {
+ if (ownerId) await writableFor(storage, ownerId, job.orgId);
+ }
const outcome = await db().transaction(async (tx) => {
const [locked] = await tx
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
index d43d516f32e..f809b6980a1 100644
--- a/apps/web/lib/loom-import/jobs.ts
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -785,6 +785,15 @@ export async function markLoomImportJobStarting(jobId: string) {
return affectedRows(result) > 0;
}
+export async function revertLoomImportJobStart(jobId: string) {
+ await db()
+ .update(loomImportJobs)
+ .set({ status: "awaiting_upgrade", updatedAt: new Date() })
+ .where(
+ and(eq(loomImportJobs.id, jobId), eq(loomImportJobs.status, "checking")),
+ );
+}
+
export async function cancelLoomImportJob(jobId: string) {
await db().transaction(async (tx) => {
const [job] = await tx
diff --git a/apps/web/lib/loom-import/loom-api.ts b/apps/web/lib/loom-import/loom-api.ts
index 4475a0b2a81..ae962c4b83b 100644
--- a/apps/web/lib/loom-import/loom-api.ts
+++ b/apps/web/lib/loom-import/loom-api.ts
@@ -129,9 +129,19 @@ async function lookupBatch(
return results;
}
} else if (response.status < 500 && response.status !== 429) {
+ console.warn("[loom-import] Loom lookup rejected", {
+ status: response.status,
+ videos: ids.length,
+ });
break;
}
- } catch {}
+ } catch (error) {
+ console.warn("[loom-import] Loom lookup failed", {
+ attempt,
+ videos: ids.length,
+ error,
+ });
+ }
if (attempt < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 400 * 2 ** attempt));
}
From ed48f7fe559eb2734c377333855b859c6a66c691 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 07:23:42 +0000
Subject: [PATCH 16/39] fix: claim stuck Loom import recoveries and settle rows
finished outside the job
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 142 +++++++++++++++++-
apps/web/lib/loom-import/dispatch.ts | 88 +++++++++--
apps/web/lib/loom-import/jobs.ts | 16 ++
apps/web/lib/loom-import/recovery.ts | 66 ++++++--
apps/web/lib/loom-import/status.ts | 61 ++++++--
5 files changed, 328 insertions(+), 45 deletions(-)
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index 4b593ed6f9f..85da7495d84 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -13,7 +13,7 @@ import {
videoUploads,
} from "@cap/database/schema";
import { Organisation, User, type Video } from "@cap/web-domain";
-import { and, asc, eq } from "drizzle-orm";
+import { and, asc, eq, inArray, notInArray } from "drizzle-orm";
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
import { Effect, Option } from "effect";
import { createPool, type Pool } from "mysql2/promise";
@@ -86,6 +86,7 @@ import {
resetFailedLoomImportItems,
resolveLoomImportJob,
} from "@/lib/loom-import/jobs";
+import { recoverLoomImportJobs } from "@/lib/loom-import/recovery";
import {
getLoomImportSnapshot,
LOOM_IMPORT_CURSOR_OVERLAP_MS,
@@ -550,6 +551,145 @@ describe.runIf(Boolean(databaseUrl))(
started: 0,
completed: false,
});
+
+ await finishVideo(state[1]?.videoId as Video.VideoId);
+ expect((await items(second.jobId))[1]).toMatchObject({
+ status: "complete",
+ error: null,
+ });
+ expect(await jobStatus(second.jobId)).toBe("cancelled");
+ });
+
+ it("marks a failed video imported once a retry from the video page finishes, even after the import ended", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: true });
+ const { jobId } = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "retry.csv",
+ rows: [{ rowNumber: 2, loomUrl: share(LOOM.ok1) }],
+ });
+ await resolveLoomImportJob(jobId, { fetchImpl: loomFetch });
+ await prepareLoomImportJob(jobId);
+ await dispatchLoomImportJob(jobId);
+ const videoId = (await items(jobId))[0]?.videoId as Video.VideoId;
+ await failVideo(videoId, "Media server unavailable");
+ expect(await jobStatus(jobId)).toBe("completed");
+ expect((await items(jobId))[0]).toMatchObject({
+ status: "failed",
+ error: "Media server unavailable",
+ });
+
+ await database()
+ .update(videoUploads)
+ .set({
+ phase: "processing",
+ processingError: null,
+ processingMessage: "Retrying Loom import...",
+ })
+ .where(eq(videoUploads.videoId, videoId));
+ await dispatchLoomImportForVideo(videoId);
+ expect((await items(jobId))[0]?.status).toBe("failed");
+ expect(
+ (await getLoomImportSnapshot({ jobId, userId: ownerId }))?.counts
+ .importing,
+ ).toBe(1);
+
+ await finishVideo(videoId);
+ expect((await items(jobId))[0]).toMatchObject({
+ status: "complete",
+ error: null,
+ videoId,
+ });
+ const finished = await getLoomImportSnapshot({ jobId, userId: ownerId });
+ expect(finished?.counts).toMatchObject({ imported: 1, failed: 0 });
+ expect(finished?.items[0]).toMatchObject({ status: "imported", videoId });
+ expect(await jobStatus(jobId)).toBe("completed");
+ const [summary] = await listLoomImportJobs({ userId: ownerId, orgId });
+ expect(summary).toMatchObject({ id: jobId, imported: 1, failed: 0 });
+
+ await database()
+ .update(loomImportJobItems)
+ .set({ status: "failed", error: "Media server unavailable" })
+ .where(eq(loomImportJobItems.jobId, jobId));
+ expect(await resetFailedLoomImportItems(jobId)).toBe(0);
+ expect((await items(jobId))[0]).toMatchObject({
+ status: "complete",
+ error: null,
+ });
+ expect(await jobStatus(jobId)).toBe("completed");
+ });
+
+ it("restarts a stuck import only once when two recovery runs overlap", async () => {
+ const { ownerId, orgId } = await makeOrganization({ pro: true });
+ const stuck = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "stuck.csv",
+ rows: [{ rowNumber: 2, loomUrl: share(LOOM.ok1) }],
+ });
+ await resolveLoomImportJob(stuck.jobId, { fetchImpl: loomFetch });
+ expect(await prepareLoomImportJob(stuck.jobId)).toBe("importing");
+ await dispatchLoomImportJob(stuck.jobId);
+ const videoId = (await items(stuck.jobId))[0]?.videoId as Video.VideoId;
+ const checking = await createLoomImportJob({
+ userId: ownerId,
+ orgId,
+ fileName: "checking.csv",
+ rows: [{ rowNumber: 2, loomUrl: share(LOOM.ok2) }],
+ });
+ const ours = [stuck.jobId, checking.jobId];
+
+ await database()
+ .update(loomImportJobs)
+ .set({ status: "cancelled" })
+ .where(
+ and(
+ inArray(loomImportJobs.status, ["checking", "importing"]),
+ notInArray(loomImportJobs.id, ours),
+ ),
+ );
+ const longAgo = new Date(Date.now() - 60 * 60 * 1000);
+ await database()
+ .update(loomImportJobs)
+ .set({ updatedAt: longAgo })
+ .where(inArray(loomImportJobs.id, ours));
+ await database()
+ .update(videoUploads)
+ .set({ updatedAt: longAgo })
+ .where(eq(videoUploads.videoId, videoId));
+ fixture.start.mockClear();
+
+ const runs = await Promise.all([
+ recoverLoomImportJobs(),
+ recoverLoomImportJobs(),
+ ]);
+
+ const startsFor = (workflow: string, key: string, value: string) =>
+ fixture.start.mock.calls.filter(
+ ([started, [payload]]) =>
+ started === workflow &&
+ (payload as Record)[key] === value,
+ );
+ expect(
+ startsFor("importLoomVideoWorkflow", "videoId", videoId),
+ ).toHaveLength(1);
+ expect(
+ startsFor("loomImportJobWorkflow", "jobId", checking.jobId),
+ ).toHaveLength(1);
+ expect(runs.reduce((sum, run) => sum + run.restarted, 0)).toBe(1);
+ expect(runs.reduce((sum, run) => sum + run.resumed, 0)).toBe(1);
+ const [upload] = await database()
+ .select()
+ .from(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ expect(upload).toMatchObject({
+ phase: "uploading",
+ processingMessage: "Retrying Loom import...",
+ });
+
+ fixture.start.mockClear();
+ await recoverLoomImportJobs();
+ expect(fixture.start).not.toHaveBeenCalled();
});
it("rejects files over the 2,000 video limit without creating anything", async () => {
diff --git a/apps/web/lib/loom-import/dispatch.ts b/apps/web/lib/loom-import/dispatch.ts
index bff64848d3a..4c14f863717 100644
--- a/apps/web/lib/loom-import/dispatch.ts
+++ b/apps/web/lib/loom-import/dispatch.ts
@@ -17,11 +17,11 @@ import { Option } from "effect";
import { start } from "workflow/api";
import { runWorkflowPromise } from "@/lib/workflow-runtime";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
+import { settleLoomImportItem } from "./status";
const DEFAULT_CONCURRENCY = 4;
const MAX_CONCURRENCY = 32;
const WAITING_FOR_CAPACITY = "Queued for Loom import";
-const DELETED_VIDEO_ERROR = "The imported Cap was deleted.";
type VideoInsert = typeof videos.$inferInsert;
@@ -163,16 +163,19 @@ export async function dispatchLoomImportJob(jobId: string) {
let active = 0;
let waiting = 0;
for (const row of inFlight) {
- if (!row.videoId || !row.videoExists) {
- failures.set(DELETED_VIDEO_ERROR, [
- ...(failures.get(DELETED_VIDEO_ERROR) ?? []),
+ const settled = settleLoomImportItem({
+ status: "importing",
+ videoExists: Boolean(row.videoId && row.videoExists),
+ uploadPhase: row.uploadVideoId ? row.uploadPhase : null,
+ uploadError: row.uploadError,
+ });
+ if (settled?.status === "complete") {
+ completedIds.push(row.id);
+ } else if (settled) {
+ failures.set(settled.error, [
+ ...(failures.get(settled.error) ?? []),
row.id,
]);
- } else if (!row.uploadVideoId) {
- completedIds.push(row.id);
- } else if (row.uploadPhase === "error") {
- const error = (row.uploadError || "Loom import failed.").slice(0, 512);
- failures.set(error, [...(failures.get(error) ?? []), row.id]);
} else {
active++;
if (row.uploadMessage?.startsWith(WAITING_FOR_CAPACITY)) waiting++;
@@ -464,18 +467,71 @@ export async function dispatchLoomImportJob(jobId: string) {
}
export async function dispatchLoomImportForVideo(videoId: string) {
- const [item] = await db()
- .select({ jobId: loomImportJobItems.jobId })
+ const items = await db()
+ .select({
+ id: loomImportJobItems.id,
+ jobId: loomImportJobItems.jobId,
+ status: loomImportJobItems.status,
+ error: loomImportJobItems.error,
+ jobStatus: loomImportJobs.status,
+ videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
+ uploadVideoId: videoUploads.videoId,
+ uploadPhase: videoUploads.phase,
+ uploadError: videoUploads.processingError,
+ })
.from(loomImportJobItems)
+ .innerJoin(loomImportJobs, eq(loomImportJobs.id, loomImportJobItems.jobId))
+ .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .leftJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
.where(
and(
eq(loomImportJobItems.videoId, Video.VideoId.make(videoId)),
- eq(loomImportJobItems.status, "importing"),
+ inArray(loomImportJobItems.status, ["importing", "failed", "complete"]),
),
- )
- .limit(1);
- if (!item) return null;
- return dispatchLoomImportJob(item.jobId);
+ );
+ if (items.length === 0) return null;
+
+ const jobsToDispatch = new Set();
+ for (const item of items) {
+ if (item.jobStatus === "importing" && item.status === "importing") {
+ jobsToDispatch.add(item.jobId);
+ continue;
+ }
+ const settled = settleLoomImportItem({
+ status: item.status,
+ videoExists: Boolean(item.videoExists),
+ uploadPhase: item.uploadVideoId ? item.uploadPhase : null,
+ uploadError: item.uploadError,
+ });
+ if (
+ !settled ||
+ (settled.status === item.status && settled.error === item.error)
+ ) {
+ continue;
+ }
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ status: settled.status,
+ error: settled.error,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomImportJobItems.id, item.id),
+ eq(loomImportJobItems.status, item.status),
+ ),
+ );
+ }
+
+ let outcome: { started: number; completed: boolean } | null = null;
+ for (const jobId of jobsToDispatch) {
+ outcome = await dispatchLoomImportJob(jobId);
+ }
+ return outcome;
}
export async function isLoomImportedVideo(videoId: Video.VideoId) {
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
index f809b6980a1..7a373764174 100644
--- a/apps/web/lib/loom-import/jobs.ts
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -824,6 +824,7 @@ export async function resetFailedLoomImportItems(jobId: string) {
const failed = await db()
.select({
id: loomImportJobItems.id,
+ status: loomImportJobItems.status,
videoId: loomImportJobItems.videoId,
loomVideoId: loomImportJobItems.loomVideoId,
videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
@@ -845,16 +846,31 @@ export async function resetFailedLoomImportItems(jobId: string) {
const toPending: string[] = [];
const toReady: string[] = [];
+ const toComplete: string[] = [];
for (const item of failed) {
if (!item.loomVideoId) continue;
if (!item.videoId || !item.videoExists) {
toPending.push(item.id);
+ } else if (item.status === "complete") {
} else if (item.uploadPhase === "error") {
toReady.push(item.id);
+ } else if (item.status === "failed" && !item.uploadVideoId) {
+ toComplete.push(item.id);
}
}
const now = new Date();
+ for (const ids of chunk(toComplete, IN_CHUNK)) {
+ await db()
+ .update(loomImportJobItems)
+ .set({ status: "complete", error: null, updatedAt: now })
+ .where(
+ and(
+ inArray(loomImportJobItems.id, ids),
+ eq(loomImportJobItems.status, "failed"),
+ ),
+ );
+ }
for (const ids of chunk(toPending, IN_CHUNK)) {
await db()
.update(loomImportJobItems)
diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts
index a640491ed4a..7180e91e32d 100644
--- a/apps/web/lib/loom-import/recovery.ts
+++ b/apps/web/lib/loom-import/recovery.ts
@@ -10,6 +10,7 @@ import { start } from "workflow/api";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
import { dispatchLoomImportJob, rawFileKeyFor } from "./dispatch";
+import { affectedRows } from "./jobs";
const STALE_CHECKING_MS = 10 * 60 * 1000;
const STALE_IMPORTING_MS = 2 * 60 * 1000;
@@ -17,7 +18,7 @@ const STUCK_START_MS = 30 * 60 * 1000;
export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
const staleChecking = await db()
- .select({ id: loomImportJobs.id })
+ .select({ id: loomImportJobs.id, updatedAt: loomImportJobs.updatedAt })
.from(loomImportJobs)
.where(
and(
@@ -29,12 +30,29 @@ export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
),
)
.limit(limit);
+
+ let resumed = 0;
for (const job of staleChecking) {
- await db()
+ const claim = await db()
.update(loomImportJobs)
.set({ updatedAt: now })
- .where(eq(loomImportJobs.id, job.id));
- await start(loomImportJobWorkflow, [{ jobId: job.id }]);
+ .where(
+ and(
+ eq(loomImportJobs.id, job.id),
+ eq(loomImportJobs.status, "checking"),
+ eq(loomImportJobs.updatedAt, job.updatedAt),
+ ),
+ );
+ if (affectedRows(claim) !== 1) continue;
+ try {
+ await start(loomImportJobWorkflow, [{ jobId: job.id }]);
+ resumed++;
+ } catch (error) {
+ console.error("[loom-import] Could not resume import job", {
+ jobId: job.id,
+ error,
+ });
+ }
}
const staleImporting = await db()
@@ -60,6 +78,7 @@ export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
bucket: videos.bucket,
loomVideoId: loomImportJobItems.loomVideoId,
rawFileKey: videoUploads.rawFileKey,
+ uploadUpdatedAt: videoUploads.updatedAt,
})
.from(loomImportJobItems)
.innerJoin(videos, eq(videos.id, loomImportJobItems.videoId))
@@ -79,31 +98,46 @@ export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
if (!row.loomVideoId) continue;
const rawFileKey =
row.rawFileKey ?? rawFileKeyFor(row.ownerId, row.videoId);
- await db()
+ const claim = await db()
.update(videoUploads)
.set({
processingMessage: "Retrying Loom import...",
rawFileKey,
updatedAt: now,
})
- .where(eq(videoUploads.videoId, row.videoId));
- await start(importLoomVideoWorkflow, [
- {
+ .where(
+ and(
+ eq(videoUploads.videoId, row.videoId),
+ eq(videoUploads.phase, "uploading"),
+ eq(videoUploads.updatedAt, row.uploadUpdatedAt),
+ ),
+ );
+ if (affectedRows(claim) !== 1) continue;
+ try {
+ await start(importLoomVideoWorkflow, [
+ {
+ videoId: row.videoId,
+ userId: row.ownerId,
+ rawFileKey,
+ bucketId: row.bucket,
+ loomVideoId: row.loomVideoId,
+ reuseExistingRawUpload: true,
+ },
+ ]);
+ restarted++;
+ } catch (error) {
+ console.error("[loom-import] Could not restart stuck import", {
videoId: row.videoId,
- userId: row.ownerId,
- rawFileKey,
- bucketId: row.bucket,
- loomVideoId: row.loomVideoId,
- reuseExistingRawUpload: true,
- },
- ]);
- restarted++;
+ error,
+ });
+ }
}
await dispatchLoomImportJob(job.id);
}
return {
checking: staleChecking.length,
+ resumed,
importing: staleImporting.length,
restarted,
};
diff --git a/apps/web/lib/loom-import/status.ts b/apps/web/lib/loom-import/status.ts
index 6d26126bf52..3fa33815b63 100644
--- a/apps/web/lib/loom-import/status.ts
+++ b/apps/web/lib/loom-import/status.ts
@@ -91,7 +91,45 @@ export type LoomImportSnapshot = {
export const LOOM_IMPORT_TERMINAL_STATUSES: ReadonlySet =
new Set(["imported", "failed", "skipped", "cancelled"]);
-const DELETED_VIDEO_ERROR = "The imported Cap was deleted.";
+export const LOOM_IMPORT_DELETED_VIDEO_ERROR = "The imported Cap was deleted.";
+
+export function loomImportWaitingStatus(
+ jobStatus: LoomImportJobStatus,
+): "ready" | "queued" {
+ return jobStatus === "awaiting_upgrade" || jobStatus === "checking"
+ ? "ready"
+ : "queued";
+}
+
+export type LoomImportSettleSource = {
+ status: LoomImportItemStatus;
+ videoExists: boolean;
+ uploadPhase: string | null;
+ uploadError: string | null;
+};
+
+export type LoomImportSettlement =
+ | { status: "complete"; error: null }
+ | { status: "failed"; error: string };
+
+export function settleLoomImportItem(
+ source: LoomImportSettleSource,
+): LoomImportSettlement | null {
+ if (!source.videoExists) {
+ return { status: "failed", error: LOOM_IMPORT_DELETED_VIDEO_ERROR };
+ }
+ if (source.status === "complete") return null;
+ if (source.uploadPhase === null || source.uploadPhase === "complete") {
+ return { status: "complete", error: null };
+ }
+ if (source.uploadPhase === "error") {
+ return {
+ status: "failed",
+ error: (source.uploadError || "Loom import failed.").slice(0, 512),
+ };
+ }
+ return null;
+}
function importStage(
phase: string | null,
@@ -113,12 +151,7 @@ export function deriveLoomImportItemState(
case "pending":
return { status: "checking" };
case "ready":
- return {
- status:
- jobStatus === "awaiting_upgrade" || jobStatus === "checking"
- ? "ready"
- : "queued",
- };
+ return { status: loomImportWaitingStatus(jobStatus) };
case "skipped":
return { status: "skipped", error: source.error ?? undefined };
case "cancelled":
@@ -135,13 +168,17 @@ export function deriveLoomImportItemState(
}
if (!source.videoExists) {
- return { status: "failed", error: source.error ?? DELETED_VIDEO_ERROR };
+ return {
+ status: "failed",
+ error: source.error ?? LOOM_IMPORT_DELETED_VIDEO_ERROR,
+ };
}
- if (source.uploadPhase === null) {
- if (source.status === "failed" && source.error) {
- return { status: "failed", error: source.error };
- }
+ if (
+ source.status === "complete" ||
+ source.uploadPhase === null ||
+ source.uploadPhase === "complete"
+ ) {
return { status: "imported" };
}
From cfecf47fd57d0fec914bf6297015a35113b3c09e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 07:23:42 +0000
Subject: [PATCH 17/39] fix: keep the Loom import job page in order and in sync
with the job
Co-authored-by: Richie McIlroy
---
.../unit/loom-import-job-view.test.ts | 296 ++++++++++++++++++
.../__tests__/unit/loom-import-status.test.ts | 122 +++++++-
.../import/loom/[jobId]/import-list.tsx | 78 ++---
.../loom/[jobId]/use-loom-import-job.ts | 52 ++-
4 files changed, 497 insertions(+), 51 deletions(-)
create mode 100644 apps/web/__tests__/unit/loom-import-job-view.test.ts
diff --git a/apps/web/__tests__/unit/loom-import-job-view.test.ts b/apps/web/__tests__/unit/loom-import-job-view.test.ts
new file mode 100644
index 00000000000..78cfea1a32d
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-import-job-view.test.ts
@@ -0,0 +1,296 @@
+// @vitest-environment jsdom
+
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) =>
+ React.createElement("a", props, children),
+}));
+
+import {
+ ROW_HEIGHT,
+ VirtualImportList,
+} from "@/app/(org)/dashboard/import/loom/[jobId]/import-list";
+import { useLoomImportJob } from "@/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job";
+import {
+ countLoomImportItems,
+ type LoomImportItemView,
+ type LoomImportJobStatus,
+ type LoomImportSnapshot,
+} from "@/lib/loom-import/status";
+
+const item = (index: number, overrides: Partial = {}) =>
+ ({
+ id: `item-${index}`,
+ row: index + 2,
+ url: `https://www.loom.com/share/${index}`,
+ loomId: String(index),
+ title: `Video ${index}`,
+ email: null,
+ space: null,
+ status: "queued",
+ videoId: null,
+ recordedAt: null,
+ duration: null,
+ thumb: null,
+ v: 1,
+ ...overrides,
+ }) satisfies LoomImportItemView;
+
+const snapshot = ({
+ items,
+ full,
+ cursor,
+ status = "importing",
+}: {
+ items: LoomImportItemView[];
+ full: boolean;
+ cursor: number;
+ status?: LoomImportJobStatus;
+}): LoomImportSnapshot => ({
+ job: {
+ id: "job-1",
+ fileName: "loom.csv",
+ status,
+ totalCount: 3,
+ createdAt: "2026-10-07T00:00:00.000Z",
+ startedAt: null,
+ completedAt: null,
+ createdByMe: true,
+ canStart: false,
+ isPro: true,
+ isAdmin: true,
+ },
+ counts: countLoomImportItems(items),
+ totalDuration: 0,
+ importedDuration: 0,
+ owners: 1,
+ items,
+ cursor,
+ full,
+});
+
+type Pending = {
+ url: string;
+ resolve: (body: LoomImportSnapshot) => void;
+};
+
+let root: Root;
+let container: HTMLDivElement;
+let requests: Pending[];
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ requests = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ (url: string) =>
+ new Promise((resolve) => {
+ requests.push({
+ url,
+ resolve: (body) =>
+ resolve({
+ ok: true,
+ status: 200,
+ json: async () => body,
+ } as Response),
+ });
+ }),
+ ),
+ );
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+async function flush() {
+ await act(async () => {
+ for (let index = 0; index < 10; index++) await Promise.resolve();
+ });
+}
+
+describe("useLoomImportJob", () => {
+ it("waits for the full load before polling and applies answers in order", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ let latest: ReturnType | undefined;
+ const Harness = ({ initial }: { initial: LoomImportSnapshot }) => {
+ latest = useLoomImportJob(initial, { watchForUpgrade: false });
+ return null;
+ };
+
+ await act(async () => {
+ root.render(
+ React.createElement(Harness, {
+ initial: snapshot({
+ items: [item(0, { status: "ready" })],
+ full: false,
+ cursor: 1_000,
+ status: "checking",
+ }),
+ }),
+ );
+ });
+ expect(requests.map((request) => request.url)).toEqual([
+ "/api/import/loom/jobs?jobId=job-1",
+ ]);
+
+ await act(async () => {
+ vi.advanceTimersByTime(5_000);
+ });
+ expect(requests).toHaveLength(1);
+
+ requests[0]?.resolve(
+ snapshot({
+ items: [
+ item(0, { status: "ready" }),
+ item(1, { status: "ready" }),
+ item(2, { status: "failed" }),
+ ],
+ full: true,
+ cursor: 2_000,
+ status: "checking",
+ }),
+ );
+ await flush();
+ expect(latest?.items.map((row) => row.status)).toEqual([
+ "ready",
+ "ready",
+ "failed",
+ ]);
+
+ await act(async () => {
+ vi.advanceTimersByTime(1_500);
+ });
+ expect(requests[1]?.url).toBe(
+ "/api/import/loom/jobs?jobId=job-1&since=2000",
+ );
+ const refresh = latest?.refresh();
+ await flush();
+ expect(requests).toHaveLength(2);
+
+ requests[1]?.resolve(
+ snapshot({
+ items: [item(0, { status: "importing", progress: 20, v: 2 })],
+ full: false,
+ cursor: 3_000,
+ }),
+ );
+ await flush();
+ expect(latest?.summary.job.status).toBe("importing");
+ expect(latest?.items.map((row) => row.status)).toEqual([
+ "importing",
+ "queued",
+ "failed",
+ ]);
+ expect(requests[2]?.url).toBe("/api/import/loom/jobs?jobId=job-1");
+
+ requests[2]?.resolve(
+ snapshot({
+ items: [
+ item(0, { status: "imported", v: 3 }),
+ item(1, { status: "importing", v: 3 }),
+ item(2, { status: "failed" }),
+ ],
+ full: true,
+ cursor: 4_000,
+ }),
+ );
+ await act(async () => {
+ await refresh;
+ });
+ expect(latest?.items.map((row) => row.status)).toEqual([
+ "imported",
+ "importing",
+ "failed",
+ ]);
+ });
+});
+
+describe("VirtualImportList", () => {
+ const rows = Array.from({ length: 200 }, (_, index) => item(index));
+ const nextFrame = (callback: FrameRequestCallback) => {
+ queueMicrotask(() => callback(0));
+ return 1;
+ };
+ const render = async (items: LoomImportItemView[]) => {
+ await act(async () => {
+ root.render(
+ React.createElement(VirtualImportList, {
+ items,
+ viewport: 640,
+ showOwner: false,
+ empty: "No videos match",
+ }),
+ );
+ });
+ };
+ const firstRenderedRow = () =>
+ container.querySelector("li")?.getAttribute("aria-posinset");
+
+ it("shows the top of the list again after a search with no matches is cleared", async () => {
+ vi.stubGlobal("requestAnimationFrame", nextFrame);
+ await render(rows);
+ const list = container.querySelector(
+ "[data-testid=loom-import-list]",
+ );
+ if (!list) throw new Error("Missing list");
+ await act(async () => {
+ list.scrollTop = 120 * ROW_HEIGHT;
+ list.dispatchEvent(new Event("scroll"));
+ });
+ expect(Number(firstRenderedRow())).toBeGreaterThan(100);
+
+ await render([]);
+ expect(container.textContent).toContain("No videos match");
+
+ await render(rows);
+ expect(firstRenderedRow()).toBe("1");
+ });
+
+ it("keeps rows on screen when the browser clamps the scroll after a search shrinks the list", async () => {
+ vi.stubGlobal("requestAnimationFrame", nextFrame);
+ await render(rows);
+ const list = container.querySelector(
+ "[data-testid=loom-import-list]",
+ );
+ if (!list) throw new Error("Missing list");
+ let requested = 0;
+ Object.defineProperty(list, "scrollTop", {
+ configurable: true,
+ get: () => {
+ const content = Number.parseFloat(
+ list.querySelector("ul")?.style.height ?? "0",
+ );
+ return Math.min(requested, Math.max(0, content - 640));
+ },
+ set: (value: number) => {
+ requested = value;
+ },
+ });
+ await act(async () => {
+ list.scrollTop = 150 * ROW_HEIGHT;
+ list.dispatchEvent(new Event("scroll"));
+ });
+ expect(Number(firstRenderedRow())).toBeGreaterThan(130);
+
+ await render(rows.slice(0, 20));
+ const rendered = Array.from(container.querySelectorAll("li")).map((li) =>
+ Number(li.getAttribute("aria-posinset")),
+ );
+ expect(rendered.length).toBeGreaterThan(0);
+ expect(Math.max(...rendered)).toBe(20);
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-import-status.test.ts b/apps/web/__tests__/unit/loom-import-status.test.ts
index d5f438ece88..67f318930c7 100644
--- a/apps/web/__tests__/unit/loom-import-status.test.ts
+++ b/apps/web/__tests__/unit/loom-import-status.test.ts
@@ -11,8 +11,12 @@ import {
deriveLoomImportItemState,
type LoomImportItemSource,
type LoomImportItemView,
+ type LoomImportJobStatus,
+ type LoomImportSettleSource,
type LoomImportSnapshot,
loomImportProgress,
+ loomImportWaitingStatus,
+ settleLoomImportItem,
} from "@/lib/loom-import/status";
const source = (
@@ -103,6 +107,75 @@ describe("deriveLoomImportItemState", () => {
).status,
).toBe("importing");
});
+
+ it("shows a failed row as imported once a retry from the video page finishes", () => {
+ expect(
+ deriveLoomImportItemState(
+ source({ status: "failed", error: "Old failure", uploadPhase: null }),
+ "completed",
+ ),
+ ).toEqual({ status: "imported" });
+ });
+
+ it("keeps finished imports imported while the Cap is edited later", () => {
+ expect(
+ deriveLoomImportItemState(
+ source({ status: "complete", uploadPhase: "processing" }),
+ "completed",
+ ),
+ ).toEqual({ status: "imported" });
+ });
+});
+
+describe("settleLoomImportItem", () => {
+ const settle = (overrides: Partial) =>
+ settleLoomImportItem({
+ status: "importing",
+ videoExists: true,
+ uploadPhase: "processing",
+ uploadError: null,
+ ...overrides,
+ });
+
+ it("completes rows whose upload finished, including ones that failed before", () => {
+ expect(settle({ uploadPhase: null })).toEqual({
+ status: "complete",
+ error: null,
+ });
+ expect(settle({ status: "failed", uploadPhase: null })).toEqual({
+ status: "complete",
+ error: null,
+ });
+ });
+
+ it("fails rows whose upload errored or whose Cap was deleted", () => {
+ expect(
+ settle({ uploadPhase: "error", uploadError: "Source unavailable" }),
+ ).toEqual({ status: "failed", error: "Source unavailable" });
+ expect(settle({ uploadPhase: "error" })).toEqual({
+ status: "failed",
+ error: "Loom import failed.",
+ });
+ expect(settle({ status: "complete", videoExists: false })).toEqual({
+ status: "failed",
+ error: "The imported Cap was deleted.",
+ });
+ });
+
+ it("leaves rows alone while they are still copying or already done", () => {
+ expect(settle({})).toBeNull();
+ expect(settle({ status: "failed", uploadPhase: "processing" })).toBeNull();
+ expect(settle({ status: "complete", uploadPhase: "error" })).toBeNull();
+ });
+});
+
+describe("loomImportWaitingStatus", () => {
+ it("calls checked rows ready until the import starts", () => {
+ expect(loomImportWaitingStatus("checking")).toBe("ready");
+ expect(loomImportWaitingStatus("awaiting_upgrade")).toBe("ready");
+ expect(loomImportWaitingStatus("importing")).toBe("queued");
+ expect(loomImportWaitingStatus("completed")).toBe("queued");
+ });
});
describe("progress", () => {
@@ -255,8 +328,53 @@ describe("results report", () => {
});
describe("mergeLoomImportItems", () => {
- const snapshot = (items: LoomImportItemView[], full: boolean) =>
- ({ items, full, cursor: 0 }) as unknown as LoomImportSnapshot;
+ const snapshot = (
+ items: LoomImportItemView[],
+ full: boolean,
+ status: LoomImportJobStatus = "importing",
+ ) => ({ items, full, cursor: 0, job: { status } }) as LoomImportSnapshot;
+
+ it("moves rows the poll skipped from ready to queued when the import starts", () => {
+ const map = new Map();
+ const first = mergeLoomImportItems(
+ map,
+ [],
+ snapshot(
+ [
+ view({ id: "a", row: 2, status: "ready" }),
+ view({ id: "b", row: 3, status: "ready" }),
+ view({ id: "c", row: 4, status: "failed" }),
+ ],
+ true,
+ "checking",
+ ),
+ "checking",
+ );
+ const started = mergeLoomImportItems(
+ map,
+ first.order,
+ snapshot(
+ [view({ id: "a", row: 2, status: "importing", v: 2 })],
+ false,
+ "importing",
+ ),
+ "checking",
+ );
+ expect(started.changed).toBe(true);
+ expect(first.order.map((id) => map.get(id)?.status)).toEqual([
+ "importing",
+ "queued",
+ "failed",
+ ]);
+
+ const steady = mergeLoomImportItems(
+ map,
+ first.order,
+ snapshot([], false, "importing"),
+ "importing",
+ );
+ expect(steady.changed).toBe(false);
+ });
it("replaces only rows that changed and keeps CSV order", () => {
const map = new Map();
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
index 91aef87f0d5..69c68f2f06a 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx
@@ -7,6 +7,7 @@ import {
type ReactNode,
useCallback,
useEffect,
+ useLayoutEffect,
useRef,
useState,
} from "react";
@@ -71,14 +72,11 @@ export function useVirtualWindow(count: number, viewport: number) {
[],
);
- useEffect(() => {
+ useLayoutEffect(() => {
const element = ref.current;
- if (!element) return;
const max = Math.max(0, count * ROW_HEIGHT - viewport);
- if (element.scrollTop > max) {
- element.scrollTop = max;
- setScrollTop(max);
- }
+ if (element && element.scrollTop > max) element.scrollTop = max;
+ setScrollTop(element?.scrollTop ?? 0);
}, [count, viewport]);
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
@@ -278,47 +276,49 @@ export const VirtualImportList = ({
items.length,
viewport,
);
- const height = Math.min(viewport, Math.max(items.length, 1) * ROW_HEIGHT);
-
- if (items.length === 0) {
- return (
-
- {empty}
-
- );
- }
+ const isEmpty = items.length === 0;
return (
-
- {items.slice(start, end).map((item, index) => (
-
-
-
- ))}
-
+ {isEmpty ? (
+
+ {empty}
+
+ ) : (
+
+ {items.slice(start, end).map((item, index) => (
+
+
+
+ ))}
+
+ )}
);
};
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index 1bd68266b59..3f388c7c6cb 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -4,12 +4,15 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
isLoomImportJobActive,
type LoomImportItemView,
+ type LoomImportJobStatus,
type LoomImportSnapshot,
+ loomImportWaitingStatus,
} from "@/lib/loom-import/status";
const ACTIVE_POLL_MS = 1500;
const HIDDEN_POLL_MS = 15_000;
const UPGRADE_POLL_MS = 2500;
+const SNAPSHOT_TIMEOUT_MS = 20_000;
const RATE_WINDOW_MS = 180_000;
const RATE_MIN_SPAN_MS = 20_000;
@@ -39,6 +42,7 @@ export function mergeLoomImportItems(
map: Map,
order: string[],
snapshot: LoomImportSnapshot,
+ previousJobStatus: LoomImportJobStatus = snapshot.job.status,
) {
let nextOrder = order;
if (snapshot.full) {
@@ -46,6 +50,18 @@ export function mergeLoomImportItems(
nextOrder = snapshot.items.map((item) => item.id);
}
let changed = snapshot.full;
+ if (!snapshot.full && previousJobStatus !== snapshot.job.status) {
+ const waiting = loomImportWaitingStatus(snapshot.job.status);
+ for (const [id, item] of map) {
+ if (
+ (item.status === "ready" || item.status === "queued") &&
+ item.status !== waiting
+ ) {
+ map.set(id, { ...item, status: waiting });
+ changed = true;
+ }
+ }
+ }
for (const item of snapshot.items) {
const previous = map.get(item.id);
if (
@@ -69,6 +85,8 @@ export function useLoomImportJob(
const itemsRef = useRef | null>(null);
const orderRef = useRef([]);
const cursorRef = useRef(initial.cursor);
+ const jobStatusRef = useRef(initial.job.status);
+ const queueRef = useRef>(Promise.resolve());
const samplesRef = useRef([]);
if (itemsRef.current === null) {
itemsRef.current = new Map(initial.items.map((item) => [item.id, item]));
@@ -84,9 +102,15 @@ export function useLoomImportJob(
const apply = useCallback((snapshot: LoomImportSnapshot) => {
const map = itemsRef.current;
if (!map) return;
- const merged = mergeLoomImportItems(map, orderRef.current, snapshot);
+ const merged = mergeLoomImportItems(
+ map,
+ orderRef.current,
+ snapshot,
+ jobStatusRef.current,
+ );
orderRef.current = merged.order;
cursorRef.current = Math.max(cursorRef.current, snapshot.cursor);
+ jobStatusRef.current = snapshot.job.status;
const next = toSummary(snapshot);
const now = Date.now();
@@ -119,15 +143,22 @@ export function useLoomImportJob(
}, []);
const fetchSnapshot = useCallback(
- async (full = false) => {
- const params = new URLSearchParams({ jobId: initial.job.id });
- if (!full) params.set("since", String(cursorRef.current));
- const response = await fetch(`/api/import/loom/jobs?${params}`, {
- cache: "no-store",
- credentials: "same-origin",
- });
- if (!response.ok) throw new Error(`Snapshot failed: ${response.status}`);
- apply((await response.json()) as LoomImportSnapshot);
+ (full = false) => {
+ const run = async () => {
+ const params = new URLSearchParams({ jobId: initial.job.id });
+ if (!full) params.set("since", String(cursorRef.current));
+ const response = await fetch(`/api/import/loom/jobs?${params}`, {
+ cache: "no-store",
+ credentials: "same-origin",
+ signal: AbortSignal.timeout(SNAPSHOT_TIMEOUT_MS),
+ });
+ if (!response.ok)
+ throw new Error(`Snapshot failed: ${response.status}`);
+ apply((await response.json()) as LoomImportSnapshot);
+ };
+ const request = queueRef.current.then(run, run);
+ queueRef.current = request.catch(() => undefined);
+ return request;
},
[apply, initial.job.id],
);
@@ -161,6 +192,7 @@ export function useLoomImportJob(
let hiddenAt: number | null = null;
const schedule = () => {
+ window.clearTimeout(timer);
if (stopped) return;
const wait = document.hidden
? HIDDEN_POLL_MS
From 5ff19b2248e5f03c4cae1748f79a4c99531be83f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 7 Oct 2026 07:23:42 +0000
Subject: [PATCH 18/39] fix: keep the first video in CSVs of bare Loom ids
Co-authored-by: Richie McIlroy
---
.../__tests__/unit/loom-import-csv.test.ts | 26 ++++++++
.../web/__tests__/unit/loom-import-ui.test.ts | 61 ++++++++++++++++++-
apps/web/lib/loom-import/csv.ts | 4 +-
3 files changed, 89 insertions(+), 2 deletions(-)
diff --git a/apps/web/__tests__/unit/loom-import-csv.test.ts b/apps/web/__tests__/unit/loom-import-csv.test.ts
index 5d416237ad0..8abd2c1dda3 100644
--- a/apps/web/__tests__/unit/loom-import-csv.test.ts
+++ b/apps/web/__tests__/unit/loom-import-csv.test.ts
@@ -51,6 +51,32 @@ describe("parseCsv", () => {
expect(table.headerless).toBe(true);
expect(table.rows).toHaveLength(2);
});
+
+ it("keeps the first video in a plain list of Loom ids", () => {
+ const table = parseCsv(`${ID_A}\n${ID_B.toUpperCase()}\n`);
+ expect(table).toMatchObject({
+ headerless: true,
+ rows: [[ID_A], [ID_B.toUpperCase()]],
+ });
+ const plan = buildLoomImportPlan(table, detectLoomImportMapping(table), {
+ allowOwners: false,
+ });
+ expect(plan.rows).toEqual([
+ { rowNumber: 1, loomUrl: `https://www.loom.com/share/${ID_A}` },
+ { rowNumber: 2, loomUrl: `https://www.loom.com/share/${ID_B}` },
+ ]);
+
+ const single = parseCsv(`${ID_A}\r\n`);
+ expect(single.headerless).toBe(true);
+ expect(single.rows).toEqual([[ID_A]]);
+ });
+
+ it("still reads a header row above a column of Loom ids", () => {
+ const table = parseCsv(`loom_video_id,owner\n${ID_A},a@b.co\n`);
+ expect(table.headerless).toBe(false);
+ expect(table.headers).toEqual(["loom_video_id", "owner"]);
+ expect(table.rows).toEqual([[ID_A, "a@b.co"]]);
+ });
});
describe("extractLoomVideoId", () => {
diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts
index 22cc595c77c..748203b9a85 100644
--- a/apps/web/__tests__/unit/loom-import-ui.test.ts
+++ b/apps/web/__tests__/unit/loom-import-ui.test.ts
@@ -71,6 +71,7 @@ const fireEvent = {
const mocks = vi.hoisted(() => ({
folders: vi.fn(),
import: vi.fn(),
+ createJob: vi.fn(),
push: vi.fn(),
refresh: vi.fn(),
}));
@@ -101,7 +102,7 @@ vi.mock("@/actions/loom", () => ({
importFromLoom: mocks.import,
}));
vi.mock("@/actions/loom-import", () => ({
- createLoomImportJobAction: vi.fn(),
+ createLoomImportJobAction: mocks.createJob,
}));
vi.mock("@cap/utils", async () => await import("@cap/utils/helpers"));
vi.mock("@cap/ui", async () => ({
@@ -112,6 +113,7 @@ vi.mock("@cap/ui", async () => ({
...(await import("../../../../packages/ui/src/components/input/Input")),
}));
+import { File as NodeFile } from "node:buffer";
import { Folder, Space } from "@cap/web-domain";
import { ImportLoomPage } from "@/app/(org)/dashboard/import/loom/ImportLoomPage";
import type { LoomImportDestination } from "@/lib/loom-import-destination";
@@ -125,6 +127,16 @@ beforeEach(() => {
HTMLElement.prototype.hasPointerCapture = () => false;
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: () => false,
+ }));
mocks.folders.mockResolvedValue([
{ id: "parent", name: "Course", parentId: null },
{ id: "child", name: "Live Calls - Two", parentId: "parent" },
@@ -282,4 +294,51 @@ describe("Loom importer component", () => {
"/dashboard/spaces/space/folder/child",
);
});
+ it("imports every video from an uploaded file of bare Loom ids", async () => {
+ const first = "0dd0a01e10c742b28dbea75082c08635";
+ const second = "31f430c1a1e744b8a7b6c18a26982c71";
+ mocks.createJob.mockResolvedValue({ ok: true, jobId: "job-1" });
+ await render();
+ await act(async () => {
+ fireEvent.click(getByRole(container, "tab", { name: "Bulk Import" }));
+ });
+ const input = container.querySelector(
+ "[data-testid=loom-csv-input]",
+ );
+ if (!input) throw new Error("Missing CSV input");
+ Object.defineProperty(input, "files", {
+ configurable: true,
+ value: [
+ new NodeFile([`${first}\n${second}\n`], "loom-ids.csv", {
+ type: "text/csv",
+ }),
+ ],
+ });
+ await act(async () => {
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await waitFor(() =>
+ expect(
+ getByRole(container, "button", { name: "Import 2 videos" }),
+ ).toBeTruthy(),
+ );
+ expect(container.textContent).toContain("loom-ids.csv");
+ await act(async () => {
+ fireEvent.click(
+ getByRole(container, "button", { name: "Import 2 videos" }),
+ );
+ });
+ await waitFor(() => expect(mocks.createJob).toHaveBeenCalledTimes(1));
+ expect(mocks.createJob).toHaveBeenCalledWith({
+ orgId: "org",
+ fileName: "loom-ids.csv",
+ rows: [
+ { rowNumber: 1, loomUrl: `https://www.loom.com/share/${first}` },
+ { rowNumber: 2, loomUrl: `https://www.loom.com/share/${second}` },
+ ],
+ });
+ await waitFor(() =>
+ expect(mocks.push).toHaveBeenCalledWith("/dashboard/import/loom/job-1"),
+ );
+ });
});
diff --git a/apps/web/lib/loom-import/csv.ts b/apps/web/lib/loom-import/csv.ts
index 12b08b8da5d..980fda4907d 100644
--- a/apps/web/lib/loom-import/csv.ts
+++ b/apps/web/lib/loom-import/csv.ts
@@ -196,7 +196,9 @@ export function parseCsv(text: string): CsvTable {
const first = records[0];
if (!first || !isFilledRecord(first)) throw new Error("This CSV is empty.");
const headerless = first.some(
- (cell) => /loom\.com/i.test(cell) && extractLoomVideoId(cell) !== null,
+ (cell) =>
+ LOOM_ID_PATTERN.test(cell) ||
+ (/loom\.com/i.test(cell) && extractLoomVideoId(cell) !== null),
);
if (headerless) {
const width = records.reduce(
From 8a0bdc9a0c966fbb945daa8765090226e08fbe89 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 00:32:14 +0000
Subject: [PATCH 19/39] fix: give each desktop audio timing test its own temp
folder
Co-authored-by: Richie McIlroy
---
apps/desktop-gpui/src/editor_preparing/audio.rs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/apps/desktop-gpui/src/editor_preparing/audio.rs b/apps/desktop-gpui/src/editor_preparing/audio.rs
index 62929f287b1..bb5206e20e6 100644
--- a/apps/desktop-gpui/src/editor_preparing/audio.rs
+++ b/apps/desktop-gpui/src/editor_preparing/audio.rs
@@ -174,13 +174,16 @@ mod tests {
impl TestDirectory {
fn new() -> Self {
+ static NEXT_DIRECTORY: std::sync::atomic::AtomicUsize =
+ std::sync::atomic::AtomicUsize::new(0);
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
- "cap-gpui-preparing-audio-{}-{nonce}",
- std::process::id()
+ "cap-gpui-preparing-audio-{}-{nonce}-{}",
+ std::process::id(),
+ NEXT_DIRECTORY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
));
std::fs::create_dir(&path).unwrap();
Self(path)
From d3c4e1b8ca91924baf4ed5298f597dc35d575e80 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 02:03:16 +0000
Subject: [PATCH 20/39] feat: add a dispatch lock, fairness column and queue
indexes for Loom imports
Co-authored-by: Richie McIlroy
---
.../migrations/0051_smooth_longshot.sql | 9 +
.../migrations/meta/0051_snapshot.json | 5297 +++++++++++++++++
.../database/migrations/meta/_journal.json | 7 +
packages/database/schema.ts | 13 +
packages/env/server.ts | 6 +
5 files changed, 5332 insertions(+)
create mode 100644 packages/database/migrations/0051_smooth_longshot.sql
create mode 100644 packages/database/migrations/meta/0051_snapshot.json
diff --git a/packages/database/migrations/0051_smooth_longshot.sql b/packages/database/migrations/0051_smooth_longshot.sql
new file mode 100644
index 00000000000..31b78c7c639
--- /dev/null
+++ b/packages/database/migrations/0051_smooth_longshot.sql
@@ -0,0 +1,9 @@
+CREATE TABLE `loom_import_dispatch_locks` (
+ `id` varchar(32) NOT NULL,
+ `locked_at` datetime(3) NOT NULL,
+ CONSTRAINT `loom_import_dispatch_locks_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+ALTER TABLE `loom_import_jobs` ADD `dispatched_at` datetime(3);--> statement-breakpoint
+CREATE INDEX `job_updated_idx` ON `loom_import_job_items` (`job_id`,`updated_at`);--> statement-breakpoint
+CREATE INDEX `status_job_idx` ON `loom_import_job_items` (`status`,`job_id`);
\ No newline at end of file
diff --git a/packages/database/migrations/meta/0051_snapshot.json b/packages/database/migrations/meta/0051_snapshot.json
new file mode 100644
index 00000000000..c75e43f8ab8
--- /dev/null
+++ b/packages/database/migrations/meta/0051_snapshot.json
@@ -0,0 +1,5297 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "2d9ce5be-a844-4cbe-9eac-f079622c3f98",
+ "prevId": "b3eb8041-9563-45df-99f3-649946dfd1e6",
+ "tables": {
+ "accounts": {
+ "name": "accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_in": {
+ "name": "expires_in",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_in": {
+ "name": "refresh_token_expires_in",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "tempColumn": {
+ "name": "tempColumn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ },
+ "provider_account_id_idx": {
+ "name": "provider_account_id_idx",
+ "columns": ["providerAccountId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "accounts_id": {
+ "name": "accounts_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_authorization_codes": {
+ "name": "agent_api_authorization_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": ["codeHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_authorization_codes_id": {
+ "name": "agent_api_authorization_codes_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_idempotency": {
+ "name": "agent_api_idempotency",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestHash": {
+ "name": "requestHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "statusCode": {
+ "name": "statusCode",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "response": {
+ "name": "response",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_operation_key_idx": {
+ "name": "user_operation_key_idx",
+ "columns": ["userId", "operation", "keyHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_idempotency_id": {
+ "name": "agent_api_idempotency_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_keys": {
+ "name": "agent_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tokenHash": {
+ "name": "tokenHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Cap CLI'"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "token_hash_idx": {
+ "name": "token_hash_idx",
+ "columns": ["tokenHash"],
+ "isUnique": true
+ },
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_keys_id": {
+ "name": "agent_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "agent_api_operations": {
+ "name": "agent_api_operations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resourceId": {
+ "name": "resourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resultResourceId": {
+ "name": "resultResourceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'queued'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorCode": {
+ "name": "errorCode",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_created_at_idx": {
+ "name": "user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "state_updated_at_idx": {
+ "name": "state_updated_at_idx",
+ "columns": ["state", "updatedAt"],
+ "isUnique": false
+ },
+ "resource_id_idx": {
+ "name": "resource_id_idx",
+ "columns": ["resourceId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_api_operations_id": {
+ "name": "agent_api_operations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "auth_api_keys": {
+ "name": "auth_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_created_at_idx": {
+ "name": "user_id_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "auth_api_keys_id": {
+ "name": "auth_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(6)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authorId": {
+ "name": "authorId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "parentCommentId": {
+ "name": "parentCommentId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaKey": {
+ "name": "mediaKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaDuration": {
+ "name": "mediaDuration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mediaMeta": {
+ "name": "mediaMeta",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_type_created_idx": {
+ "name": "video_type_created_idx",
+ "columns": ["videoId", "type", "createdAt", "id"],
+ "isUnique": false
+ },
+ "author_id_idx": {
+ "name": "author_id_idx",
+ "columns": ["authorId"],
+ "isUnique": false
+ },
+ "parent_comment_id_idx": {
+ "name": "parent_comment_id_idx",
+ "columns": ["parentCommentId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "comments_id": {
+ "name": "comments_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_api_keys": {
+ "name": "developer_api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyType": {
+ "name": "keyType",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyPrefix": {
+ "name": "keyPrefix",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedKey": {
+ "name": "encryptedKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "key_hash_idx": {
+ "name": "key_hash_idx",
+ "columns": ["keyHash"],
+ "isUnique": true
+ },
+ "app_key_type_idx": {
+ "name": "app_key_type_idx",
+ "columns": ["appId", "keyType"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "developer_api_keys_appId_developer_apps_id_fk": {
+ "name": "developer_api_keys_appId_developer_apps_id_fk",
+ "tableFrom": "developer_api_keys",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_api_keys_id": {
+ "name": "developer_api_keys_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_app_domains": {
+ "name": "developer_app_domains",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "varchar(253)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "developer_app_domains_appId_developer_apps_id_fk": {
+ "name": "developer_app_domains_appId_developer_apps_id_fk",
+ "tableFrom": "developer_app_domains",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_app_domains_id": {
+ "name": "developer_app_domains_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "app_domain_unique": {
+ "name": "app_domain_unique",
+ "columns": ["appId", "domain"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "developer_apps": {
+ "name": "developer_apps",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment": {
+ "name": "environment",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logoUrl": {
+ "name": "logoUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletedAt": {
+ "name": "deletedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_deleted_idx": {
+ "name": "owner_deleted_idx",
+ "columns": ["ownerId", "deletedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "developer_apps_id": {
+ "name": "developer_apps_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_credit_accounts": {
+ "name": "developer_credit_accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "balanceMicroCredits": {
+ "name": "balanceMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripePaymentMethodId": {
+ "name": "stripePaymentMethodId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoTopUpEnabled": {
+ "name": "autoTopUpEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "autoTopUpThresholdMicroCredits": {
+ "name": "autoTopUpThresholdMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "autoTopUpAmountCents": {
+ "name": "autoTopUpAmountCents",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "app_id_unique": {
+ "name": "app_id_unique",
+ "columns": ["appId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "developer_credit_accounts_appId_developer_apps_id_fk": {
+ "name": "developer_credit_accounts_appId_developer_apps_id_fk",
+ "tableFrom": "developer_credit_accounts",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_credit_accounts_id": {
+ "name": "developer_credit_accounts_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_credit_transactions": {
+ "name": "developer_credit_transactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accountId": {
+ "name": "accountId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amountMicroCredits": {
+ "name": "amountMicroCredits",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "balanceAfterMicroCredits": {
+ "name": "balanceAfterMicroCredits",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referenceId": {
+ "name": "referenceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referenceType": {
+ "name": "referenceType",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "account_type_created_idx": {
+ "name": "account_type_created_idx",
+ "columns": ["accountId", "type", "createdAt"],
+ "isUnique": false
+ },
+ "account_ref_dedup_idx": {
+ "name": "account_ref_dedup_idx",
+ "columns": ["accountId", "referenceId", "referenceType"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "dev_credit_txn_account_fk": {
+ "name": "dev_credit_txn_account_fk",
+ "tableFrom": "developer_credit_transactions",
+ "tableTo": "developer_credit_accounts",
+ "columnsFrom": ["accountId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_credit_transactions_id": {
+ "name": "developer_credit_transactions_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "developer_daily_storage_snapshots": {
+ "name": "developer_daily_storage_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshotDate": {
+ "name": "snapshotDate",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totalDurationMinutes": {
+ "name": "totalDurationMinutes",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "videoCount": {
+ "name": "videoCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "microCreditsCharged": {
+ "name": "microCreditsCharged",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processedAt": {
+ "name": "processedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "developer_daily_storage_snapshots_appId_developer_apps_id_fk": {
+ "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk",
+ "tableFrom": "developer_daily_storage_snapshots",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_daily_storage_snapshots_id": {
+ "name": "developer_daily_storage_snapshots_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "app_date_unique": {
+ "name": "app_date_unique",
+ "columns": ["appId", "snapshotDate"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "developer_videos": {
+ "name": "developer_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appId": {
+ "name": "appId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalUserId": {
+ "name": "externalUserId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Untitled'"
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "s3Key": {
+ "name": "s3Key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletedAt": {
+ "name": "deletedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "app_created_idx": {
+ "name": "app_created_idx",
+ "columns": ["appId", "createdAt"],
+ "isUnique": false
+ },
+ "app_user_idx": {
+ "name": "app_user_idx",
+ "columns": ["appId", "externalUserId"],
+ "isUnique": false
+ },
+ "app_deleted_idx": {
+ "name": "app_deleted_idx",
+ "columns": ["appId", "deletedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "developer_videos_appId_developer_apps_id_fk": {
+ "name": "developer_videos_appId_developer_apps_id_fk",
+ "tableFrom": "developer_videos",
+ "tableTo": "developer_apps",
+ "columnsFrom": ["appId"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "developer_videos_id": {
+ "name": "developer_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "folders": {
+ "name": "folders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'normal'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "created_by_id_idx": {
+ "name": "created_by_id_idx",
+ "columns": ["createdById"],
+ "isUnique": false
+ },
+ "parent_id_idx": {
+ "name": "parent_id_idx",
+ "columns": ["parentId"],
+ "isUnique": false
+ },
+ "space_id_idx": {
+ "name": "space_id_idx",
+ "columns": ["spaceId"],
+ "isUnique": false
+ },
+ "public_parent_id_idx": {
+ "name": "public_parent_id_idx",
+ "columns": ["public", "parentId"],
+ "isUnique": false
+ },
+ "public_space_parent_id_idx": {
+ "name": "public_space_parent_id_idx",
+ "columns": ["public", "spaceId", "parentId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "folders_id": {
+ "name": "folders_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "imported_videos": {
+ "name": "imported_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "id_idx": {
+ "name": "id_idx",
+ "columns": ["id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "imported_videos_orgId_source_source_id_pk": {
+ "name": "imported_videos_orgId_source_source_id_pk",
+ "columns": ["orgId", "source", "source_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_installations": {
+ "name": "integration_installations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "externalId": {
+ "name": "externalId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "installedByUserId": {
+ "name": "installedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryptedCredentials": {
+ "name": "encryptedCredentials",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "provider_external_id_idx": {
+ "name": "provider_external_id_idx",
+ "columns": ["provider", "externalId"],
+ "isUnique": true
+ },
+ "organization_provider_display_name_idx": {
+ "name": "organization_provider_display_name_idx",
+ "columns": ["organizationId", "provider", "displayName"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "integration_installations_id": {
+ "name": "integration_installations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_dispatch_locks": {
+ "name": "loom_import_dispatch_locks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locked_at": {
+ "name": "locked_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_dispatch_locks_id": {
+ "name": "loom_import_dispatch_locks_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_job_items": {
+ "name": "loom_import_job_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "csv_row": {
+ "name": "csv_row",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "loom_url": {
+ "name": "loom_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "loom_video_id": {
+ "name": "loom_video_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "owner_email": {
+ "name": "owner_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "space_name": {
+ "name": "space_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "space_id": {
+ "name": "space_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loom_created_at": {
+ "name": "loom_created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_url": {
+ "name": "thumbnail_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "job_row_idx": {
+ "name": "job_row_idx",
+ "columns": ["job_id", "csv_row"],
+ "isUnique": true
+ },
+ "job_status_row_idx": {
+ "name": "job_status_row_idx",
+ "columns": ["job_id", "status", "csv_row"],
+ "isUnique": false
+ },
+ "job_updated_idx": {
+ "name": "job_updated_idx",
+ "columns": ["job_id", "updated_at"],
+ "isUnique": false
+ },
+ "status_job_idx": {
+ "name": "status_job_idx",
+ "columns": ["status", "job_id"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_job_items_id": {
+ "name": "loom_import_job_items_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loom_import_jobs": {
+ "name": "loom_import_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'checking'"
+ },
+ "total_count": {
+ "name": "total_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dispatched_at": {
+ "name": "dispatched_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "org_creator_created_idx": {
+ "name": "org_creator_created_idx",
+ "columns": ["org_id", "created_by_id", "created_at"],
+ "isUnique": false
+ },
+ "status_updated_idx": {
+ "name": "status_updated_idx",
+ "columns": ["status", "updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loom_import_jobs_id": {
+ "name": "loom_import_jobs_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "loops_sync_jobs": {
+ "name": "loops_sync_jobs",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "nextAttemptAt": {
+ "name": "nextAttemptAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "leaseToken": {
+ "name": "leaseToken",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "leaseUntil": {
+ "name": "leaseUntil",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failures": {
+ "name": "failures",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lastError": {
+ "name": "lastError",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "profileHash": {
+ "name": "profileHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teammateJoinedAt": {
+ "name": "teammateJoinedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "syncedEmail": {
+ "name": "syncedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastSyncedAt": {
+ "name": "lastSyncedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "loops_sync_due_idx": {
+ "name": "loops_sync_due_idx",
+ "columns": ["nextAttemptAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "loops_sync_jobs_userId": {
+ "name": "loops_sync_jobs_userId",
+ "columns": ["userId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_clients": {
+ "name": "mcp_oauth_clients",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientName": {
+ "name": "clientName",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUris": {
+ "name": "redirectUris",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "activatedAt": {
+ "name": "activatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "client_id_idx": {
+ "name": "client_id_idx",
+ "columns": ["clientId"],
+ "isUnique": true
+ },
+ "inactive_created_at_idx": {
+ "name": "inactive_created_at_idx",
+ "columns": ["activatedAt", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_clients_id": {
+ "name": "mcp_oauth_clients_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_codes": {
+ "name": "mcp_oauth_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeHash": {
+ "name": "codeHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "codeChallenge": {
+ "name": "codeChallenge",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "redirectUri": {
+ "name": "redirectUri",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "consumedAt": {
+ "name": "consumedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "code_hash_idx": {
+ "name": "code_hash_idx",
+ "columns": ["codeHash"],
+ "isUnique": true
+ },
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_codes_id": {
+ "name": "mcp_oauth_codes_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_registration_quotas": {
+ "name": "mcp_oauth_registration_quotas",
+ "columns": {
+ "windowId": {
+ "name": "windowId",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "registrations": {
+ "name": "registrations",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "expires_at_idx": {
+ "name": "expires_at_idx",
+ "columns": ["expiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_registration_quotas_windowId": {
+ "name": "mcp_oauth_registration_quotas_windowId",
+ "columns": ["windowId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "mcp_oauth_tokens": {
+ "name": "mcp_oauth_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clientId": {
+ "name": "clientId",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "familyId": {
+ "name": "familyId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessHash": {
+ "name": "accessHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshHash": {
+ "name": "refreshHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessExpiresAt": {
+ "name": "accessExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refreshExpiresAt": {
+ "name": "refreshExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "access_hash_idx": {
+ "name": "access_hash_idx",
+ "columns": ["accessHash"],
+ "isUnique": true
+ },
+ "refresh_hash_idx": {
+ "name": "refresh_hash_idx",
+ "columns": ["refreshHash"],
+ "isUnique": true
+ },
+ "family_id_idx": {
+ "name": "family_id_idx",
+ "columns": ["familyId"],
+ "isUnique": false
+ },
+ "client_active_idx": {
+ "name": "client_active_idx",
+ "columns": ["clientId", "revokedAt", "refreshExpiresAt"],
+ "isUnique": false
+ },
+ "refresh_expires_at_idx": {
+ "name": "refresh_expires_at_idx",
+ "columns": ["refreshExpiresAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "mcp_oauth_tokens_id": {
+ "name": "mcp_oauth_tokens_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media_processing_budgets": {
+ "name": "media_processing_budgets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reserved_bytes": {
+ "name": "reserved_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "limit_bytes": {
+ "name": "limit_bytes",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "media_budget_expiry_idx": {
+ "name": "media_budget_expiry_idx",
+ "columns": ["expires_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "media_processing_budgets_id": {
+ "name": "media_processing_budgets_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_conversations": {
+ "name": "messenger_conversations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent": {
+ "name": "agent",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'agent'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "anonymousId": {
+ "name": "anonymousId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "takeoverByUserId": {
+ "name": "takeoverByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "takeoverAt": {
+ "name": "takeoverAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastMessageAt": {
+ "name": "lastMessageAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_last_message_idx": {
+ "name": "user_last_message_idx",
+ "columns": ["userId", "lastMessageAt"],
+ "isUnique": false
+ },
+ "anonymous_last_message_idx": {
+ "name": "anonymous_last_message_idx",
+ "columns": ["anonymousId", "lastMessageAt"],
+ "isUnique": false
+ },
+ "mode_last_message_idx": {
+ "name": "mode_last_message_idx",
+ "columns": ["mode", "lastMessageAt"],
+ "isUnique": false
+ },
+ "updated_at_idx": {
+ "name": "updated_at_idx",
+ "columns": ["updatedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "messenger_conversations_id": {
+ "name": "messenger_conversations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_messages": {
+ "name": "messenger_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversationId": {
+ "name": "conversationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "anonymousId": {
+ "name": "anonymousId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "conversation_created_at_idx": {
+ "name": "conversation_created_at_idx",
+ "columns": ["conversationId", "createdAt"],
+ "isUnique": false
+ },
+ "role_created_at_idx": {
+ "name": "role_created_at_idx",
+ "columns": ["role", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messenger_messages_conversationId_messenger_conversations_id_fk": {
+ "name": "messenger_messages_conversationId_messenger_conversations_id_fk",
+ "tableFrom": "messenger_messages",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": ["conversationId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_messages_id": {
+ "name": "messenger_messages_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "messenger_support_emails": {
+ "name": "messenger_support_emails",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversationId": {
+ "name": "conversationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userEmail": {
+ "name": "userEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "support_email_user_created_at_idx": {
+ "name": "support_email_user_created_at_idx",
+ "columns": ["userId", "createdAt"],
+ "isUnique": false
+ },
+ "support_email_conversation_created_at_idx": {
+ "name": "support_email_conversation_created_at_idx",
+ "columns": ["conversationId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "support_email_conversation_fk": {
+ "name": "support_email_conversation_fk",
+ "tableFrom": "messenger_support_emails",
+ "tableTo": "messenger_conversations",
+ "columnsFrom": ["conversationId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "messenger_support_emails_id": {
+ "name": "messenger_support_emails_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipientId": {
+ "name": "recipientId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dedupKey": {
+ "name": "dedupKey",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readAt": {
+ "name": "readAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "org_id_idx": {
+ "name": "org_id_idx",
+ "columns": ["orgId"],
+ "isUnique": false
+ },
+ "type_idx": {
+ "name": "type_idx",
+ "columns": ["type"],
+ "isUnique": false
+ },
+ "read_at_idx": {
+ "name": "read_at_idx",
+ "columns": ["readAt"],
+ "isUnique": false
+ },
+ "created_at_idx": {
+ "name": "created_at_idx",
+ "columns": ["createdAt"],
+ "isUnique": false
+ },
+ "recipient_read_idx": {
+ "name": "recipient_read_idx",
+ "columns": ["recipientId", "readAt"],
+ "isUnique": false
+ },
+ "recipient_created_idx": {
+ "name": "recipient_created_idx",
+ "columns": ["recipientId", "createdAt"],
+ "isUnique": false
+ },
+ "dedup_key_idx": {
+ "name": "dedup_key_idx",
+ "columns": ["dedupKey"],
+ "isUnique": true
+ },
+ "type_recipient_created_idx": {
+ "name": "type_recipient_created_idx",
+ "columns": ["type", "recipientId", "createdAt"],
+ "isUnique": false
+ },
+ "type_recipient_video_created_idx": {
+ "name": "type_recipient_video_created_idx",
+ "columns": ["type", "recipientId", "videoId", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "notifications_id": {
+ "name": "notifications_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_invites": {
+ "name": "organization_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "invited_email_idx": {
+ "name": "invited_email_idx",
+ "columns": ["invitedEmail"],
+ "isUnique": false
+ },
+ "invited_by_user_id_idx": {
+ "name": "invited_by_user_id_idx",
+ "columns": ["invitedByUserId"],
+ "isUnique": false
+ },
+ "status_idx": {
+ "name": "status_idx",
+ "columns": ["status"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_invites_id": {
+ "name": "organization_invites_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_members": {
+ "name": "organization_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "hasProSeat": {
+ "name": "hasProSeat",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "user_id_organization_id_idx": {
+ "name": "user_id_organization_id_idx",
+ "columns": ["userId", "organizationId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_members_id": {
+ "name": "organization_members_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organization_sso": {
+ "name": "organization_sso",
+ "columns": {
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "purchasedByUserId": {
+ "name": "purchasedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripePriceId": {
+ "name": "stripePriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "paidThrough": {
+ "name": "paidThrough",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "currentPeriodEnd": {
+ "name": "currentPeriodEnd",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "checkoutAttemptId": {
+ "name": "checkoutAttemptId",
+ "type": "varchar(36)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutCurrency": {
+ "name": "checkoutCurrency",
+ "type": "varchar(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutPriceId": {
+ "name": "checkoutPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutSessionId": {
+ "name": "checkoutSessionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkoutStartedAt": {
+ "name": "checkoutStartedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "sso_stripe_subscription_id_idx": {
+ "name": "sso_stripe_subscription_id_idx",
+ "columns": ["stripeSubscriptionId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organization_sso_organizationId": {
+ "name": "organization_sso_organizationId",
+ "columns": ["organizationId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "organizations": {
+ "name": "organizations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tombstoneAt": {
+ "name": "tombstoneAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "allowedEmailDomain": {
+ "name": "allowedEmailDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "defaultVideoVisibility": {
+ "name": "defaultVideoVisibility",
+ "type": "varchar(7)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customDomain": {
+ "name": "customDomain",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "domainVerified": {
+ "name": "domainVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shareableLinkIconUrl": {
+ "name": "shareableLinkIconUrl",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "workosOrganizationId": {
+ "name": "workosOrganizationId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workosConnectionId": {
+ "name": "workosConnectionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "owner_id_tombstone_idx": {
+ "name": "owner_id_tombstone_idx",
+ "columns": ["ownerId", "tombstoneAt"],
+ "isUnique": false
+ },
+ "custom_domain_idx": {
+ "name": "custom_domain_idx",
+ "columns": ["customDomain"],
+ "isUnique": false
+ },
+ "workos_organization_id_idx": {
+ "name": "workos_organization_id_idx",
+ "columns": ["workosOrganizationId"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "organizations_id": {
+ "name": "organizations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "s3_buckets": {
+ "name": "s3_buckets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bucketName": {
+ "name": "bucketName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessKeyId": {
+ "name": "accessKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secretAccessKey": {
+ "name": "secretAccessKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('aws')"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_organization_idx": {
+ "name": "owner_organization_idx",
+ "columns": ["ownerId", "organizationId"],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": ["organizationId", "active", "updatedAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "s3_buckets_id": {
+ "name": "s3_buckets_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "session_token_idx": {
+ "name": "session_token_idx",
+ "columns": ["sessionToken"],
+ "isUnique": true
+ },
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sessions_id": {
+ "name": "sessions_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "shared_videos": {
+ "name": "shared_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedByUserId": {
+ "name": "sharedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sharedAt": {
+ "name": "sharedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "shared_by_user_id_idx": {
+ "name": "shared_by_user_id_idx",
+ "columns": ["sharedByUserId"],
+ "isUnique": false
+ },
+ "video_id_organization_id_idx": {
+ "name": "video_id_organization_id_idx",
+ "columns": ["videoId", "organizationId"],
+ "isUnique": false
+ },
+ "video_id_folder_id_idx": {
+ "name": "video_id_folder_id_idx",
+ "columns": ["videoId", "folderId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "shared_videos_id": {
+ "name": "shared_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "signed_baas": {
+ "name": "signed_baas",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entityName": {
+ "name": "entityName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityType": {
+ "name": "entityType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entityAddress": {
+ "name": "entityAddress",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerName": {
+ "name": "signerName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signerTitle": {
+ "name": "signerTitle",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "noticesEmail": {
+ "name": "noticesEmail",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signatureData": {
+ "name": "signatureData",
+ "type": "longtext",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signedAt": {
+ "name": "signedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "emailSentAt": {
+ "name": "emailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "signed_baa_organization_id_idx": {
+ "name": "signed_baa_organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": true
+ },
+ "signed_baa_stripe_subscription_idx": {
+ "name": "signed_baa_stripe_subscription_idx",
+ "columns": ["stripeSubscriptionId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "signed_baas_id": {
+ "name": "signed_baas_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "space_members": {
+ "name": "space_members",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_members_id": {
+ "name": "space_members_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "space_id_user_id_unique": {
+ "name": "space_id_user_id_unique",
+ "columns": ["spaceId", "userId"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "space_videos": {
+ "name": "space_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedById": {
+ "name": "addedById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["videoId"],
+ "isUnique": false
+ },
+ "added_by_id_idx": {
+ "name": "added_by_id_idx",
+ "columns": ["addedById"],
+ "isUnique": false
+ },
+ "space_id_video_id_idx": {
+ "name": "space_id_video_id_idx",
+ "columns": ["spaceId", "videoId"],
+ "isUnique": false
+ },
+ "space_id_folder_id_idx": {
+ "name": "space_id_folder_id_idx",
+ "columns": ["spaceId", "folderId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "space_videos_id": {
+ "name": "space_videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "spaces": {
+ "name": "spaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "primary": {
+ "name": "primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdById": {
+ "name": "createdById",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "iconUrl": {
+ "name": "iconUrl",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "privacy": {
+ "name": "privacy",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Private'"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "organization_id_idx": {
+ "name": "organization_id_idx",
+ "columns": ["organizationId"],
+ "isUnique": false
+ },
+ "created_by_id_idx": {
+ "name": "created_by_id_idx",
+ "columns": ["createdById"],
+ "isUnique": false
+ },
+ "public_organization_id_idx": {
+ "name": "public_organization_id_idx",
+ "columns": ["public", "organizationId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "spaces_id": {
+ "name": "spaces_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_integrations": {
+ "name": "storage_integrations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "displayName": {
+ "name": "displayName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "encryptedConfig": {
+ "name": "encryptedConfig",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "googleDriveAccessToken": {
+ "name": "googleDriveAccessToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveAccessTokenExpiresAt": {
+ "name": "googleDriveAccessTokenExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseId": {
+ "name": "googleDriveTokenRefreshLeaseId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveTokenRefreshLeaseExpiresAt": {
+ "name": "googleDriveTokenRefreshLeaseExpiresAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "googleDriveStorageQuotaCache": {
+ "name": "googleDriveStorageQuotaCache",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "owner_provider_idx": {
+ "name": "owner_provider_idx",
+ "columns": ["ownerId", "provider"],
+ "isUnique": false
+ },
+ "owner_active_idx": {
+ "name": "owner_active_idx",
+ "columns": ["ownerId", "active"],
+ "isUnique": false
+ },
+ "organization_provider_idx": {
+ "name": "organization_provider_idx",
+ "columns": ["organizationId", "provider"],
+ "isUnique": false
+ },
+ "organization_active_idx": {
+ "name": "organization_active_idx",
+ "columns": ["organizationId", "active", "status"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "storage_integrations_id": {
+ "name": "storage_integrations_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "storage_objects": {
+ "name": "storage_objects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrationId": {
+ "name": "integrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "objectKey": {
+ "name": "objectKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "objectKeyHash": {
+ "name": "objectKeyHash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerObjectId": {
+ "name": "providerObjectId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploadSessionUrl": {
+ "name": "uploadSessionUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uploadStatus": {
+ "name": "uploadStatus",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentLength": {
+ "name": "contentLength",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "integration_key_hash_idx": {
+ "name": "integration_key_hash_idx",
+ "columns": ["integrationId", "objectKeyHash"],
+ "isUnique": true
+ },
+ "integration_status_idx": {
+ "name": "integration_status_idx",
+ "columns": ["integrationId", "uploadStatus"],
+ "isUnique": false
+ },
+ "integration_object_key_prefix_idx": {
+ "name": "integration_object_key_prefix_idx",
+ "columns": ["integrationId", "`objectKey`(191)"],
+ "isUnique": false
+ },
+ "video_id_idx": {
+ "name": "video_id_idx",
+ "columns": ["videoId"],
+ "isUnique": false
+ },
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": ["ownerId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "storage_objects_integrationId_storage_integrations_id_fk": {
+ "name": "storage_objects_integrationId_storage_integrations_id_fk",
+ "tableFrom": "storage_objects",
+ "tableTo": "storage_integrations",
+ "columnsFrom": ["integrationId"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "storage_objects_id": {
+ "name": "storage_objects_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastName": {
+ "name": "lastName",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thirdPartyStripeSubscriptionId": {
+ "name": "thirdPartyStripeSubscriptionId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionStatus": {
+ "name": "stripeSubscriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stripeSubscriptionPriceId": {
+ "name": "stripeSubscriptionPriceId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferences": {
+ "name": "preferences",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('null')"
+ },
+ "activeOrganizationId": {
+ "name": "activeOrganizationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "onboardingSteps": {
+ "name": "onboardingSteps",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customBucket": {
+ "name": "customBucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inviteQuota": {
+ "name": "inviteQuota",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "defaultOrgId": {
+ "name": "defaultOrgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "authSessionVersion": {
+ "name": "authSessionVersion",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "marketingOrigin": {
+ "name": "marketingOrigin",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {
+ "email_idx": {
+ "name": "email_idx",
+ "columns": ["email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verification_tokens": {
+ "name": "verification_tokens",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verification_tokens_identifier": {
+ "name": "verification_tokens_identifier",
+ "columns": ["identifier"]
+ }
+ },
+ "uniqueConstraints": {
+ "verification_tokens_token_unique": {
+ "name": "verification_tokens_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "video_edits": {
+ "name": "video_edits",
+ "columns": {
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sourceKey": {
+ "name": "sourceKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "editSpec": {
+ "name": "editSpec",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "video_edits_videoId_videos_id_fk": {
+ "name": "video_edits_videoId_videos_id_fk",
+ "tableFrom": "video_edits",
+ "tableTo": "videos",
+ "columnsFrom": ["videoId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_edits_videoId": {
+ "name": "video_edits_videoId",
+ "columns": ["videoId"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_processing_jobs": {
+ "name": "video_processing_jobs",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manifest_sha256": {
+ "name": "manifest_sha256",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'committing'"
+ },
+ "attempt_id": {
+ "name": "attempt_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_retry_at": {
+ "name": "next_retry_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workflow_run_id": {
+ "name": "workflow_run_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_job_id": {
+ "name": "remote_job_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification": {
+ "name": "verification",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "output": {
+ "name": "output",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime(3)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "processing_state_retry_video_idx": {
+ "name": "processing_state_retry_video_idx",
+ "columns": ["state", "next_retry_at", "video_id"],
+ "isUnique": false
+ },
+ "processing_state_lease_video_idx": {
+ "name": "processing_state_lease_video_idx",
+ "columns": ["state", "lease_expires_at", "video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_processing_jobs_video_id": {
+ "name": "video_processing_jobs_video_id",
+ "columns": ["video_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_uploads": {
+ "name": "video_uploads",
+ "columns": {
+ "video_id": {
+ "name": "video_id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded": {
+ "name": "uploaded",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total": {
+ "name": "total",
+ "type": "bigint unsigned",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'uploading'"
+ },
+ "processing_progress": {
+ "name": "processing_progress",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "processing_message": {
+ "name": "processing_message",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processing_error": {
+ "name": "processing_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "raw_file_key": {
+ "name": "raw_file_key",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "phase_updated_at_video_id_idx": {
+ "name": "phase_updated_at_video_id_idx",
+ "columns": ["phase", "updated_at", "video_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_uploads_video_id": {
+ "name": "video_uploads_video_id",
+ "columns": ["video_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "video_viewer_grants": {
+ "name": "video_viewer_grants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedByUserId": {
+ "name": "invitedByUserId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "revokedAt": {
+ "name": "revokedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "video_email_idx": {
+ "name": "video_email_idx",
+ "columns": ["videoId", "email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "video_viewer_grants_videoId_videos_id_fk": {
+ "name": "video_viewer_grants_videoId_videos_id_fk",
+ "tableFrom": "video_viewer_grants",
+ "tableTo": "videos",
+ "columnsFrom": ["videoId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "video_viewer_grants_id": {
+ "name": "video_viewer_grants_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "videos": {
+ "name": "videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ownerId": {
+ "name": "ownerId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "orgId": {
+ "name": "orgId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'My Video'"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageIntegrationId": {
+ "name": "storageIntegrationId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fps": {
+ "name": "fps",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transcriptionStatus": {
+ "name": "transcriptionStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"type\":\"MediaConvert\"}')"
+ },
+ "folderId": {
+ "name": "folderId",
+ "type": "varchar(15)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "effectiveCreatedAt": {
+ "name": "effectiveCreatedAt",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "generated": {
+ "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )",
+ "type": "stored"
+ }
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "xStreamInfo": {
+ "name": "xStreamInfo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "firstViewEmailSentAt": {
+ "name": "firstViewEmailSentAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "isScreenshot": {
+ "name": "isScreenshot",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "awsRegion": {
+ "name": "awsRegion",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "awsBucket": {
+ "name": "awsBucket",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "videoStartTime": {
+ "name": "videoStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "audioStartTime": {
+ "name": "audioStartTime",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobId": {
+ "name": "jobId",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "jobStatus": {
+ "name": "jobStatus",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "skipProcessing": {
+ "name": "skipProcessing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "owner_id_idx": {
+ "name": "owner_id_idx",
+ "columns": ["ownerId"],
+ "isUnique": false
+ },
+ "owner_updated_id_idx": {
+ "name": "owner_updated_id_idx",
+ "columns": ["ownerId", "updatedAt", "id"],
+ "isUnique": false
+ },
+ "is_public_idx": {
+ "name": "is_public_idx",
+ "columns": ["public"],
+ "isUnique": false
+ },
+ "folder_id_idx": {
+ "name": "folder_id_idx",
+ "columns": ["folderId"],
+ "isUnique": false
+ },
+ "storage_integration_id_idx": {
+ "name": "storage_integration_id_idx",
+ "columns": ["storageIntegrationId"],
+ "isUnique": false
+ },
+ "org_owner_folder_idx": {
+ "name": "org_owner_folder_idx",
+ "columns": ["orgId", "ownerId", "folderId"],
+ "isUnique": false
+ },
+ "org_effective_created_idx": {
+ "name": "org_effective_created_idx",
+ "columns": ["orgId", "effectiveCreatedAt"],
+ "isUnique": false
+ },
+ "screenshot_transcription_created_idx": {
+ "name": "screenshot_transcription_created_idx",
+ "columns": ["isScreenshot", "transcriptionStatus", "createdAt"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "videos_storageIntegrationId_storage_integrations_id_fk": {
+ "name": "videos_storageIntegrationId_storage_integrations_id_fk",
+ "tableFrom": "videos",
+ "tableTo": "storage_integrations",
+ "columnsFrom": ["storageIntegrationId"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "videos_id": {
+ "name": "videos_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {
+ "integration_object_key_prefix_idx": {
+ "columns": {
+ "`objectKey`(191)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json
index b20cd37dc8f..c325de98556 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -358,6 +358,13 @@
"when": 1791345311370,
"tag": "0050_productive_christian_walker",
"breakpoints": true
+ },
+ {
+ "idx": 51,
+ "version": "5",
+ "when": 1791422257360,
+ "tag": "0051_smooth_longshot",
+ "breakpoints": true
}
]
}
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index 736e66c6518..a8fbac267f3 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1688,6 +1688,7 @@ export const loomImportJobs = mysqlTable(
.$defaultFn(() => new Date()),
startedAt: datetime("started_at", { fsp: 3 }),
completedAt: datetime("completed_at", { fsp: 3 }),
+ dispatchedAt: datetime("dispatched_at", { fsp: 3 }),
},
(table) => [
index("org_creator_created_idx").on(
@@ -1699,6 +1700,16 @@ export const loomImportJobs = mysqlTable(
],
);
+export const loomImportDispatchLocks = mysqlTable(
+ "loom_import_dispatch_locks",
+ {
+ id: varchar("id", { length: 32 }).notNull().primaryKey(),
+ lockedAt: datetime("locked_at", { fsp: 3 })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ },
+);
+
export const loomImportJobItems = mysqlTable(
"loom_import_job_items",
{
@@ -1730,6 +1741,8 @@ export const loomImportJobItems = mysqlTable(
(table) => [
uniqueIndex("job_row_idx").on(table.jobId, table.rowNumber),
index("job_status_row_idx").on(table.jobId, table.status, table.rowNumber),
+ index("job_updated_idx").on(table.jobId, table.updatedAt),
+ index("status_job_idx").on(table.status, table.jobId),
index("video_id_idx").on(table.videoId),
],
);
diff --git a/packages/env/server.ts b/packages/env/server.ts
index e488c019fce..3dd8d10cb67 100644
--- a/packages/env/server.ts
+++ b/packages/env/server.ts
@@ -194,6 +194,12 @@ function createServerEnv() {
.describe(
"How many videos each Loom CSV import sends to the media server at once (default 4)",
),
+ LOOM_IMPORT_GLOBAL_CONCURRENCY: z
+ .string()
+ .optional()
+ .describe(
+ "How many Loom CSV videos all imports together send to the media server at once (default 12)",
+ ),
/// Ignore
NODE_ENV: z.string(),
From a93561f52b5cc3bbd071cfb7252cb25abd91fe98 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 02:03:16 +0000
Subject: [PATCH 21/39] feat: run Loom CSV imports through one fair,
capacity-aware queue
- Dispatch is system-wide: one MySQL lock row, a global limit
(LOOM_IMPORT_GLOBAL_CONCURRENCY, default 12) on top of the per-import limit,
per-person round robin, at most 4 launches per pass, and no new launches
while the media server or Loom reports busy.
- Job rows are locked by primary key before any item so cancelling and
dispatching never deadlock; passes that hit a lock error are retried.
- A workflow that can't start is queued once more before the row fails.
- Recovery fails rows whose import stopped responding for two hours so a
dead run can't hold a slot.
- Link checks keep rate-limited rows pending, honour Retry-After, stay within
a 90 second budget per step, and give up only after six passes without
progress.
- The per-video workflow waits for Loom when it rate limits the download
link instead of failing the video, and dispatches as soon as the media
server accepts a video.
- Polling reads only rows changed since the last poll plus rows copying now,
the page counts locally, backs off to 10 seconds when nothing changes and
refreshes fully every 3 minutes.
- Create requests send canonical Loom ids with deduplicated owners and
spaces, keeping 2,000 rows around 100 KB.
- Hot queries pin the (job_id, status, csv_row) index; the finished check
probes each open status separately.
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 294 +++++-
.../unit/loom-import-job-view.test.ts | 23 +-
.../unit/loom-import-schedule.test.ts | 237 +++++
.../web/__tests__/unit/loom-import-ui.test.ts | 12 +-
.../unit/media-processing-workflows.test.ts | 41 +
.../loom/[jobId]/use-loom-import-job.ts | 62 +-
.../import/loom/_components/bulk-import.tsx | 3 +-
apps/web/app/api/import/loom/jobs/route.ts | 32 +-
apps/web/lib/loom-import/csv.ts | 67 ++
apps/web/lib/loom-import/dispatch.ts | 911 +++++++++++-------
apps/web/lib/loom-import/indexes.ts | 2 +
apps/web/lib/loom-import/jobs.ts | 60 +-
apps/web/lib/loom-import/loom-api.ts | 28 +-
apps/web/lib/loom-import/recovery.ts | 182 ++--
apps/web/lib/loom-import/schedule.ts | 51 +
apps/web/lib/loom-import/snapshot.ts | 210 ++--
apps/web/lib/loom-import/status.ts | 36 +-
apps/web/workflows/import-loom-video.ts | 60 +-
apps/web/workflows/loom-import-job.ts | 22 +-
19 files changed, 1726 insertions(+), 607 deletions(-)
create mode 100644 apps/web/__tests__/unit/loom-import-schedule.test.ts
create mode 100644 apps/web/lib/loom-import/indexes.ts
create mode 100644 apps/web/lib/loom-import/schedule.ts
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index 85da7495d84..26f250089d3 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -31,6 +31,7 @@ const fixture = vi.hoisted(() => ({
database: undefined as MySql2Database | undefined,
start: vi.fn(),
concurrency: "2",
+ globalConcurrency: "12",
}));
vi.mock("server-only", () => ({}));
@@ -46,6 +47,7 @@ vi.mock("@cap/env", () => ({
serverEnv: () => ({
CAP_VIDEOS_DEFAULT_PUBLIC: true,
LOOM_IMPORT_CONCURRENCY: fixture.concurrency,
+ LOOM_IMPORT_GLOBAL_CONCURRENCY: fixture.globalConcurrency,
WEB_URL: "https://cap.test",
}),
}));
@@ -76,6 +78,7 @@ vi.mock("@/workflows/loom-import-job", () => ({
import {
dispatchLoomImportForVideo,
dispatchLoomImportJob,
+ dispatchLoomImports,
} from "@/lib/loom-import/dispatch";
import {
cancelLoomImportJob,
@@ -86,7 +89,10 @@ import {
resetFailedLoomImportItems,
resolveLoomImportJob,
} from "@/lib/loom-import/jobs";
-import { recoverLoomImportJobs } from "@/lib/loom-import/recovery";
+import {
+ LOOM_IMPORT_SILENT_ERROR,
+ recoverLoomImportJobs,
+} from "@/lib/loom-import/recovery";
import {
getLoomImportSnapshot,
LOOM_IMPORT_CURSOR_OVERLAP_MS,
@@ -133,18 +139,18 @@ const loomFetchMock = vi.fn(
const alias = key.replace("id", "v");
if (loomId === LOOM.private) {
data[alias] = { __typename: "PrivateVideo", id: loomId };
- } else if (RECORDED[loomId]) {
+ } else if (loomId === LOOM.missing) {
+ data[alias] = null;
+ } else {
data[alias] = {
__typename: "RegularUserVideo",
name: `Video ${loomId.slice(0, 4)}`,
- createdAt: RECORDED[loomId],
+ createdAt: RECORDED[loomId] ?? "2024-01-01T00:00:00.000Z",
thumbnails: {
default: `https://cdn.loom.com/sessions/thumbnails/${loomId}-00001.jpg`,
},
video_properties: { duration: 120.5, width: 1920, height: 1080 },
};
- } else {
- data[alias] = null;
}
}
return Response.json({ data });
@@ -210,6 +216,40 @@ async function failVideo(videoId: Video.VideoId, error: string) {
return dispatchLoomImportForVideo(videoId);
}
+function loomIds(count: number) {
+ return Array.from({ length: count }, () => randomUUID().replaceAll("-", ""));
+}
+
+async function readyJob(
+ owner: { ownerId: User.UserId; orgId: Organisation.OrganisationId },
+ count: number,
+) {
+ const { jobId } = await createLoomImportJob({
+ userId: owner.ownerId,
+ orgId: owner.orgId,
+ fileName: `${count}.csv`,
+ rows: loomIds(count).map((loomId, index) => ({
+ rowNumber: index + 2,
+ loomUrl: share(loomId),
+ })),
+ });
+ await resolveLoomImportJob(jobId, { fetchImpl: loomFetch });
+ expect(await prepareLoomImportJob(jobId)).toBe("importing");
+ return jobId;
+}
+
+async function inFlight() {
+ return database()
+ .select({
+ jobId: loomImportJobItems.jobId,
+ videoId: loomImportJobItems.videoId,
+ updatedAt: loomImportJobItems.updatedAt,
+ })
+ .from(loomImportJobItems)
+ .where(eq(loomImportJobItems.status, "importing"))
+ .orderBy(asc(loomImportJobItems.updatedAt), asc(loomImportJobItems.id));
+}
+
describe.runIf(Boolean(databaseUrl))(
"Loom CSV imports with an isolated MySQL database",
() => {
@@ -235,10 +275,13 @@ describe.runIf(Boolean(databaseUrl))(
fixture.database = undefined;
});
- beforeEach(() => {
+ beforeEach(async () => {
fixture.start.mockReset().mockResolvedValue({ runId: "run" });
fixture.concurrency = "2";
+ fixture.globalConcurrency = "12";
loomFetchMock.mockClear();
+ await database().delete(loomImportJobItems);
+ await database().delete(loomImportJobs);
});
it("checks every link, waits for Pro on the free plan, then imports with original dates in a bounded window", async () => {
@@ -302,12 +345,12 @@ describe.runIf(Boolean(databaseUrl))(
canStart: false,
isPro: false,
});
- expect(waiting?.counts).toMatchObject({
+ expect(waiting?.summary?.counts).toMatchObject({
ready: 4,
failed: 3,
skipped: 1,
});
- expect(waiting?.totalDuration).toBeCloseTo(482);
+ expect(waiting?.summary?.totalDuration).toBeCloseTo(482);
expect(fixture.start).not.toHaveBeenCalled();
expect(
await database().select().from(videos).where(eq(videos.orgId, orgId)),
@@ -423,7 +466,7 @@ describe.runIf(Boolean(databaseUrl))(
expect(state[7]?.status).toBe("importing");
const live = await getLoomImportSnapshot({ jobId, userId: ownerId });
- expect(live?.counts).toMatchObject({
+ expect(live?.summary?.counts).toMatchObject({
imported: 1,
importing: 2,
failed: 4,
@@ -457,12 +500,19 @@ describe.runIf(Boolean(databaseUrl))(
since:
(settledView?.cursor ?? 0) + LOOM_IMPORT_CURSOR_OVERLAP_MS + 1_000,
});
- expect(quiet?.items).toEqual([]);
+ expect(quiet?.items.map((item) => [item.row, item.status])).toEqual([
+ [4, "importing"],
+ [9, "importing"],
+ ]);
const lastPoll = Date.now() - 1_000;
await database()
.update(videoUploads)
.set({ processingProgress: 40, updatedAt: new Date() })
.where(eq(videoUploads.videoId, state[7]?.videoId as Video.VideoId));
+ await database()
+ .update(loomImportJobItems)
+ .set({ updatedAt: new Date() })
+ .where(eq(loomImportJobItems.id, state[0]?.id as string));
const delta = await getLoomImportSnapshot({
jobId,
userId: ownerId,
@@ -471,8 +521,12 @@ describe.runIf(Boolean(databaseUrl))(
expect(delta?.full).toBe(false);
expect(
delta?.items.map((item) => [item.row, item.status, item.progress]),
- ).toEqual([[9, "importing", 40]]);
- expect(delta?.counts.total).toBe(8);
+ ).toEqual([
+ [2, "imported", undefined],
+ [4, "importing", 0],
+ [9, "importing", 40],
+ ]);
+ expect(delta?.summary).toBeNull();
await finishVideo(state[2]?.videoId as Video.VideoId);
await finishVideo(state[7]?.videoId as Video.VideoId);
@@ -590,8 +644,8 @@ describe.runIf(Boolean(databaseUrl))(
await dispatchLoomImportForVideo(videoId);
expect((await items(jobId))[0]?.status).toBe("failed");
expect(
- (await getLoomImportSnapshot({ jobId, userId: ownerId }))?.counts
- .importing,
+ (await getLoomImportSnapshot({ jobId, userId: ownerId }))?.summary
+ ?.counts.importing,
).toBe(1);
await finishVideo(videoId);
@@ -601,7 +655,10 @@ describe.runIf(Boolean(databaseUrl))(
videoId,
});
const finished = await getLoomImportSnapshot({ jobId, userId: ownerId });
- expect(finished?.counts).toMatchObject({ imported: 1, failed: 0 });
+ expect(finished?.summary?.counts).toMatchObject({
+ imported: 1,
+ failed: 0,
+ });
expect(finished?.items[0]).toMatchObject({ status: "imported", videoId });
expect(await jobStatus(jobId)).toBe("completed");
const [summary] = await listLoomImportJobs({ userId: ownerId, orgId });
@@ -754,10 +811,215 @@ describe.runIf(Boolean(databaseUrl))(
const snapshotStartedAt = performance.now();
const snapshot = await getLoomImportSnapshot({ jobId, userId: ownerId });
const snapshotElapsed = performance.now() - snapshotStartedAt;
- expect(snapshot?.counts.checking).toBe(2000);
+ expect(snapshot?.summary?.counts.checking).toBe(2000);
console.info(
`[loom-import benchmark] create 2000 rows: ${elapsed.toFixed(0)}ms, full snapshot: ${snapshotElapsed.toFixed(0)}ms`,
);
});
+
+ it("shares a system-wide limit fairly between people and finishes every import", async () => {
+ fixture.globalConcurrency = "6";
+ fixture.concurrency = "4";
+ const [ana, ben, cy] = await Promise.all([
+ makeOrganization({ pro: true }),
+ makeOrganization({ pro: true }),
+ makeOrganization({ pro: true }),
+ ]);
+ if (!ana || !ben || !cy) throw new Error("Missing organizations.");
+ const creatorOf = new Map();
+ for (const [owner, count] of [
+ [ana, 10],
+ [ana, 10],
+ [ben, 10],
+ [cy, 10],
+ ] as const) {
+ creatorOf.set(await readyJob(owner, count), owner.ownerId);
+ }
+
+ expect((await dispatchLoomImports()).started).toBe(4);
+ expect((await dispatchLoomImports()).started).toBe(2);
+ expect((await dispatchLoomImports()).started).toBe(0);
+ const first = await inFlight();
+ expect(first).toHaveLength(6);
+ const perPerson = new Map();
+ for (const row of first) {
+ const creator = creatorOf.get(row.jobId) ?? "";
+ perPerson.set(creator, (perPerson.get(creator) ?? 0) + 1);
+ }
+ expect([...perPerson.values()]).toEqual([2, 2, 2]);
+
+ let most = 0;
+ let mostForOneImport = 0;
+ for (let step = 0; step < 100; step++) {
+ const running = await inFlight();
+ if (running.length === 0) break;
+ most = Math.max(most, running.length);
+ const perJob = new Map();
+ for (const row of running) {
+ perJob.set(row.jobId, (perJob.get(row.jobId) ?? 0) + 1);
+ }
+ mostForOneImport = Math.max(mostForOneImport, ...perJob.values());
+ await finishVideo(running[0]?.videoId as Video.VideoId);
+ }
+ expect(most).toBe(6);
+ expect(mostForOneImport).toBeLessThanOrEqual(4);
+ for (const jobId of creatorOf.keys()) {
+ expect(await jobStatus(jobId)).toBe("completed");
+ expect(
+ (await items(jobId)).every((item) => item.status === "complete"),
+ ).toBe(true);
+ }
+ expect(fixture.start).toHaveBeenCalledTimes(40);
+ });
+
+ it("starts nothing new while the media server is busy, then carries on", async () => {
+ fixture.concurrency = "4";
+ const owner = await makeOrganization({ pro: true });
+ const jobId = await readyJob(owner, 8);
+ expect((await dispatchLoomImportJob(jobId)).started).toBe(4);
+ const [busy, done] = await inFlight();
+ await database()
+ .update(videoUploads)
+ .set({ processingMessage: "Queued for Loom import processing..." })
+ .where(eq(videoUploads.videoId, busy?.videoId as Video.VideoId));
+
+ const paused = await finishVideo(done?.videoId as Video.VideoId);
+ expect(paused).toMatchObject({ started: 0, waiting: true, inFlight: 3 });
+ expect(await inFlight()).toHaveLength(3);
+
+ await database()
+ .update(videoUploads)
+ .set({ processingMessage: "Starting video processing..." })
+ .where(eq(videoUploads.videoId, busy?.videoId as Video.VideoId));
+ expect(await dispatchLoomImports()).toMatchObject({
+ started: 1,
+ waiting: false,
+ inFlight: 4,
+ });
+ });
+
+ it("keeps checking links Loom is rate limiting instead of failing them", async () => {
+ const owner = await makeOrganization({ pro: true });
+ const ids = loomIds(30);
+ const { jobId } = await createLoomImportJob({
+ userId: owner.ownerId,
+ orgId: owner.orgId,
+ fileName: "busy.csv",
+ rows: ids.map((loomId, index) => ({
+ rowNumber: index + 2,
+ loomUrl: share(loomId),
+ })),
+ });
+ let limited = true;
+ const rateLimited = vi.fn(
+ async (url: string | URL | Request, init?: RequestInit) =>
+ limited
+ ? new Response("slow down", {
+ status: 429,
+ headers: { "Retry-After": "0.01" },
+ })
+ : loomFetchMock(url, init),
+ ) as unknown as typeof fetch;
+
+ expect(
+ await resolveLoomImportJob(jobId, { fetchImpl: rateLimited }),
+ ).toEqual({ waiting: 30 });
+ expect(
+ (await items(jobId)).every((item) => item.status === "pending"),
+ ).toBe(true);
+ expect(await jobStatus(jobId)).toBe("checking");
+
+ limited = false;
+ expect(
+ await resolveLoomImportJob(jobId, {
+ fetchImpl: rateLimited,
+ budgetMs: -1,
+ }),
+ ).toEqual({ waiting: 30 });
+ expect(
+ await resolveLoomImportJob(jobId, { fetchImpl: rateLimited }),
+ ).toEqual({ waiting: 0 });
+ expect(
+ (await items(jobId)).every((item) => item.status === "ready"),
+ ).toBe(true);
+
+ const stuck = await createLoomImportJob({
+ userId: owner.ownerId,
+ orgId: owner.orgId,
+ fileName: "down.csv",
+ rows: [{ rowNumber: 2, loomUrl: share(loomIds(1)[0] as string) }],
+ });
+ limited = true;
+ expect(
+ await resolveLoomImportJob(stuck.jobId, {
+ fetchImpl: rateLimited,
+ giveUp: true,
+ }),
+ ).toEqual({ waiting: 0 });
+ expect((await items(stuck.jobId))[0]).toMatchObject({
+ status: "failed",
+ error: "Loom didn't respond. Try again in a minute.",
+ });
+ });
+
+ it("queues a video again once when its workflow can't start", async () => {
+ fixture.concurrency = "1";
+ const owner = await makeOrganization({ pro: true });
+ const jobId = await readyJob(owner, 2);
+ fixture.start.mockRejectedValueOnce(new Error("Workflow unavailable"));
+ const quiet = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ expect((await dispatchLoomImportJob(jobId)).started).toBe(1);
+ const [first] = await items(jobId);
+ const videoId = first?.videoId as Video.VideoId;
+ expect(first).toMatchObject({ status: "importing", error: null });
+ expect(fixture.start).toHaveBeenCalledTimes(2);
+ expect(fixture.start.mock.calls[0]?.[1]?.[0]).toMatchObject({
+ videoId,
+ });
+ expect(fixture.start.mock.calls[1]?.[1]?.[0]).toMatchObject({
+ videoId,
+ reuseExistingRawUpload: true,
+ });
+
+ fixture.start
+ .mockRejectedValueOnce(new Error("Workflow unavailable"))
+ .mockRejectedValueOnce(new Error("Workflow unavailable"));
+ await finishVideo(videoId);
+ const [, second] = await items(jobId);
+ expect(second).toMatchObject({
+ status: "failed",
+ error: "Loom import could not start.",
+ });
+ expect(await jobStatus(jobId)).toBe("completed");
+ quiet.mockRestore();
+ });
+
+ it("frees the slot of a video that stopped responding", async () => {
+ fixture.concurrency = "1";
+ const owner = await makeOrganization({ pro: true });
+ const jobId = await readyJob(owner, 2);
+ await dispatchLoomImportJob(jobId);
+ const [silent] = await inFlight();
+ await database()
+ .update(videoUploads)
+ .set({
+ phase: "processing",
+ updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000),
+ })
+ .where(eq(videoUploads.videoId, silent?.videoId as Video.VideoId));
+ fixture.start.mockClear();
+
+ const recovered = await recoverLoomImportJobs();
+ expect(recovered).toMatchObject({ silent: 1, started: 1 });
+ const [first, second] = await items(jobId);
+ expect(first).toMatchObject({
+ status: "failed",
+ error: LOOM_IMPORT_SILENT_ERROR,
+ });
+ expect(second?.status).toBe("importing");
+ });
},
);
diff --git a/apps/web/__tests__/unit/loom-import-job-view.test.ts b/apps/web/__tests__/unit/loom-import-job-view.test.ts
index 78cfea1a32d..7fcebd268b8 100644
--- a/apps/web/__tests__/unit/loom-import-job-view.test.ts
+++ b/apps/web/__tests__/unit/loom-import-job-view.test.ts
@@ -18,10 +18,10 @@ import {
} from "@/app/(org)/dashboard/import/loom/[jobId]/import-list";
import { useLoomImportJob } from "@/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job";
import {
- countLoomImportItems,
type LoomImportItemView,
type LoomImportJobStatus,
type LoomImportSnapshot,
+ summarizeLoomImportItems,
} from "@/lib/loom-import/status";
const item = (index: number, overrides: Partial = {}) =>
@@ -47,11 +47,13 @@ const snapshot = ({
full,
cursor,
status = "importing",
+ summary = full,
}: {
items: LoomImportItemView[];
full: boolean;
cursor: number;
status?: LoomImportJobStatus;
+ summary?: boolean;
}): LoomImportSnapshot => ({
job: {
id: "job-1",
@@ -66,10 +68,7 @@ const snapshot = ({
isPro: true,
isAdmin: true,
},
- counts: countLoomImportItems(items),
- totalDuration: 0,
- importedDuration: 0,
- owners: 1,
+ summary: summary ? summarizeLoomImportItems(items) : null,
items,
cursor,
full,
@@ -139,6 +138,7 @@ describe("useLoomImportJob", () => {
full: false,
cursor: 1_000,
status: "checking",
+ summary: true,
}),
}),
);
@@ -170,9 +170,14 @@ describe("useLoomImportJob", () => {
"ready",
"failed",
]);
+ expect(latest?.summary.counts).toMatchObject({
+ ready: 2,
+ failed: 1,
+ total: 3,
+ });
await act(async () => {
- vi.advanceTimersByTime(1_500);
+ vi.advanceTimersByTime(2_000);
});
expect(requests[1]?.url).toBe(
"/api/import/loom/jobs?jobId=job-1&since=2000",
@@ -195,6 +200,12 @@ describe("useLoomImportJob", () => {
"queued",
"failed",
]);
+ expect(latest?.summary.counts).toMatchObject({
+ importing: 1,
+ queued: 1,
+ failed: 1,
+ total: 3,
+ });
expect(requests[2]?.url).toBe("/api/import/loom/jobs?jobId=job-1");
requests[2]?.resolve(
diff --git a/apps/web/__tests__/unit/loom-import-schedule.test.ts b/apps/web/__tests__/unit/loom-import-schedule.test.ts
new file mode 100644
index 00000000000..8358725b6ab
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-import-schedule.test.ts
@@ -0,0 +1,237 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildLoomImportPlan,
+ decodeLoomImportRows,
+ detectLoomImportMapping,
+ encodeLoomImportRows,
+ LOOM_IMPORT_MAX_ROWS,
+ parseCsv,
+} from "@/lib/loom-import/csv";
+import { loomRetryDelayMs } from "@/lib/loom-import/loom-api";
+import {
+ addLoomImportLoad,
+ type LoomImportQueueJob,
+ type LoomImportQueueLoad,
+ pickLoomImportJob,
+} from "@/lib/loom-import/schedule";
+import { summarizeLoomImportItems } from "@/lib/loom-import/status";
+
+const job = (
+ id: string,
+ creatorId: string,
+ overrides: Partial = {},
+): LoomImportQueueJob => ({
+ id,
+ creatorId,
+ dispatchedAt: null,
+ startedAt: new Date(`2026-10-07T00:00:0${id.length}Z`),
+ ...overrides,
+});
+
+const emptyLoad = (): LoomImportQueueLoad => ({
+ jobs: new Map(),
+ creators: new Map(),
+});
+
+function allocate(
+ jobs: LoomImportQueueJob[],
+ slots: number,
+ perJob: number,
+ load = emptyLoad(),
+) {
+ const picks: string[] = [];
+ for (let slot = 0; slot < slots; slot++) {
+ const next = pickLoomImportJob(jobs, load, perJob);
+ if (!next) break;
+ picks.push(next.id);
+ addLoomImportLoad(load, next);
+ }
+ return picks;
+}
+
+describe("pickLoomImportJob", () => {
+ it("shares slots between people before giving anyone a second one", () => {
+ const jobs = [
+ job("a1", "ana"),
+ job("a2", "ana"),
+ job("a3", "ana"),
+ job("b1", "ben"),
+ job("c1", "cy"),
+ ];
+ const picks = allocate(jobs, 6, 4);
+ expect(picks.slice(0, 3).sort()).toEqual(["a1", "b1", "c1"]);
+ const perPerson = new Map();
+ for (const id of picks) {
+ const creator = jobs.find((candidate) => candidate.id === id)?.creatorId;
+ if (creator) perPerson.set(creator, (perPerson.get(creator) ?? 0) + 1);
+ }
+ expect([...perPerson.values()].sort()).toEqual([2, 2, 2]);
+ });
+
+ it("never gives one import more than its own limit", () => {
+ expect(allocate([job("a1", "ana")], 10, 4)).toEqual([
+ "a1",
+ "a1",
+ "a1",
+ "a1",
+ ]);
+ });
+
+ it("serves the import that waited longest when everyone is even", () => {
+ const jobs = [
+ job("served", "ana", { dispatchedAt: new Date("2026-10-07T10:00:00Z") }),
+ job("waiting", "ben", {
+ dispatchedAt: new Date("2026-10-07T09:00:00Z"),
+ }),
+ job("new", "cy"),
+ ];
+ expect(allocate(jobs, 1, 4)).toEqual(["new"]);
+ expect(allocate(jobs.slice(0, 2), 1, 4)).toEqual(["waiting"]);
+ });
+
+ it("rotates through 50 imports when only 12 can copy at once", () => {
+ const jobs = Array.from({ length: 50 }, (_, index) =>
+ job(`job-${String(index).padStart(2, "0")}`, `person-${index}`),
+ );
+ const served = new Map();
+ const load = emptyLoad();
+ let clock = 0;
+ const inFlight: string[] = [];
+ for (let step = 0; step < 500; step++) {
+ while (inFlight.length < 12) {
+ const next = pickLoomImportJob(jobs, load, 4);
+ if (!next) break;
+ addLoomImportLoad(load, next);
+ next.dispatchedAt = new Date(++clock);
+ inFlight.push(next.id);
+ served.set(next.id, (served.get(next.id) ?? 0) + 1);
+ }
+ const finished = inFlight.shift();
+ const owner = jobs.find((candidate) => candidate.id === finished);
+ if (owner) {
+ load.jobs.set(owner.id, (load.jobs.get(owner.id) ?? 1) - 1);
+ load.creators.set(
+ owner.creatorId,
+ (load.creators.get(owner.creatorId) ?? 1) - 1,
+ );
+ }
+ }
+ const counts = [...served.values()];
+ expect(served.size).toBe(50);
+ expect(Math.max(...counts) - Math.min(...counts)).toBeLessThanOrEqual(1);
+ });
+});
+
+describe("loomRetryDelayMs", () => {
+ it("backs off further when Loom says it is rate limiting", () => {
+ expect(loomRetryDelayMs(0, null, () => 0)).toBe(400);
+ expect(loomRetryDelayMs(2, null, () => 0)).toBe(1600);
+ expect(loomRetryDelayMs(0, "", () => 0)).toBe(2000);
+ expect(loomRetryDelayMs(1, "", () => 0)).toBe(4000);
+ expect(loomRetryDelayMs(0, "7", () => 0)).toBe(7000);
+ expect(loomRetryDelayMs(0, "600", () => 0)).toBe(10_000);
+ expect(loomRetryDelayMs(0, "7", () => 1)).toBe(10_500);
+ });
+});
+
+describe("compact import rows", () => {
+ it("round trips owners, spaces and canonical links", () => {
+ const table = parseCsv(
+ [
+ "Video URL,Creator Email,Space",
+ "https://www.loom.com/share/My-Demo-0dd0a01e10c742b28dbea75082c08635?sid=1,ana@acme.com,Sales",
+ "https://www.loom.com/share/31f430c1a1e744b8a7b6c18a26982c71,ana@acme.com,",
+ "2bff5385f32643ea9d065227ebfadd0d,,Sales",
+ ].join("\n"),
+ );
+ const plan = buildLoomImportPlan(table, detectLoomImportMapping(table), {
+ allowOwners: true,
+ });
+ const payload = encodeLoomImportRows(plan.rows);
+ expect(payload).toEqual({
+ owners: ["ana@acme.com"],
+ spaces: ["Sales"],
+ rows: [
+ [2, "0dd0a01e10c742b28dbea75082c08635", 0, 0],
+ [3, "31f430c1a1e744b8a7b6c18a26982c71", 0, -1],
+ [4, "2bff5385f32643ea9d065227ebfadd0d", -1, 0],
+ ],
+ });
+ expect(decodeLoomImportRows(JSON.parse(JSON.stringify(payload)))).toEqual([
+ {
+ rowNumber: 2,
+ loomUrl: "https://www.loom.com/share/0dd0a01e10c742b28dbea75082c08635",
+ ownerEmail: "ana@acme.com",
+ spaceName: "Sales",
+ },
+ {
+ rowNumber: 3,
+ loomUrl: "https://www.loom.com/share/31f430c1a1e744b8a7b6c18a26982c71",
+ ownerEmail: "ana@acme.com",
+ },
+ {
+ rowNumber: 4,
+ loomUrl: "https://www.loom.com/share/2bff5385f32643ea9d065227ebfadd0d",
+ spaceName: "Sales",
+ },
+ ]);
+ });
+
+ it("rejects payloads that are not import rows and keeps bad rows visible", () => {
+ expect(decodeLoomImportRows(null)).toBeNull();
+ expect(decodeLoomImportRows({ rows: [] })).toBeNull();
+ expect(
+ decodeLoomImportRows({
+ owners: ["a@b.co"],
+ spaces: [],
+ rows: [["x", 5, 9, "y"], "nope"],
+ }),
+ ).toEqual([
+ { rowNumber: 1, loomUrl: "" },
+ { rowNumber: 2, loomUrl: "" },
+ ]);
+ });
+
+ it("keeps a full 2,000 video import far below the 1 MB request limit", () => {
+ const rows = Array.from({ length: LOOM_IMPORT_MAX_ROWS }, (_, index) => ({
+ rowNumber: index + 2,
+ loomUrl: `https://www.loom.com/share/A-very-long-video-title-that-loom-adds-to-links-${index.toString(16).padStart(32, "0")}?sid=0123456789abcdef0123456789abcdef`,
+ ownerEmail: `person-${index % 200}@a-company-with-a-long-domain.example.com`,
+ spaceName: `Customer success and onboarding ${index % 40}`,
+ }));
+ const bytes = new TextEncoder().encode(
+ JSON.stringify(encodeLoomImportRows(rows)),
+ ).length;
+ const naive = new TextEncoder().encode(JSON.stringify(rows)).length;
+ expect(bytes).toBeLessThan(150_000);
+ expect(naive).toBeGreaterThan(bytes * 3);
+ });
+});
+
+describe("summarizeLoomImportItems", () => {
+ it("counts rows, owners and minutes the same way on server and client", () => {
+ expect(
+ summarizeLoomImportItems([
+ { status: "imported", duration: 60, email: "a@b.co" },
+ { status: "importing", duration: 30, email: "c@d.co" },
+ { status: "failed", duration: 90, email: "a@b.co" },
+ { status: "queued", duration: null, email: null },
+ ]),
+ ).toEqual({
+ counts: {
+ checking: 0,
+ ready: 0,
+ queued: 1,
+ importing: 1,
+ imported: 1,
+ failed: 1,
+ skipped: 0,
+ cancelled: 0,
+ total: 4,
+ },
+ totalDuration: 90,
+ importedDuration: 60,
+ owners: 2,
+ });
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts
index 748203b9a85..6aaff2fd410 100644
--- a/apps/web/__tests__/unit/loom-import-ui.test.ts
+++ b/apps/web/__tests__/unit/loom-import-ui.test.ts
@@ -332,10 +332,14 @@ describe("Loom importer component", () => {
expect(mocks.createJob).toHaveBeenCalledWith({
orgId: "org",
fileName: "loom-ids.csv",
- rows: [
- { rowNumber: 1, loomUrl: `https://www.loom.com/share/${first}` },
- { rowNumber: 2, loomUrl: `https://www.loom.com/share/${second}` },
- ],
+ rows: {
+ owners: [],
+ spaces: [],
+ rows: [
+ [1, first, -1, -1],
+ [2, second, -1, -1],
+ ],
+ },
});
await waitFor(() =>
expect(mocks.push).toHaveBeenCalledWith("/dashboard/import/loom/job-1"),
diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts
index a0ebd149d9d..d58ec46650f 100644
--- a/apps/web/__tests__/unit/media-processing-workflows.test.ts
+++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts
@@ -66,6 +66,7 @@ vi.mock("@/lib/ai-generation-entitlement", () => ({
vi.mock("workflow", () => ({
sleep: mocks.sleep,
FatalError: class FatalError extends Error {},
+ RetryableError: class RetryableError extends Error {},
}));
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
@@ -203,6 +204,46 @@ describe("media processing workflows", () => {
priority: "bulk",
});
expect(mocks.remove).not.toHaveBeenCalled();
+ expect(mocks.continueLoomImport.mock.calls).toEqual([["video"], ["video"]]);
+ });
+
+ it("waits for Loom instead of failing when Loom rate limits the download link", async () => {
+ let loomCalls = 0;
+ mocks.fetch.mockImplementation(async (url: string) => {
+ if (url.startsWith("https://www.loom.com/")) {
+ loomCalls++;
+ return loomCalls <= 4
+ ? new Response("slow down", { status: 429 })
+ : Response.json({ url: "https://cdn.loom.com/original.mp4" });
+ }
+ return Response.json({ jobId: "job-1", status: "queued" });
+ });
+ mocks.rows = [[video], [pending], [], [metadata]];
+ await expect(importLoomVideoWorkflow(payload)).resolves.toMatchObject({
+ success: true,
+ });
+ expect(loomCalls).toBe(5);
+ expect(mocks.sleep.mock.calls[0]).toEqual(["60s"]);
+ expect(mocks.write).toHaveBeenCalled();
+ });
+
+ it("gives a Loom video up after Loom stays unavailable for a while", async () => {
+ mocks.fetch.mockImplementation(async (url: string) =>
+ url.startsWith("https://www.loom.com/")
+ ? new Response("unavailable", { status: 503 })
+ : Response.json({ jobId: "job-1" }),
+ );
+ await expect(importLoomVideoWorkflow(payload)).rejects.toThrow(
+ "Loom is not responding right now",
+ );
+ expect(mocks.sleep.mock.calls).toEqual([
+ ["60s"],
+ ["120s"],
+ ["180s"],
+ ["240s"],
+ ["300s"],
+ ["360s"],
+ ]);
expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith("video");
});
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index 3f388c7c6cb..50b883471d0 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -5,35 +5,29 @@ import {
isLoomImportJobActive,
type LoomImportItemView,
type LoomImportJobStatus,
+ type LoomImportJobView,
type LoomImportSnapshot,
+ type LoomImportSummaryView,
loomImportWaitingStatus,
+ summarizeLoomImportItems,
} from "@/lib/loom-import/status";
-const ACTIVE_POLL_MS = 1500;
+const ACTIVE_POLL_MS = 2000;
+const IDLE_POLL_MS = 10_000;
const HIDDEN_POLL_MS = 15_000;
const UPGRADE_POLL_MS = 2500;
+const FULL_REFRESH_MS = 180_000;
const SNAPSHOT_TIMEOUT_MS = 20_000;
const RATE_WINDOW_MS = 180_000;
const RATE_MIN_SPAN_MS = 20_000;
-export type LoomImportSummary = Omit<
- LoomImportSnapshot,
- "items" | "cursor" | "full"
->;
+export type LoomImportSummary = LoomImportSummaryView & {
+ job: LoomImportJobView;
+};
type RateSample = { at: number; settled: number };
-function toSummary(snapshot: LoomImportSnapshot): LoomImportSummary {
- return {
- job: snapshot.job,
- counts: snapshot.counts,
- totalDuration: snapshot.totalDuration,
- importedDuration: snapshot.importedDuration,
- owners: snapshot.owners,
- };
-}
-
-function settledCount(summary: LoomImportSummary) {
+function settledCount(summary: LoomImportSummaryView) {
const { imported, failed, skipped, cancelled } = summary.counts;
return imported + failed + skipped + cancelled;
}
@@ -86,7 +80,13 @@ export function useLoomImportJob(
const orderRef = useRef([]);
const cursorRef = useRef(initial.cursor);
const jobStatusRef = useRef(initial.job.status);
+ const hasAllRef = useRef(initial.full);
+ const fullAtRef = useRef(initial.full ? Date.now() : 0);
+ const summaryRef = useRef(
+ initial.summary ?? summarizeLoomImportItems(initial.items),
+ );
const queueRef = useRef>(Promise.resolve());
+ const pollDelayRef = useRef(ACTIVE_POLL_MS);
const samplesRef = useRef([]);
if (itemsRef.current === null) {
itemsRef.current = new Map(initial.items.map((item) => [item.id, item]));
@@ -94,9 +94,10 @@ export function useLoomImportJob(
}
const [items, setItems] = useState(initial.items);
- const [summary, setSummary] = useState(() =>
- toSummary(initial),
- );
+ const [summary, setSummary] = useState(() => ({
+ job: initial.job,
+ ...summaryRef.current,
+ }));
const [rate, setRate] = useState(null);
const apply = useCallback((snapshot: LoomImportSnapshot) => {
@@ -111,11 +112,22 @@ export function useLoomImportJob(
orderRef.current = merged.order;
cursorRef.current = Math.max(cursorRef.current, snapshot.cursor);
jobStatusRef.current = snapshot.job.status;
+ if (snapshot.full) {
+ hasAllRef.current = true;
+ fullAtRef.current = Date.now();
+ }
+ if (snapshot.summary) {
+ summaryRef.current = snapshot.summary;
+ } else if (hasAllRef.current && merged.changed) {
+ summaryRef.current = summarizeLoomImportItems(map.values());
+ }
+ pollDelayRef.current = merged.changed
+ ? ACTIVE_POLL_MS
+ : Math.min(IDLE_POLL_MS, Math.round(pollDelayRef.current * 1.5));
- const next = toSummary(snapshot);
const now = Date.now();
const samples = samplesRef.current;
- samples.push({ at: now, settled: settledCount(next) });
+ samples.push({ at: now, settled: settledCount(summaryRef.current) });
while (
samples.length > 2 &&
(samples[0]?.at ?? now) < now - RATE_WINDOW_MS
@@ -130,7 +142,7 @@ export function useLoomImportJob(
? ((last.settled - first.settled) / span) * 60_000
: 0;
- setSummary(next);
+ setSummary({ job: snapshot.job, ...summaryRef.current });
setRate(perMinute > 0 ? perMinute : null);
if (merged.changed) {
const list: LoomImportItemView[] = [];
@@ -198,12 +210,14 @@ export function useLoomImportJob(
? HIDDEN_POLL_MS
: status === "awaiting_upgrade"
? UPGRADE_POLL_MS
- : ACTIVE_POLL_MS;
+ : pollDelayRef.current;
timer = window.setTimeout(tick, wait);
};
const tick = () => {
- fetchSnapshot()
+ const full =
+ hasAllRef.current && Date.now() - fullAtRef.current > FULL_REFRESH_MS;
+ fetchSnapshot(full)
.catch(() => undefined)
.finally(schedule);
};
diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
index b2c8b41fed4..2ab672a552a 100644
--- a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx
@@ -20,6 +20,7 @@ import {
type CsvTable,
countFilledRows,
detectLoomImportMapping,
+ encodeLoomImportRows,
LOOM_CSV_TEMPLATE,
LOOM_IMPORT_ISSUE_LABELS,
LOOM_IMPORT_MAX_ROWS,
@@ -404,7 +405,7 @@ const ReviewCard = ({
const result = await createLoomImportJobAction({
orgId,
fileName: source.name,
- rows: plan.rows,
+ rows: encodeLoomImportRows(plan.rows),
});
if (!result.ok) {
toast.error(result.error);
diff --git a/apps/web/app/api/import/loom/jobs/route.ts b/apps/web/app/api/import/loom/jobs/route.ts
index 306f0ebbb4b..1e5295d2e35 100644
--- a/apps/web/app/api/import/loom/jobs/route.ts
+++ b/apps/web/app/api/import/loom/jobs/route.ts
@@ -70,20 +70,24 @@ const Snapshot = Schema.Struct({
isPro: Schema.Boolean,
isAdmin: Schema.Boolean,
}),
- counts: Schema.Struct({
- checking: Schema.Number,
- ready: Schema.Number,
- queued: Schema.Number,
- importing: Schema.Number,
- imported: Schema.Number,
- failed: Schema.Number,
- skipped: Schema.Number,
- cancelled: Schema.Number,
- total: Schema.Number,
- }),
- totalDuration: Schema.Number,
- importedDuration: Schema.Number,
- owners: Schema.Number,
+ summary: Schema.NullOr(
+ Schema.Struct({
+ counts: Schema.Struct({
+ checking: Schema.Number,
+ ready: Schema.Number,
+ queued: Schema.Number,
+ importing: Schema.Number,
+ imported: Schema.Number,
+ failed: Schema.Number,
+ skipped: Schema.Number,
+ cancelled: Schema.Number,
+ total: Schema.Number,
+ }),
+ totalDuration: Schema.Number,
+ importedDuration: Schema.Number,
+ owners: Schema.Number,
+ }),
+ ),
items: Schema.Array(Item),
cursor: Schema.Number,
full: Schema.Boolean,
diff --git a/apps/web/lib/loom-import/csv.ts b/apps/web/lib/loom-import/csv.ts
index 980fda4907d..de5a05ede14 100644
--- a/apps/web/lib/loom-import/csv.ts
+++ b/apps/web/lib/loom-import/csv.ts
@@ -450,6 +450,73 @@ export function buildLoomImportPlan(
};
}
+export type LoomImportRowsPayload = {
+ owners: string[];
+ spaces: string[];
+ rows: Array<[number, string, number, number]>;
+};
+
+export function encodeLoomImportRows(
+ rows: LoomImportRowInput[],
+): LoomImportRowsPayload {
+ const owners: string[] = [];
+ const spaces: string[] = [];
+ const ownerIndex = new Map();
+ const spaceIndex = new Map();
+ const indexOf = (
+ value: string | undefined,
+ list: string[],
+ index: Map,
+ ) => {
+ if (!value) return -1;
+ let position = index.get(value);
+ if (position === undefined) {
+ position = list.length;
+ list.push(value);
+ index.set(value, position);
+ }
+ return position;
+ };
+ return {
+ owners,
+ spaces,
+ rows: rows.map((row) => [
+ row.rowNumber,
+ extractLoomVideoId(row.loomUrl) ?? row.loomUrl,
+ indexOf(row.ownerEmail, owners, ownerIndex),
+ indexOf(row.spaceName, spaces, spaceIndex),
+ ]),
+ };
+}
+
+export function decodeLoomImportRows(
+ payload: unknown,
+): LoomImportRowInput[] | null {
+ if (!payload || typeof payload !== "object") return null;
+ const { owners, spaces, rows } = payload as Partial;
+ if (!Array.isArray(owners) || !Array.isArray(spaces) || !Array.isArray(rows))
+ return null;
+ const pick = (list: unknown[], index: unknown) =>
+ typeof index === "number" && typeof list[index] === "string"
+ ? (list[index] as string)
+ : undefined;
+ return rows.map((row, index) => {
+ const [rowNumber, loomId, owner, space] = Array.isArray(row) ? row : [];
+ const id = typeof loomId === "string" ? loomId : "";
+ const ownerEmail = pick(owners, owner);
+ const spaceName = pick(spaces, space);
+ return {
+ rowNumber:
+ typeof rowNumber === "number" && Number.isInteger(rowNumber)
+ ? rowNumber
+ : index + 1,
+ loomUrl: LOOM_ID_PATTERN.test(id) ? loomShareUrl(id.toLowerCase()) : id,
+ ...(ownerEmail ? { ownerEmail } : {}),
+ ...(spaceName ? { spaceName } : {}),
+ };
+ });
+}
+
export const LOOM_IMPORT_ISSUE_LABELS: Record<
LoomImportRowIssue["reason"],
string
diff --git a/apps/web/lib/loom-import/dispatch.ts b/apps/web/lib/loom-import/dispatch.ts
index 4c14f863717..f66bd2a235a 100644
--- a/apps/web/lib/loom-import/dispatch.ts
+++ b/apps/web/lib/loom-import/dispatch.ts
@@ -2,6 +2,7 @@ import { db } from "@cap/database";
import { nanoId } from "@cap/database/helpers";
import {
importedVideos,
+ loomImportDispatchLocks,
loomImportJobItems,
loomImportJobs,
spaceVideos,
@@ -17,47 +18,103 @@ import { Option } from "effect";
import { start } from "workflow/api";
import { runWorkflowPromise } from "@/lib/workflow-runtime";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
+import { LOOM_IMPORT_JOB_STATUS_INDEX } from "./indexes";
+import {
+ addLoomImportLoad,
+ type LoomImportQueueJob,
+ type LoomImportQueueLoad,
+ pickLoomImportJob,
+} from "./schedule";
import { settleLoomImportItem } from "./status";
const DEFAULT_CONCURRENCY = 4;
const MAX_CONCURRENCY = 32;
-const WAITING_FOR_CAPACITY = "Queued for Loom import";
+const DEFAULT_GLOBAL_CONCURRENCY = 12;
+const MAX_GLOBAL_CONCURRENCY = 256;
+const LAUNCH_BURST = 4;
+const MAX_CLAIMS_PER_PASS = 32;
+const MAX_PASSES = 8;
+const LOCK_RETRY_ATTEMPTS = 6;
+const DISPATCH_LOCK_ID = "global";
+const OPEN_ITEM_STATUSES = ["pending", "ready", "importing"] as const;
+export const LOOM_IMPORT_WAITING_PREFIX = "Queued for Loom import";
+const START_RETRY_NOTE = "Starting this import again.";
+const START_FAILED_ERROR = "Loom import could not start.";
+const STORAGE_ERROR = "We couldn't prepare storage for this video.";
+const NO_OWNER_ERROR = "We couldn't find an owner for this video.";
type VideoInsert = typeof videos.$inferInsert;
+type Transaction = Parameters<
+ Parameters["transaction"]>[0]
+>[0];
type Writable = {
bucketId: NonNullable | null;
storageIntegrationId: NonNullable | null;
};
+type ActiveJob = LoomImportQueueJob & {
+ orgId: Organisation.OrganisationId;
+};
+
+type ClaimedItem = NonNullable>>;
+
type Launch = {
itemId: string;
+ jobId: string;
videoId: Video.VideoId;
ownerId: User.UserId;
rawFileKey: string;
bucketId: string | null;
loomVideoId: string;
reuseExistingRawUpload: boolean;
+ startRetried: boolean;
+};
+
+type PassContext = {
+ tx: Transaction;
+ now: Date;
+ storage: Map;
+ publicByOrg: Map;
};
+export type LoomImportDispatchOutcome = {
+ started: number;
+ startedByJob: Map;
+ completedJobs: Set;
+ inFlight: number;
+ waiting: boolean;
+};
+
+function configuredLimit(value: string | undefined, fallback: number) {
+ const configured = Number.parseInt(value ?? "", 10);
+ return Number.isFinite(configured) && configured > 0 ? configured : fallback;
+}
+
export function loomImportConcurrency() {
- const configured = Number.parseInt(
- serverEnv().LOOM_IMPORT_CONCURRENCY ?? "",
- 10,
+ return Math.min(
+ configuredLimit(serverEnv().LOOM_IMPORT_CONCURRENCY, DEFAULT_CONCURRENCY),
+ MAX_CONCURRENCY,
+ );
+}
+
+export function loomImportGlobalConcurrency() {
+ return Math.min(
+ configuredLimit(
+ serverEnv().LOOM_IMPORT_GLOBAL_CONCURRENCY,
+ DEFAULT_GLOBAL_CONCURRENCY,
+ ),
+ MAX_GLOBAL_CONCURRENCY,
);
- return Number.isFinite(configured) && configured > 0
- ? Math.min(configured, MAX_CONCURRENCY)
- : DEFAULT_CONCURRENCY;
}
-function isDuplicateKeyError(error: unknown): boolean {
+function hasMysqlError(error: unknown, errnos: number[]): boolean {
let current: unknown = error;
for (let depth = 0; current && depth < 4; depth++) {
if (
typeof current === "object" &&
- ("code" in current || "errno" in current) &&
- ((current as { code?: unknown }).code === "ER_DUP_ENTRY" ||
- (current as { errno?: unknown }).errno === 1062)
+ "errno" in current &&
+ errnos.includes((current as { errno?: unknown }).errno as number)
) {
return true;
}
@@ -66,6 +123,14 @@ function isDuplicateKeyError(error: unknown): boolean {
return false;
}
+function isDuplicateKeyError(error: unknown) {
+ return hasMysqlError(error, [1062]);
+}
+
+function isRetryableLockError(error: unknown) {
+ return hasMysqlError(error, [1205, 1213, 1305]);
+}
+
export function rawFileKeyFor(ownerId: string, videoId: string) {
return `${ownerId}/${videoId}/raw-upload.mp4`;
}
@@ -75,7 +140,8 @@ async function writableFor(
ownerId: User.UserId,
orgId: Organisation.OrganisationId,
) {
- if (cache.has(ownerId)) return cache.get(ownerId) ?? null;
+ const key = `${orgId}:${ownerId}`;
+ if (cache.has(key)) return cache.get(key) ?? null;
const writable = await Storage.getWritableAccessForUser(ownerId, orgId)
.pipe(runWorkflowPromise)
.then(
@@ -92,26 +158,104 @@ async function writableFor(
return null;
},
);
- cache.set(ownerId, writable);
+ cache.set(key, writable);
return writable;
}
-export async function dispatchLoomImportJob(jobId: string) {
- const [job] = await db()
- .select({ orgId: loomImportJobs.orgId, status: loomImportJobs.status })
- .from(loomImportJobs)
- .where(eq(loomImportJobs.id, jobId))
- .limit(1);
- if (!job || job.status !== "importing") {
- return { started: 0, completed: job?.status === "completed" };
- }
+async function isPublicFor(
+ cache: Map,
+ orgId: Organisation.OrganisationId,
+) {
+ const cached = cache.get(orgId);
+ if (cached !== undefined) return cached;
+ const isPublic = await getNewVideoPublic(orgId);
+ cache.set(orgId, isPublic);
+ return isPublic;
+}
- const limit = loomImportConcurrency();
- const isPublic = await getNewVideoPublic(job.orgId);
- const storage = new Map();
- const upcoming = await db()
- .select({ ownerId: loomImportJobItems.ownerId })
+async function settleInFlight(context: PassContext) {
+ const { tx, now } = context;
+ const rows = await tx
+ .select({
+ id: loomImportJobItems.id,
+ jobId: loomImportJobItems.jobId,
+ creatorId: loomImportJobs.createdById,
+ videoId: loomImportJobItems.videoId,
+ videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
+ uploadVideoId: videoUploads.videoId,
+ uploadPhase: videoUploads.phase,
+ uploadMessage: videoUploads.processingMessage,
+ uploadError: videoUploads.processingError,
+ })
.from(loomImportJobItems)
+ .innerJoin(loomImportJobs, eq(loomImportJobs.id, loomImportJobItems.jobId))
+ .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .leftJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(eq(loomImportJobItems.status, "importing"));
+
+ const load: LoomImportQueueLoad = { jobs: new Map(), creators: new Map() };
+ const completedIds: string[] = [];
+ const failures = new Map();
+ let inFlight = 0;
+ let waiting = false;
+ for (const row of rows) {
+ const settled = settleLoomImportItem({
+ status: "importing",
+ videoExists: Boolean(row.videoId && row.videoExists),
+ uploadPhase: row.uploadVideoId ? row.uploadPhase : null,
+ uploadError: row.uploadError,
+ });
+ if (settled) {
+ if (settled.status === "complete") completedIds.push(row.id);
+ else
+ failures.set(settled.error, [
+ ...(failures.get(settled.error) ?? []),
+ row.id,
+ ]);
+ continue;
+ }
+ inFlight++;
+ addLoomImportLoad(load, { id: row.jobId, creatorId: row.creatorId });
+ if (row.uploadMessage?.startsWith(LOOM_IMPORT_WAITING_PREFIX)) {
+ waiting = true;
+ }
+ }
+
+ if (completedIds.length > 0) {
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "complete", error: null, updatedAt: now })
+ .where(inArray(loomImportJobItems.id, completedIds));
+ }
+ for (const [error, ids] of failures) {
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "failed", error, updatedAt: now })
+ .where(inArray(loomImportJobItems.id, ids));
+ }
+
+ return { load, inFlight, waiting };
+}
+
+async function claimNextItem(tx: Transaction, jobId: string) {
+ const [item] = await tx
+ .select({
+ id: loomImportJobItems.id,
+ ownerId: loomImportJobItems.ownerId,
+ spaceId: loomImportJobItems.spaceId,
+ videoId: loomImportJobItems.videoId,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ title: loomImportJobItems.title,
+ loomCreatedAt: loomImportJobItems.loomCreatedAt,
+ durationSeconds: loomImportJobItems.durationSeconds,
+ width: loomImportJobItems.width,
+ height: loomImportJobItems.height,
+ error: loomImportJobItems.error,
+ })
+ .from(loomImportJobItems, { forceIndex: LOOM_IMPORT_JOB_STATUS_INDEX })
.where(
and(
eq(loomImportJobItems.jobId, jobId),
@@ -119,368 +263,435 @@ export async function dispatchLoomImportJob(jobId: string) {
),
)
.orderBy(asc(loomImportJobItems.rowNumber))
- .limit(limit * 2);
- for (const ownerId of new Set(upcoming.map((row) => row.ownerId))) {
- if (ownerId) await writableFor(storage, ownerId, job.orgId);
- }
+ .limit(1)
+ .for("update");
+ return item;
+}
- const outcome = await db().transaction(async (tx) => {
- const [locked] = await tx
- .select({ status: loomImportJobs.status })
- .from(loomImportJobs)
- .where(eq(loomImportJobs.id, jobId))
- .for("update");
- if (!locked || locked.status !== "importing") {
- return { launches: [] as Launch[], completed: false };
- }
+async function launchItem(
+ context: PassContext,
+ job: ActiveJob,
+ item: ClaimedItem,
+): Promise {
+ const { tx, now } = context;
+ const startRetried = item.error === START_RETRY_NOTE;
+ if (!item.ownerId || !item.loomVideoId) {
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "failed", error: NO_OWNER_ERROR, updatedAt: now })
+ .where(eq(loomImportJobItems.id, item.id));
+ return null;
+ }
- const inFlight = await tx
+ if (item.videoId) {
+ const [existing] = await tx
.select({
- id: loomImportJobItems.id,
- videoId: loomImportJobItems.videoId,
- videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
- uploadVideoId: videoUploads.videoId,
- uploadPhase: videoUploads.phase,
- uploadMessage: videoUploads.processingMessage,
- uploadError: videoUploads.processingError,
+ id: videos.id,
+ bucket: videos.bucket,
+ ownerId: videos.ownerId,
})
- .from(loomImportJobItems)
- .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
- .leftJoin(
- videoUploads,
- eq(videoUploads.videoId, loomImportJobItems.videoId),
+ .from(videos)
+ .where(eq(videos.id, item.videoId))
+ .limit(1);
+ if (existing) {
+ const rawFileKey = rawFileKeyFor(existing.ownerId, existing.id);
+ await tx
+ .insert(videoUploads)
+ .values({
+ videoId: existing.id,
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Retrying Loom import...",
+ rawFileKey,
+ })
+ .onDuplicateKeyUpdate({
+ set: {
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Retrying Loom import...",
+ processingError: null,
+ rawFileKey,
+ updatedAt: now,
+ },
+ });
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "importing", error: null, updatedAt: now })
+ .where(eq(loomImportJobItems.id, item.id));
+ return {
+ itemId: item.id,
+ jobId: job.id,
+ videoId: existing.id,
+ ownerId: existing.ownerId,
+ rawFileKey,
+ bucketId: existing.bucket,
+ loomVideoId: item.loomVideoId,
+ reuseExistingRawUpload: true,
+ startRetried,
+ };
+ }
+ }
+
+ const writable = await writableFor(context.storage, item.ownerId, job.orgId);
+ if (!writable) {
+ await tx
+ .update(loomImportJobItems)
+ .set({ status: "failed", error: STORAGE_ERROR, updatedAt: now })
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, job.id),
+ eq(loomImportJobItems.ownerId, item.ownerId),
+ eq(loomImportJobItems.status, "ready"),
+ ),
+ );
+ return null;
+ }
+
+ const isPublic = await isPublicFor(context.publicByOrg, job.orgId);
+ const videoId = Video.VideoId.make(nanoId());
+ const ownerId = item.ownerId;
+ const loomVideoId = item.loomVideoId;
+ const rawFileKey = rawFileKeyFor(ownerId, videoId);
+ const fallbackName = `Loom Import - ${now.toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`;
+ try {
+ await tx.transaction(async (savepoint) => {
+ await savepoint.insert(videos).values({
+ id: videoId,
+ name: item.title || fallbackName,
+ ownerId,
+ orgId: job.orgId,
+ source: { type: "webMP4" as const },
+ bucket: writable.bucketId,
+ storageIntegrationId: writable.storageIntegrationId,
+ public: isPublic,
+ ...(item.durationSeconds ? { duration: item.durationSeconds } : {}),
+ ...(item.width ? { width: item.width } : {}),
+ ...(item.height ? { height: item.height } : {}),
+ ...(item.loomCreatedAt
+ ? {
+ metadata: {
+ customCreatedAt: item.loomCreatedAt.toISOString(),
+ },
+ }
+ : {}),
+ });
+ await savepoint.insert(videoUploads).values({
+ videoId,
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Importing from Loom...",
+ rawFileKey,
+ });
+ await savepoint.insert(importedVideos).values({
+ id: videoId,
+ orgId: job.orgId,
+ source: "loom",
+ sourceId: loomVideoId,
+ });
+ if (item.spaceId) {
+ await savepoint.insert(spaceVideos).values({
+ id: nanoId(),
+ spaceId: item.spaceId,
+ videoId,
+ addedById: ownerId,
+ });
+ }
+ await savepoint
+ .update(loomImportJobItems)
+ .set({
+ status: "importing",
+ videoId,
+ error: null,
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, item.id));
+ });
+ } catch (error) {
+ if (!isDuplicateKeyError(error)) throw error;
+ const [existing] = await tx
+ .select({ id: importedVideos.id })
+ .from(importedVideos)
+ .where(
+ and(
+ eq(importedVideos.orgId, job.orgId),
+ eq(importedVideos.source, "loom"),
+ eq(importedVideos.sourceId, loomVideoId),
+ ),
)
+ .limit(1);
+ await tx
+ .update(loomImportJobItems)
+ .set({
+ status: "skipped",
+ videoId: existing ? Video.VideoId.make(existing.id) : null,
+ error: "Already in Cap.",
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobItems.id, item.id));
+ return null;
+ }
+
+ return {
+ itemId: item.id,
+ jobId: job.id,
+ videoId,
+ ownerId,
+ rawFileKey,
+ bucketId: writable.bucketId,
+ loomVideoId,
+ reuseExistingRawUpload: false,
+ startRetried,
+ };
+}
+
+async function startLaunch(launch: Launch) {
+ try {
+ await start(importLoomVideoWorkflow, [
+ {
+ videoId: launch.videoId,
+ userId: launch.ownerId,
+ rawFileKey: launch.rawFileKey,
+ bucketId: launch.bucketId,
+ loomVideoId: launch.loomVideoId,
+ ...(launch.reuseExistingRawUpload
+ ? { reuseExistingRawUpload: true }
+ : {}),
+ },
+ ]);
+ return true;
+ } catch (error) {
+ console.error("[loom-import] Could not start import workflow", {
+ videoId: launch.videoId,
+ retried: launch.startRetried,
+ error,
+ });
+ }
+
+ const now = new Date();
+ if (!launch.startRetried) {
+ await db()
+ .update(loomImportJobItems)
+ .set({ status: "ready", error: START_RETRY_NOTE, updatedAt: now })
.where(
and(
- eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.id, launch.itemId),
eq(loomImportJobItems.status, "importing"),
),
);
+ return false;
+ }
+ await db()
+ .update(videoUploads)
+ .set({
+ phase: "error",
+ processingError: START_FAILED_ERROR,
+ processingMessage: "Loom import failed",
+ updatedAt: now,
+ })
+ .where(eq(videoUploads.videoId, launch.videoId));
+ await db()
+ .update(loomImportJobItems)
+ .set({ status: "failed", error: START_FAILED_ERROR, updatedAt: now })
+ .where(eq(loomImportJobItems.id, launch.itemId));
+ return false;
+}
+
+async function runDispatchPass() {
+ const perJob = loomImportConcurrency();
+ const globalLimit = loomImportGlobalConcurrency();
+ return db().transaction(async (tx) => {
const now = new Date();
- const completedIds: string[] = [];
- const failures = new Map();
- let active = 0;
- let waiting = 0;
- for (const row of inFlight) {
- const settled = settleLoomImportItem({
- status: "importing",
- videoExists: Boolean(row.videoId && row.videoExists),
- uploadPhase: row.uploadVideoId ? row.uploadPhase : null,
- uploadError: row.uploadError,
- });
- if (settled?.status === "complete") {
- completedIds.push(row.id);
- } else if (settled) {
- failures.set(settled.error, [
- ...(failures.get(settled.error) ?? []),
- row.id,
- ]);
- } else {
- active++;
- if (row.uploadMessage?.startsWith(WAITING_FOR_CAPACITY)) waiting++;
- }
- }
+ const context: PassContext = {
+ tx,
+ now,
+ storage: new Map(),
+ publicByOrg: new Map(),
+ };
+ await tx
+ .insert(loomImportDispatchLocks)
+ .values({ id: DISPATCH_LOCK_ID, lockedAt: now })
+ .onDuplicateKeyUpdate({ set: { lockedAt: now } });
- if (completedIds.length > 0) {
- await tx
- .update(loomImportJobItems)
- .set({ status: "complete", error: null, updatedAt: now })
- .where(inArray(loomImportJobItems.id, completedIds));
- }
- for (const [error, ids] of failures) {
- await tx
- .update(loomImportJobItems)
- .set({ status: "failed", error, updatedAt: now })
- .where(inArray(loomImportJobItems.id, ids));
- }
+ // Cancelling locks its job row and then its items, so the job rows are
+ // locked here before any item. They are locked by primary key: a locking
+ // scan of the status index deadlocks with cancel updating that index.
+ const activeIds = await tx
+ .select({ id: loomImportJobs.id })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.status, "importing"));
+ const locked = activeIds.length
+ ? await tx
+ .select({
+ id: loomImportJobs.id,
+ status: loomImportJobs.status,
+ orgId: loomImportJobs.orgId,
+ creatorId: loomImportJobs.createdById,
+ dispatchedAt: loomImportJobs.dispatchedAt,
+ startedAt: loomImportJobs.startedAt,
+ })
+ .from(loomImportJobs)
+ .where(
+ inArray(
+ loomImportJobs.id,
+ activeIds.map((job) => job.id),
+ ),
+ )
+ .orderBy(asc(loomImportJobs.id))
+ .for("update")
+ : [];
+ const active: ActiveJob[] = locked.filter(
+ (job) => job.status === "importing",
+ );
+ const { load, inFlight, waiting } = await settleInFlight(context);
+
+ const candidates = new Map(active.map((job) => [job.id, job]));
const launches: Launch[] = [];
- while (waiting === 0 && active + launches.length < limit) {
- const claimed = await tx
- .select({
- id: loomImportJobItems.id,
- ownerId: loomImportJobItems.ownerId,
- spaceId: loomImportJobItems.spaceId,
- videoId: loomImportJobItems.videoId,
- loomVideoId: loomImportJobItems.loomVideoId,
- title: loomImportJobItems.title,
- loomCreatedAt: loomImportJobItems.loomCreatedAt,
- durationSeconds: loomImportJobItems.durationSeconds,
- width: loomImportJobItems.width,
- height: loomImportJobItems.height,
- })
- .from(loomImportJobItems)
- .where(
- and(
- eq(loomImportJobItems.jobId, jobId),
- eq(loomImportJobItems.status, "ready"),
- ),
- )
- .orderBy(asc(loomImportJobItems.rowNumber))
- .limit(limit - active - launches.length)
- .for("update");
- if (claimed.length === 0) break;
-
- for (const item of claimed) {
- if (!item.ownerId || !item.loomVideoId) {
- await tx
- .update(loomImportJobItems)
- .set({
- status: "failed",
- error: "We couldn't find an owner for this video.",
- updatedAt: now,
- })
- .where(eq(loomImportJobItems.id, item.id));
- continue;
- }
-
- if (item.videoId) {
- const [existing] = await tx
- .select({
- id: videos.id,
- bucket: videos.bucket,
- ownerId: videos.ownerId,
- })
- .from(videos)
- .where(eq(videos.id, item.videoId))
- .limit(1);
- if (existing) {
- const rawFileKey = rawFileKeyFor(existing.ownerId, existing.id);
- await tx
- .insert(videoUploads)
- .values({
- videoId: existing.id,
- phase: "uploading",
- processingProgress: 0,
- processingMessage: "Retrying Loom import...",
- rawFileKey,
- })
- .onDuplicateKeyUpdate({
- set: {
- phase: "uploading",
- processingProgress: 0,
- processingMessage: "Retrying Loom import...",
- processingError: null,
- rawFileKey,
- updatedAt: now,
- },
- });
- await tx
- .update(loomImportJobItems)
- .set({ status: "importing", error: null, updatedAt: now })
- .where(eq(loomImportJobItems.id, item.id));
- launches.push({
- itemId: item.id,
- videoId: existing.id,
- ownerId: existing.ownerId,
- rawFileKey,
- bucketId: existing.bucket,
- loomVideoId: item.loomVideoId,
- reuseExistingRawUpload: true,
- });
- continue;
- }
- }
-
- const writable = await writableFor(storage, item.ownerId, job.orgId);
- if (!writable) {
- await tx
- .update(loomImportJobItems)
- .set({
- status: "failed",
- error: "We couldn't prepare storage for this video.",
- updatedAt: now,
- })
- .where(eq(loomImportJobItems.id, item.id));
- continue;
- }
-
- const videoId = Video.VideoId.make(nanoId());
- const ownerId = item.ownerId;
- const loomVideoId = item.loomVideoId;
- const rawFileKey = rawFileKeyFor(ownerId, videoId);
- const fallbackName = `Loom Import - ${now.toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`;
- try {
- await tx.transaction(async (savepoint) => {
- await savepoint.insert(videos).values({
- id: videoId,
- name: item.title || fallbackName,
- ownerId,
- orgId: job.orgId,
- source: { type: "webMP4" as const },
- bucket: writable.bucketId,
- storageIntegrationId: writable.storageIntegrationId,
- public: isPublic,
- ...(item.durationSeconds
- ? { duration: item.durationSeconds }
- : {}),
- ...(item.width ? { width: item.width } : {}),
- ...(item.height ? { height: item.height } : {}),
- ...(item.loomCreatedAt
- ? {
- metadata: {
- customCreatedAt: item.loomCreatedAt.toISOString(),
- },
- }
- : {}),
- });
- await savepoint.insert(videoUploads).values({
- videoId,
- phase: "uploading",
- processingProgress: 0,
- processingMessage: "Importing from Loom...",
- rawFileKey,
- });
- await savepoint.insert(importedVideos).values({
- id: videoId,
- orgId: job.orgId,
- source: "loom",
- sourceId: loomVideoId,
- });
- if (item.spaceId) {
- await savepoint.insert(spaceVideos).values({
- id: nanoId(),
- spaceId: item.spaceId,
- videoId,
- addedById: ownerId,
- });
- }
- await savepoint
- .update(loomImportJobItems)
- .set({
- status: "importing",
- videoId,
- error: null,
- updatedAt: now,
- })
- .where(eq(loomImportJobItems.id, item.id));
- });
- } catch (error) {
- if (!isDuplicateKeyError(error)) throw error;
- const [existing] = await tx
- .select({ id: importedVideos.id })
- .from(importedVideos)
- .where(
- and(
- eq(importedVideos.orgId, job.orgId),
- eq(importedVideos.source, "loom"),
- eq(importedVideos.sourceId, loomVideoId),
- ),
- )
- .limit(1);
- await tx
- .update(loomImportJobItems)
- .set({
- status: "skipped",
- videoId: existing ? Video.VideoId.make(existing.id) : null,
- error: "Already in Cap.",
- updatedAt: now,
- })
- .where(eq(loomImportJobItems.id, item.id));
- continue;
- }
-
- launches.push({
- itemId: item.id,
- videoId,
- ownerId,
- rawFileKey,
- bucketId: writable.bucketId,
- loomVideoId,
- reuseExistingRawUpload: false,
- });
+ const room = waiting
+ ? 0
+ : Math.max(0, Math.min(LAUNCH_BURST, globalLimit - inFlight));
+ let claims = 0;
+ while (launches.length < room && claims < MAX_CLAIMS_PER_PASS) {
+ const job = pickLoomImportJob(candidates.values(), load, perJob);
+ if (!job) break;
+ claims++;
+ const item = await claimNextItem(tx, job.id);
+ if (!item) {
+ candidates.delete(job.id);
+ continue;
}
+ const launch = await launchItem(context, job, item);
+ if (!launch) continue;
+ launches.push(launch);
+ addLoomImportLoad(load, job);
}
- let completed = false;
- if (active + launches.length === 0) {
- const [open] = await tx
- .select({ count: sql`COUNT(*)`.mapWith(Number) })
- .from(loomImportJobItems)
+ const launchedJobs = new Set(launches.map((launch) => launch.jobId));
+ const idle = active
+ .map((job) => job.id)
+ .filter((id) => !launchedJobs.has(id) && !load.jobs.get(id));
+ const finished = idle.length
+ ? await tx
+ .select({ id: loomImportJobs.id })
+ .from(loomImportJobs)
+ .where(
+ and(
+ inArray(loomImportJobs.id, idle),
+ eq(loomImportJobs.status, "importing"),
+ ...OPEN_ITEM_STATUSES.map(
+ (status) =>
+ sql`NOT EXISTS (SELECT 1 FROM ${loomImportJobItems} FORCE INDEX (${sql.raw(LOOM_IMPORT_JOB_STATUS_INDEX)}) WHERE ${loomImportJobItems.jobId} = ${loomImportJobs.id} AND ${loomImportJobItems.status} = ${status})`,
+ ),
+ ),
+ )
+ : [];
+ const completedJobs = new Set(finished.map((job) => job.id));
+ if (completedJobs.size > 0) {
+ await tx
+ .update(loomImportJobs)
+ .set({ status: "completed", completedAt: now, updatedAt: now })
.where(
and(
- eq(loomImportJobItems.jobId, jobId),
- inArray(loomImportJobItems.status, [
- "pending",
- "ready",
- "importing",
- ]),
+ inArray(loomImportJobs.id, Array.from(completedJobs)),
+ eq(loomImportJobs.status, "importing"),
),
);
- completed = (open?.count ?? 0) === 0;
+ }
+ let order = 0;
+ for (const jobId of launchedJobs) {
+ await tx
+ .update(loomImportJobs)
+ .set({
+ dispatchedAt: new Date(now.getTime() + order++),
+ updatedAt: now,
+ })
+ .where(eq(loomImportJobs.id, jobId));
}
- await tx
- .update(loomImportJobs)
- .set(
- completed
- ? { status: "completed", completedAt: now, updatedAt: now }
- : { updatedAt: now },
- )
- .where(eq(loomImportJobs.id, jobId));
-
- return { launches, completed };
+ return {
+ launches,
+ completedJobs,
+ inFlight: inFlight + launches.length,
+ waiting,
+ unfinished:
+ claims >= MAX_CLAIMS_PER_PASS &&
+ launches.length < room &&
+ candidates.size > 0,
+ };
});
+}
- for (const launch of outcome.launches) {
+async function runDispatchPassWithRetry() {
+ for (let attempt = 1; ; attempt++) {
try {
- await start(importLoomVideoWorkflow, [
- {
- videoId: launch.videoId,
- userId: launch.ownerId,
- rawFileKey: launch.rawFileKey,
- bucketId: launch.bucketId,
- loomVideoId: launch.loomVideoId,
- ...(launch.reuseExistingRawUpload
- ? { reuseExistingRawUpload: true }
- : {}),
- },
- ]);
+ return await runDispatchPass();
} catch (error) {
- console.error("[loom-import] Could not start import workflow", {
- videoId: launch.videoId,
- error,
- });
- const now = new Date();
- await db()
- .update(videoUploads)
- .set({
- phase: "error",
- processingError: "Loom import could not start.",
- processingMessage: "Loom import failed",
- updatedAt: now,
- })
- .where(eq(videoUploads.videoId, launch.videoId));
- await db()
- .update(loomImportJobItems)
- .set({
- status: "failed",
- error: "Loom import could not start.",
- updatedAt: now,
- })
- .where(eq(loomImportJobItems.id, launch.itemId));
+ if (attempt >= LOCK_RETRY_ATTEMPTS || !isRetryableLockError(error)) {
+ throw error;
+ }
+ await new Promise((resolve) =>
+ setTimeout(resolve, 25 * 2 ** attempt + Math.random() * 50),
+ );
}
}
+}
- return { started: outcome.launches.length, completed: outcome.completed };
+export async function dispatchLoomImports(): Promise {
+ const outcome: LoomImportDispatchOutcome = {
+ started: 0,
+ startedByJob: new Map(),
+ completedJobs: new Set(),
+ inFlight: 0,
+ waiting: false,
+ };
+ for (let passes = 0; passes < MAX_PASSES; passes++) {
+ const pass = await runDispatchPassWithRetry();
+ let failedStarts = 0;
+ for (const launch of pass.launches) {
+ if (!(await startLaunch(launch))) {
+ failedStarts++;
+ continue;
+ }
+ outcome.started++;
+ outcome.startedByJob.set(
+ launch.jobId,
+ (outcome.startedByJob.get(launch.jobId) ?? 0) + 1,
+ );
+ }
+ for (const jobId of pass.completedJobs) outcome.completedJobs.add(jobId);
+ outcome.inFlight = pass.inFlight - failedStarts;
+ outcome.waiting = pass.waiting;
+ if (!pass.unfinished && failedStarts === 0) break;
+ }
+ return outcome;
+}
+
+export async function dispatchLoomImportJob(jobId: string) {
+ const outcome = await dispatchLoomImports();
+ const [job] = await db()
+ .select({ status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(eq(loomImportJobs.id, jobId))
+ .limit(1);
+ return {
+ started: outcome.startedByJob.get(jobId) ?? 0,
+ completed: job?.status === "completed",
+ };
}
export async function dispatchLoomImportForVideo(videoId: string) {
const items = await db()
.select({
id: loomImportJobItems.id,
- jobId: loomImportJobItems.jobId,
status: loomImportJobItems.status,
error: loomImportJobItems.error,
- jobStatus: loomImportJobs.status,
videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
uploadVideoId: videoUploads.videoId,
uploadPhase: videoUploads.phase,
uploadError: videoUploads.processingError,
})
.from(loomImportJobItems)
- .innerJoin(loomImportJobs, eq(loomImportJobs.id, loomImportJobItems.jobId))
.leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
.leftJoin(
videoUploads,
@@ -494,10 +705,10 @@ export async function dispatchLoomImportForVideo(videoId: string) {
);
if (items.length === 0) return null;
- const jobsToDispatch = new Set();
+ let inFlight = false;
for (const item of items) {
- if (item.jobStatus === "importing" && item.status === "importing") {
- jobsToDispatch.add(item.jobId);
+ if (item.status === "importing") {
+ inFlight = true;
continue;
}
const settled = settleLoomImportItem({
@@ -527,11 +738,7 @@ export async function dispatchLoomImportForVideo(videoId: string) {
);
}
- let outcome: { started: number; completed: boolean } | null = null;
- for (const jobId of jobsToDispatch) {
- outcome = await dispatchLoomImportJob(jobId);
- }
- return outcome;
+ return inFlight ? dispatchLoomImports() : null;
}
export async function isLoomImportedVideo(videoId: Video.VideoId) {
diff --git a/apps/web/lib/loom-import/indexes.ts b/apps/web/lib/loom-import/indexes.ts
new file mode 100644
index 00000000000..ca033e10a2e
--- /dev/null
+++ b/apps/web/lib/loom-import/indexes.ts
@@ -0,0 +1,2 @@
+export const LOOM_IMPORT_JOB_STATUS_INDEX = "job_status_row_idx";
+export const LOOM_IMPORT_JOB_UPDATED_INDEX = "job_updated_idx";
diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts
index 7a373764174..d0b1a8564fc 100644
--- a/apps/web/lib/loom-import/jobs.ts
+++ b/apps/web/lib/loom-import/jobs.ts
@@ -25,6 +25,7 @@ import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
import { canManageOrganizationSettings } from "@/lib/permissions/roles";
import { hasProSubscription } from "@/lib/pro-subscription";
import {
+ decodeLoomImportRows,
extractLoomVideoId,
isValidImportEmail,
LOOM_IMPORT_MAX_ROWS,
@@ -33,6 +34,7 @@ import {
normalizeImportEmail,
normalizeImportSpaceName,
} from "./csv";
+import { LOOM_IMPORT_JOB_STATUS_INDEX } from "./indexes";
import {
type LoomVideoLookup,
lookupLoomVideos,
@@ -42,6 +44,8 @@ import {
const INSERT_CHUNK = 500;
const LOOM_IMPORT_JOBS_PER_HOUR = 30;
const IN_CHUNK = 500;
+const RESOLVE_HEARTBEAT_MS = 60_000;
+const RESOLVE_BUDGET_MS = 90_000;
export class LoomImportError extends Error {}
@@ -98,13 +102,14 @@ export function planLoomImportItems(
rows: unknown,
{ isAdmin }: { isAdmin: boolean },
): ItemInsert[] {
- if (!Array.isArray(rows)) throw new LoomImportError("No Loom links found.");
+ const list = Array.isArray(rows) ? rows : decodeLoomImportRows(rows);
+ if (!list) throw new LoomImportError("No Loom links found.");
const seen = new Map();
const usedRows = new Set();
const items: ItemInsert[] = [];
const now = new Date();
- (rows as IncomingRow[]).forEach((row, index) => {
+ (list as IncomingRow[]).forEach((row, index) => {
const candidateRow =
typeof row?.rowNumber === "number" &&
Number.isInteger(row.rowNumber) &&
@@ -316,14 +321,41 @@ function resolvedItem(
export async function resolveLoomImportJob(
jobId: string,
- options: { fetchImpl?: typeof fetch } = {},
-) {
+ options: {
+ fetchImpl?: typeof fetch;
+ giveUp?: boolean;
+ budgetMs?: number;
+ } = {},
+): Promise<{ waiting: number }> {
const [job] = await db()
.select({ orgId: loomImportJobs.orgId, status: loomImportJobs.status })
.from(loomImportJobs)
.where(eq(loomImportJobs.id, jobId))
.limit(1);
- if (!job || job.status === "cancelled" || job.status === "completed") return;
+ if (!job || job.status === "cancelled" || job.status === "completed")
+ return { waiting: 0 };
+
+ if (options.giveUp) {
+ const now = new Date();
+ await db()
+ .update(loomImportJobItems)
+ .set({
+ status: "failed",
+ error: loomLookupError({ status: "error" }),
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "pending"),
+ ),
+ );
+ await db()
+ .update(loomImportJobs)
+ .set({ updatedAt: now })
+ .where(eq(loomImportJobs.id, jobId));
+ return { waiting: 0 };
+ }
const pending: PendingItem[] = await db()
.select({
@@ -333,7 +365,7 @@ export async function resolveLoomImportJob(
loomUrl: loomImportJobItems.loomUrl,
loomVideoId: loomImportJobItems.loomVideoId,
})
- .from(loomImportJobItems)
+ .from(loomImportJobItems, { forceIndex: LOOM_IMPORT_JOB_STATUS_INDEX })
.where(
and(
eq(loomImportJobItems.jobId, jobId),
@@ -341,7 +373,7 @@ export async function resolveLoomImportJob(
),
)
.orderBy(asc(loomImportJobItems.rowNumber));
- if (pending.length === 0) return;
+ if (pending.length === 0) return { waiting: 0 };
const loomIds = Array.from(
new Set(
@@ -422,16 +454,29 @@ export async function resolveLoomImportJob(
itemsByLoomId.set(item.loomVideoId, list);
}
+ let resolved = 0;
+ let touchedAt = Date.now();
+ const deadline = Date.now() + (options.budgetMs ?? RESOLVE_BUDGET_MS);
await lookupLoomVideos(Array.from(itemsByLoomId.keys()), {
fetchImpl: options.fetchImpl,
+ shouldStop: () => Date.now() > deadline,
onBatch: async (results) => {
const updates: ItemInsert[] = [];
const batchTime = new Date();
+ if (batchTime.getTime() - touchedAt > RESOLVE_HEARTBEAT_MS) {
+ touchedAt = batchTime.getTime();
+ await db()
+ .update(loomImportJobs)
+ .set({ updatedAt: batchTime })
+ .where(eq(loomImportJobs.id, jobId));
+ }
for (const [loomVideoId, lookup] of results) {
+ if (lookup.status === "error") continue;
for (const item of itemsByLoomId.get(loomVideoId) ?? []) {
updates.push(resolvedItem(item, lookup, batchTime));
}
}
+ resolved += updates.length;
await writeItemUpdates(updates);
},
});
@@ -440,6 +485,7 @@ export async function resolveLoomImportJob(
.update(loomImportJobs)
.set({ updatedAt: new Date() })
.where(eq(loomImportJobs.id, jobId));
+ return { waiting: toLookup.length - resolved };
}
async function resolveImportOwners({
diff --git a/apps/web/lib/loom-import/loom-api.ts b/apps/web/lib/loom-import/loom-api.ts
index ae962c4b83b..f1dd8408601 100644
--- a/apps/web/lib/loom-import/loom-api.ts
+++ b/apps/web/lib/loom-import/loom-api.ts
@@ -19,6 +19,7 @@ type LookupOptions = {
attempts?: number;
fetchImpl?: typeof fetch;
onBatch?: (results: Map) => Promise | void;
+ shouldStop?: () => boolean;
};
type GraphqlVideo = {
@@ -96,6 +97,19 @@ export function buildLoomVideosQuery(ids: string[]) {
};
}
+export function loomRetryDelayMs(
+ attempt: number,
+ retryAfter: string | null,
+ random = Math.random,
+) {
+ const seconds = retryAfter ? Number(retryAfter) : Number.NaN;
+ const base =
+ Number.isFinite(seconds) && seconds > 0
+ ? Math.min(seconds, 10) * 1000
+ : (retryAfter === null ? 400 : 2000) * 2 ** attempt;
+ return Math.round(base + random() * base * 0.5);
+}
+
async function lookupBatch(
ids: string[],
fetchImpl: typeof fetch,
@@ -103,6 +117,7 @@ async function lookupBatch(
): Promise> {
const body = JSON.stringify(buildLoomVideosQuery(ids));
for (let attempt = 0; attempt < attempts; attempt++) {
+ let retryAfter: string | null = null;
try {
const response = await fetchImpl(LOOM_GRAPHQL_URL, {
method: "POST",
@@ -128,7 +143,9 @@ async function lookupBatch(
});
return results;
}
- } else if (response.status < 500 && response.status !== 429) {
+ } else if (response.status === 429) {
+ retryAfter = response.headers.get("Retry-After") ?? "";
+ } else if (response.status < 500) {
console.warn("[loom-import] Loom lookup rejected", {
status: response.status,
videos: ids.length,
@@ -143,7 +160,9 @@ async function lookupBatch(
});
}
if (attempt < attempts - 1) {
- await new Promise((resolve) => setTimeout(resolve, 400 * 2 ** attempt));
+ await new Promise((resolve) =>
+ setTimeout(resolve, loomRetryDelayMs(attempt, retryAfter)),
+ );
}
}
return new Map(ids.map((id) => [id, { status: "error" } as const]));
@@ -154,9 +173,10 @@ export async function lookupLoomVideos(
{
batchSize = 25,
concurrency = 4,
- attempts = 3,
+ attempts = 4,
fetchImpl = fetch,
onBatch,
+ shouldStop,
}: LookupOptions = {},
): Promise> {
const unique = Array.from(new Set(ids));
@@ -170,7 +190,7 @@ export async function lookupLoomVideos(
const workers = Array.from(
{ length: Math.min(concurrency, batches.length) },
async () => {
- while (next < batches.length) {
+ while (next < batches.length && !shouldStop?.()) {
const batch = batches[next++];
if (!batch) continue;
const batchResults = await lookupBatch(batch, fetchImpl, attempts);
diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts
index 7180e91e32d..b8bd34899d9 100644
--- a/apps/web/lib/loom-import/recovery.ts
+++ b/apps/web/lib/loom-import/recovery.ts
@@ -5,18 +5,20 @@ import {
videos,
videoUploads,
} from "@cap/database/schema";
-import { and, eq, lt } from "drizzle-orm";
+import { and, eq, inArray, lt } from "drizzle-orm";
import { start } from "workflow/api";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
-import { dispatchLoomImportJob, rawFileKeyFor } from "./dispatch";
+import { dispatchLoomImports, rawFileKeyFor } from "./dispatch";
import { affectedRows } from "./jobs";
const STALE_CHECKING_MS = 10 * 60 * 1000;
-const STALE_IMPORTING_MS = 2 * 60 * 1000;
const STUCK_START_MS = 30 * 60 * 1000;
+const SILENT_IMPORT_MS = 2 * 60 * 60 * 1000;
+export const LOOM_IMPORT_SILENT_ERROR =
+ "This video stopped responding while it was copying. Try it again.";
-export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
+async function resumeStaleChecks(now: Date, limit: number) {
const staleChecking = await db()
.select({ id: loomImportJobs.id, updatedAt: loomImportJobs.updatedAt })
.from(loomImportJobs)
@@ -54,91 +56,133 @@ export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
});
}
}
+ return { checking: staleChecking.length, resumed };
+}
- const staleImporting = await db()
- .select({ id: loomImportJobs.id })
- .from(loomImportJobs)
+async function restartStuckStarts(now: Date, limit: number) {
+ const stuck = await db()
+ .select({
+ videoId: videos.id,
+ ownerId: videos.ownerId,
+ bucket: videos.bucket,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ rawFileKey: videoUploads.rawFileKey,
+ uploadUpdatedAt: videoUploads.updatedAt,
+ })
+ .from(loomImportJobItems)
+ .innerJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .innerJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
.where(
and(
- eq(loomImportJobs.status, "importing"),
- lt(
- loomImportJobs.updatedAt,
- new Date(now.getTime() - STALE_IMPORTING_MS),
- ),
+ eq(loomImportJobItems.status, "importing"),
+ eq(videoUploads.phase, "uploading"),
+ lt(videoUploads.updatedAt, new Date(now.getTime() - STUCK_START_MS)),
),
)
.limit(limit);
let restarted = 0;
- for (const job of staleImporting) {
- const stuck = await db()
- .select({
- videoId: videos.id,
- ownerId: videos.ownerId,
- bucket: videos.bucket,
- loomVideoId: loomImportJobItems.loomVideoId,
- rawFileKey: videoUploads.rawFileKey,
- uploadUpdatedAt: videoUploads.updatedAt,
+ for (const row of stuck) {
+ if (!row.loomVideoId) continue;
+ const rawFileKey =
+ row.rawFileKey ?? rawFileKeyFor(row.ownerId, row.videoId);
+ const claim = await db()
+ .update(videoUploads)
+ .set({
+ processingMessage: "Retrying Loom import...",
+ rawFileKey,
+ updatedAt: now,
})
- .from(loomImportJobItems)
- .innerJoin(videos, eq(videos.id, loomImportJobItems.videoId))
- .innerJoin(
- videoUploads,
- eq(videoUploads.videoId, loomImportJobItems.videoId),
- )
.where(
and(
- eq(loomImportJobItems.jobId, job.id),
- eq(loomImportJobItems.status, "importing"),
+ eq(videoUploads.videoId, row.videoId),
eq(videoUploads.phase, "uploading"),
- lt(videoUploads.updatedAt, new Date(now.getTime() - STUCK_START_MS)),
+ eq(videoUploads.updatedAt, row.uploadUpdatedAt),
),
);
- for (const row of stuck) {
- if (!row.loomVideoId) continue;
- const rawFileKey =
- row.rawFileKey ?? rawFileKeyFor(row.ownerId, row.videoId);
- const claim = await db()
- .update(videoUploads)
- .set({
- processingMessage: "Retrying Loom import...",
- rawFileKey,
- updatedAt: now,
- })
- .where(
- and(
- eq(videoUploads.videoId, row.videoId),
- eq(videoUploads.phase, "uploading"),
- eq(videoUploads.updatedAt, row.uploadUpdatedAt),
- ),
- );
- if (affectedRows(claim) !== 1) continue;
- try {
- await start(importLoomVideoWorkflow, [
- {
- videoId: row.videoId,
- userId: row.ownerId,
- rawFileKey,
- bucketId: row.bucket,
- loomVideoId: row.loomVideoId,
- reuseExistingRawUpload: true,
- },
- ]);
- restarted++;
- } catch (error) {
- console.error("[loom-import] Could not restart stuck import", {
+ if (affectedRows(claim) !== 1) continue;
+ try {
+ await start(importLoomVideoWorkflow, [
+ {
videoId: row.videoId,
- error,
- });
- }
+ userId: row.ownerId,
+ rawFileKey,
+ bucketId: row.bucket,
+ loomVideoId: row.loomVideoId,
+ reuseExistingRawUpload: true,
+ },
+ ]);
+ restarted++;
+ } catch (error) {
+ console.error("[loom-import] Could not restart stuck import", {
+ videoId: row.videoId,
+ error,
+ });
}
- await dispatchLoomImportJob(job.id);
}
+ return restarted;
+}
+
+async function failSilentImports(now: Date, limit: number) {
+ const silent = await db()
+ .select({
+ videoId: videoUploads.videoId,
+ updatedAt: videoUploads.updatedAt,
+ })
+ .from(loomImportJobItems)
+ .innerJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(
+ and(
+ eq(loomImportJobItems.status, "importing"),
+ inArray(videoUploads.phase, [
+ "uploading",
+ "processing",
+ "generating_thumbnail",
+ ]),
+ lt(videoUploads.updatedAt, new Date(now.getTime() - SILENT_IMPORT_MS)),
+ ),
+ )
+ .limit(limit);
+
+ let failed = 0;
+ for (const row of silent) {
+ const result = await db()
+ .update(videoUploads)
+ .set({
+ phase: "error",
+ processingError: LOOM_IMPORT_SILENT_ERROR,
+ processingMessage: "Loom import failed",
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(videoUploads.videoId, row.videoId),
+ eq(videoUploads.updatedAt, row.updatedAt),
+ ),
+ );
+ failed += affectedRows(result);
+ }
+ return failed;
+}
+export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
+ const { checking, resumed } = await resumeStaleChecks(now, limit);
+ const restarted = await restartStuckStarts(now, limit);
+ const silent = await failSilentImports(now, limit);
+ const dispatched = await dispatchLoomImports();
return {
- checking: staleChecking.length,
+ checking,
resumed,
- importing: staleImporting.length,
restarted,
+ silent,
+ started: dispatched.started,
+ inFlight: dispatched.inFlight,
+ waiting: dispatched.waiting,
};
}
diff --git a/apps/web/lib/loom-import/schedule.ts b/apps/web/lib/loom-import/schedule.ts
new file mode 100644
index 00000000000..6919f1eb93e
--- /dev/null
+++ b/apps/web/lib/loom-import/schedule.ts
@@ -0,0 +1,51 @@
+export type LoomImportQueueJob = {
+ id: string;
+ creatorId: string;
+ dispatchedAt: Date | null;
+ startedAt: Date | null;
+};
+
+export type LoomImportQueueLoad = {
+ jobs: Map;
+ creators: Map;
+};
+
+function time(value: Date | null) {
+ return value ? value.getTime() : Number.NEGATIVE_INFINITY;
+}
+
+function compareJobs(
+ left: LoomImportQueueJob,
+ right: LoomImportQueueJob,
+ load: LoomImportQueueLoad,
+) {
+ return (
+ (load.creators.get(left.creatorId) ?? 0) -
+ (load.creators.get(right.creatorId) ?? 0) ||
+ (load.jobs.get(left.id) ?? 0) - (load.jobs.get(right.id) ?? 0) ||
+ time(left.dispatchedAt) - time(right.dispatchedAt) ||
+ time(left.startedAt) - time(right.startedAt) ||
+ left.id.localeCompare(right.id)
+ );
+}
+
+export function pickLoomImportJob(
+ jobs: Iterable,
+ load: LoomImportQueueLoad,
+ perJob: number,
+): Job | null {
+ let best: Job | null = null;
+ for (const job of jobs) {
+ if ((load.jobs.get(job.id) ?? 0) >= perJob) continue;
+ if (!best || compareJobs(job, best, load) < 0) best = job;
+ }
+ return best;
+}
+
+export function addLoomImportLoad(
+ load: LoomImportQueueLoad,
+ job: Pick,
+) {
+ load.jobs.set(job.id, (load.jobs.get(job.id) ?? 0) + 1);
+ load.creators.set(job.creatorId, (load.creators.get(job.creatorId) ?? 0) + 1);
+}
diff --git a/apps/web/lib/loom-import/snapshot.ts b/apps/web/lib/loom-import/snapshot.ts
index c3975a5ad8e..5bfdbb69a68 100644
--- a/apps/web/lib/loom-import/snapshot.ts
+++ b/apps/web/lib/loom-import/snapshot.ts
@@ -1,19 +1,24 @@
import { db } from "@cap/database";
import {
+ type LoomImportJobStatus,
loomImportJobItems,
users,
videos,
videoUploads,
} from "@cap/database/schema";
import type { User } from "@cap/web-domain";
-import { asc, eq, sql } from "drizzle-orm";
+import { and, asc, eq, gt, type SQL, sql } from "drizzle-orm";
import { hasProSubscription } from "@/lib/pro-subscription";
+import {
+ LOOM_IMPORT_JOB_STATUS_INDEX,
+ LOOM_IMPORT_JOB_UPDATED_INDEX,
+} from "./indexes";
import { getLoomImportJobForUser } from "./jobs";
import {
- countLoomImportItems,
deriveLoomImportItemState,
type LoomImportItemView,
type LoomImportSnapshot,
+ summarizeLoomImportItems,
} from "./status";
export const LOOM_IMPORT_CURSOR_OVERLAP_MS = 3_000;
@@ -22,6 +27,105 @@ function time(value: Date | null | undefined) {
return value ? value.getTime() : 0;
}
+function selectItems(where: SQL | undefined, forceIndex?: string) {
+ return db()
+ .select({
+ id: loomImportJobItems.id,
+ rowNumber: loomImportJobItems.rowNumber,
+ loomUrl: loomImportJobItems.loomUrl,
+ loomVideoId: loomImportJobItems.loomVideoId,
+ ownerEmail: loomImportJobItems.ownerEmail,
+ spaceName: loomImportJobItems.spaceName,
+ status: loomImportJobItems.status,
+ videoId: loomImportJobItems.videoId,
+ title: loomImportJobItems.title,
+ loomCreatedAt: loomImportJobItems.loomCreatedAt,
+ durationSeconds: loomImportJobItems.durationSeconds,
+ thumbnailUrl: loomImportJobItems.thumbnailUrl,
+ error: loomImportJobItems.error,
+ updatedAt: loomImportJobItems.updatedAt,
+ videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
+ videoUpdatedAt: videos.updatedAt,
+ uploadPhase: videoUploads.phase,
+ uploadProgress: videoUploads.processingProgress,
+ uploadMessage: videoUploads.processingMessage,
+ uploadError: videoUploads.processingError,
+ uploadUpdatedAt: videoUploads.updatedAt,
+ })
+ .from(loomImportJobItems, forceIndex ? { forceIndex } : undefined)
+ .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
+ .leftJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomImportJobItems.videoId),
+ )
+ .where(where)
+ .orderBy(asc(loomImportJobItems.rowNumber));
+}
+
+type ItemRow = Awaited>[number];
+
+async function changedRows(jobId: string, after: Date) {
+ const [changed, inFlight] = await Promise.all([
+ selectItems(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ gt(loomImportJobItems.updatedAt, after),
+ ),
+ LOOM_IMPORT_JOB_UPDATED_INDEX,
+ ),
+ selectItems(
+ and(
+ eq(loomImportJobItems.jobId, jobId),
+ eq(loomImportJobItems.status, "importing"),
+ ),
+ LOOM_IMPORT_JOB_STATUS_INDEX,
+ ),
+ ]);
+ const byId = new Map();
+ for (const row of [...changed, ...inFlight]) byId.set(row.id, row);
+ return Array.from(byId.values()).sort(
+ (left, right) => left.rowNumber - right.rowNumber,
+ );
+}
+
+function toItemView(
+ row: ItemRow,
+ jobStatus: LoomImportJobStatus,
+): LoomImportItemView {
+ const state = deriveLoomImportItemState(
+ {
+ status: row.status,
+ videoId: row.videoId,
+ error: row.error,
+ videoExists: Boolean(row.videoExists),
+ uploadPhase: row.uploadPhase ?? null,
+ uploadProgress: row.uploadProgress ?? null,
+ uploadMessage: row.uploadMessage ?? null,
+ uploadError: row.uploadError ?? null,
+ },
+ jobStatus,
+ );
+ return {
+ id: row.id,
+ row: row.rowNumber,
+ url: row.loomUrl,
+ loomId: row.loomVideoId,
+ title: row.title,
+ email: row.ownerEmail,
+ space: row.spaceName,
+ ...state,
+ videoId: row.videoId,
+ recordedAt: row.loomCreatedAt?.toISOString() ?? null,
+ duration: row.durationSeconds,
+ thumb: row.thumbnailUrl,
+ v: Math.max(
+ time(row.updatedAt),
+ time(row.videoUpdatedAt),
+ time(row.uploadUpdatedAt),
+ ),
+ };
+}
+
export async function getLoomImportSnapshot({
jobId,
userId,
@@ -35,6 +139,7 @@ export async function getLoomImportSnapshot({
const found = await getLoomImportJobForUser(jobId, userId);
if (!found) return null;
const { job, isAdmin } = found;
+ const full = since === undefined || !Number.isFinite(since);
const [[viewer], rows] = await Promise.all([
db()
@@ -45,93 +150,17 @@ export async function getLoomImportSnapshot({
.from(users)
.where(eq(users.id, userId))
.limit(1),
- db()
- .select({
- id: loomImportJobItems.id,
- rowNumber: loomImportJobItems.rowNumber,
- loomUrl: loomImportJobItems.loomUrl,
- loomVideoId: loomImportJobItems.loomVideoId,
- ownerEmail: loomImportJobItems.ownerEmail,
- spaceName: loomImportJobItems.spaceName,
- status: loomImportJobItems.status,
- videoId: loomImportJobItems.videoId,
- title: loomImportJobItems.title,
- loomCreatedAt: loomImportJobItems.loomCreatedAt,
- durationSeconds: loomImportJobItems.durationSeconds,
- thumbnailUrl: loomImportJobItems.thumbnailUrl,
- error: loomImportJobItems.error,
- updatedAt: loomImportJobItems.updatedAt,
- videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number),
- videoUpdatedAt: videos.updatedAt,
- uploadPhase: videoUploads.phase,
- uploadProgress: videoUploads.processingProgress,
- uploadMessage: videoUploads.processingMessage,
- uploadError: videoUploads.processingError,
- uploadUpdatedAt: videoUploads.updatedAt,
- })
- .from(loomImportJobItems)
- .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId))
- .leftJoin(
- videoUploads,
- eq(videoUploads.videoId, loomImportJobItems.videoId),
- )
- .where(eq(loomImportJobItems.jobId, jobId))
- .orderBy(asc(loomImportJobItems.rowNumber)),
+ full
+ ? selectItems(eq(loomImportJobItems.jobId, jobId))
+ : changedRows(
+ jobId,
+ new Date((since as number) - LOOM_IMPORT_CURSOR_OVERLAP_MS),
+ ),
]);
- const all: LoomImportItemView[] = rows.map((row) => {
- const state = deriveLoomImportItemState(
- {
- status: row.status,
- videoId: row.videoId,
- error: row.error,
- videoExists: Boolean(row.videoExists),
- uploadPhase: row.uploadPhase ?? null,
- uploadProgress: row.uploadProgress ?? null,
- uploadMessage: row.uploadMessage ?? null,
- uploadError: row.uploadError ?? null,
- },
- job.status,
- );
- return {
- id: row.id,
- row: row.rowNumber,
- url: row.loomUrl,
- loomId: row.loomVideoId,
- title: row.title,
- email: row.ownerEmail,
- space: row.spaceName,
- ...state,
- videoId: row.videoId,
- recordedAt: row.loomCreatedAt?.toISOString() ?? null,
- duration: row.durationSeconds,
- thumb: row.thumbnailUrl,
- v: Math.max(
- time(row.updatedAt),
- time(row.videoUpdatedAt),
- time(row.uploadUpdatedAt),
- ),
- };
- });
-
- let totalDuration = 0;
- let importedDuration = 0;
- const owners = new Set();
- for (const item of all) {
- if (item.email) owners.add(item.email);
- if (
- item.duration &&
- item.status !== "failed" &&
- item.status !== "cancelled"
- ) {
- totalDuration += item.duration;
- if (item.status === "imported") importedDuration += item.duration;
- }
- }
-
+ const items = rows.map((row) => toItemView(row, job.status));
const isPro = hasProSubscription(viewer ?? null);
const createdByMe = job.createdById === userId;
- const full = since === undefined || !Number.isFinite(since);
return {
job: {
@@ -147,15 +176,8 @@ export async function getLoomImportSnapshot({
isPro,
isAdmin,
},
- counts: countLoomImportItems(all),
- totalDuration,
- importedDuration,
- owners: Math.max(owners.size, 1),
- items: full
- ? all
- : all.filter(
- (item) => item.v > (since as number) - LOOM_IMPORT_CURSOR_OVERLAP_MS,
- ),
+ summary: full ? summarizeLoomImportItems(items) : null,
+ items,
cursor: queriedAt,
full,
};
diff --git a/apps/web/lib/loom-import/status.ts b/apps/web/lib/loom-import/status.ts
index 3fa33815b63..bd8bc4099d5 100644
--- a/apps/web/lib/loom-import/status.ts
+++ b/apps/web/lib/loom-import/status.ts
@@ -77,12 +77,16 @@ export type LoomImportJobView = {
isAdmin: boolean;
};
-export type LoomImportSnapshot = {
- job: LoomImportJobView;
+export type LoomImportSummaryView = {
counts: LoomImportCounts;
totalDuration: number;
importedDuration: number;
owners: number;
+};
+
+export type LoomImportSnapshot = {
+ job: LoomImportJobView;
+ summary: LoomImportSummaryView | null;
items: LoomImportItemView[];
cursor: number;
full: boolean;
@@ -223,6 +227,34 @@ export function countLoomImportItems(
return counts;
}
+export function summarizeLoomImportItems(
+ items: Iterable>,
+): LoomImportSummaryView {
+ const counts = emptyLoomImportCounts();
+ const owners = new Set();
+ let totalDuration = 0;
+ let importedDuration = 0;
+ for (const item of items) {
+ counts[item.status]++;
+ counts.total++;
+ if (item.email) owners.add(item.email);
+ if (
+ item.duration &&
+ item.status !== "failed" &&
+ item.status !== "cancelled"
+ ) {
+ totalDuration += item.duration;
+ if (item.status === "imported") importedDuration += item.duration;
+ }
+ }
+ return {
+ counts,
+ totalDuration,
+ importedDuration,
+ owners: Math.max(owners.size, 1),
+ };
+}
+
export function loomImportProgress(
counts: LoomImportCounts,
importingProgress = 0,
diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts
index cc94f36e128..70f453fb3af 100644
--- a/apps/web/workflows/import-loom-video.ts
+++ b/apps/web/workflows/import-loom-video.ts
@@ -6,7 +6,7 @@ import { Storage } from "@cap/web-backend/src/Storage/index";
import { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect } from "effect";
-import { FatalError, sleep } from "workflow";
+import { FatalError, RetryableError, sleep } from "workflow";
import { dispatchLoomImportForVideo } from "@/lib/loom-import/dispatch";
import {
createMediaServerCapacityError,
@@ -31,6 +31,9 @@ interface ImportLoomPayload {
}
const MINIMUM_VIDEO_SIZE = 1024;
+const LOOM_UNAVAILABLE_ERROR = "Loom is not responding right now";
+const LOOM_UNAVAILABLE_RETRY_AFTER_MS = 20_000;
+const LOOM_UNAVAILABLE_MAX_WAITS = 6;
const MEDIA_SERVER_START_MAX_ATTEMPTS = 2;
const MEDIA_SERVER_START_RETRY_BASE_MS = 250;
const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 3 * 60 * 60;
@@ -45,11 +48,16 @@ function isStreamingUrl(url: string): boolean {
return path.endsWith(".m3u8") || path.endsWith(".mpd");
}
+function isLoomUnavailableError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.includes(LOOM_UNAVAILABLE_ERROR);
+}
+
async function fetchLoomCdnUrl(
videoId: string,
endpoint: string,
includeBody: boolean,
-): Promise {
+): Promise<{ url: string } | { unavailable: true } | null> {
try {
const options: RequestInit = {
method: "POST",
@@ -73,15 +81,18 @@ async function fetchLoomCdnUrl(
options,
);
+ if (response.status === 429 || response.status >= 500) {
+ return { unavailable: true };
+ }
if (!response.ok || response.status === 204) return null;
const text = await response.text();
if (!text.trim()) return null;
const data = JSON.parse(text) as { url?: string };
- return data.url ?? null;
+ return data.url ? { url: data.url } : null;
} catch {
- return null;
+ return { unavailable: true };
}
}
@@ -94,18 +105,30 @@ async function fetchFreshLoomDownloadUrl(loomVideoId: string): Promise {
];
let fallbackStreamingUrl: string | null = null;
+ let unavailable = false;
for (const { endpoint, includeBody } of requestVariants) {
- const url = await fetchLoomCdnUrl(loomVideoId, endpoint, includeBody);
- if (!url) continue;
+ const result = await fetchLoomCdnUrl(loomVideoId, endpoint, includeBody);
+ if (!result) continue;
+ if ("unavailable" in result) {
+ unavailable = true;
+ continue;
+ }
- if (!isStreamingUrl(url)) return url;
+ if (!isStreamingUrl(result.url)) return result.url;
- if (!fallbackStreamingUrl) fallbackStreamingUrl = url;
+ if (!fallbackStreamingUrl) fallbackStreamingUrl = result.url;
}
if (fallbackStreamingUrl) return fallbackStreamingUrl;
+ if (unavailable) {
+ throw new RetryableError(
+ `${LOOM_UNAVAILABLE_ERROR}. We'll try this video again shortly.`,
+ { retryAfter: LOOM_UNAVAILABLE_RETRY_AFTER_MS },
+ );
+ }
+
throw new FatalError(
"Could not retrieve a download URL from Loom. The video may be private, password-protected, or the link may have expired.",
);
@@ -207,6 +230,7 @@ export async function importLoomVideoWorkflow(
: { importFromLoom: true };
let metadata: ProcessedVideoMetadata;
+ let loomWaits = 0;
for (let processingAttempt = 0; ; processingAttempt++) {
let capacityRetryCount = 0;
while (true) {
@@ -214,6 +238,15 @@ export async function importLoomVideoWorkflow(
await processVideoOnMediaServer(payload, processingInput);
break;
} catch (error) {
+ if (
+ isLoomUnavailableError(error) &&
+ loomWaits < LOOM_UNAVAILABLE_MAX_WAITS
+ ) {
+ loomWaits++;
+ await markLoomImportWaitingForCapacity(payload.videoId);
+ await sleep(`${60 * loomWaits}s`);
+ continue;
+ }
if (!isMediaServerCapacityError(error)) throw error;
await markLoomImportWaitingForCapacity(payload.videoId);
await sleep(`${Math.min(180, 30 + capacityRetryCount * 15)}s`);
@@ -253,9 +286,7 @@ export async function importLoomVideoWorkflow(
}
}
-async function continueLoomImportJob(videoId: string): Promise {
- "use step";
-
+async function continueLoomImports(videoId: string) {
try {
await dispatchLoomImportForVideo(videoId);
} catch (error) {
@@ -266,6 +297,12 @@ async function continueLoomImportJob(videoId: string): Promise {
}
}
+async function continueLoomImportJob(videoId: string): Promise {
+ "use step";
+
+ await continueLoomImports(videoId);
+}
+
function getInputExtension(url: string): string | undefined {
const pathname = new URL(url).pathname.toLowerCase();
@@ -509,6 +546,7 @@ async function processVideoOnMediaServer(
inputExtension: getInputExtension(rawVideoUrl),
priority: "bulk",
});
+ await continueLoomImports(videoId);
}
async function saveMetadataAndComplete(
diff --git a/apps/web/workflows/loom-import-job.ts b/apps/web/workflows/loom-import-job.ts
index aa39c54c8b4..0196edc9223 100644
--- a/apps/web/workflows/loom-import-job.ts
+++ b/apps/web/workflows/loom-import-job.ts
@@ -1,3 +1,4 @@
+import { sleep } from "workflow";
import { dispatchLoomImportJob } from "@/lib/loom-import/dispatch";
import {
prepareLoomImportJob,
@@ -8,10 +9,13 @@ interface LoomImportJobPayload {
jobId: string;
}
-async function resolveLoomImportJobStep(jobId: string) {
+const RESOLVE_PATIENCE = 6;
+const RESOLVE_MAX_PASSES = 60;
+
+async function resolveLoomImportJobStep(jobId: string, giveUp: boolean) {
"use step";
- await resolveLoomImportJob(jobId);
+ return resolveLoomImportJob(jobId, { giveUp });
}
async function prepareLoomImportJobStep(jobId: string) {
@@ -29,7 +33,19 @@ async function dispatchLoomImportJobStep(jobId: string) {
export async function loomImportJobWorkflow({ jobId }: LoomImportJobPayload) {
"use workflow";
- await resolveLoomImportJobStep(jobId);
+ let stalled = 0;
+ let previous = Number.POSITIVE_INFINITY;
+ for (let pass = 0; pass < RESOLVE_MAX_PASSES; pass++) {
+ const { waiting } = await resolveLoomImportJobStep(jobId, false);
+ if (waiting === 0) break;
+ stalled = waiting < previous ? 0 : stalled + 1;
+ previous = waiting;
+ if (stalled >= RESOLVE_PATIENCE || pass === RESOLVE_MAX_PASSES - 1) {
+ await resolveLoomImportJobStep(jobId, true);
+ break;
+ }
+ await sleep(`${Math.min(300, 15 * 2 ** stalled)}s`);
+ }
const status = await prepareLoomImportJobStep(jobId);
if (status !== "importing") return { jobId, status };
const dispatched = await dispatchLoomImportJobStep(jobId);
From 36e3bec85050e80c4e7c4828713c17f1b9a0b395 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 02:03:16 +0000
Subject: [PATCH 22/39] test: load test Loom imports with 50 people and 100,000
rows
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-scale.test.ts | 842 ++++++++++++++++++
1 file changed, 842 insertions(+)
create mode 100644 apps/web/__tests__/integration/loom-import-scale.test.ts
diff --git a/apps/web/__tests__/integration/loom-import-scale.test.ts b/apps/web/__tests__/integration/loom-import-scale.test.ts
new file mode 100644
index 00000000000..3245c6be870
--- /dev/null
+++ b/apps/web/__tests__/integration/loom-import-scale.test.ts
@@ -0,0 +1,842 @@
+import { randomUUID } from "node:crypto";
+import {
+ importedVideos,
+ loomImportJobItems,
+ loomImportJobs,
+ organizationMembers,
+ organizations,
+ users,
+ videos,
+ videoUploads,
+} from "@cap/database/schema";
+import { Organisation, User, Video } from "@cap/web-domain";
+import { and, eq, inArray, ne, sql } from "drizzle-orm";
+import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
+import { Effect, Option } from "effect";
+import { createPool, type Pool } from "mysql2/promise";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+
+const fixture = vi.hoisted(() => ({
+ database: undefined as MySql2Database | undefined,
+ start: vi.fn(),
+ sleepScale: 0.05,
+ queries: 0,
+}));
+
+vi.mock("server-only", () => ({}));
+vi.mock("@cap/database", () => ({
+ db: () => {
+ if (!fixture.database) throw new Error("Test database is not connected.");
+ return fixture.database;
+ },
+}));
+vi.mock("@cap/env", () => ({
+ buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
+ NODE_ENV: "test",
+ serverEnv: () => ({
+ CAP_VIDEOS_DEFAULT_PUBLIC: true,
+ WEB_URL: "https://cap.test",
+ }),
+}));
+vi.mock("@cap/database/loops/queue", () => ({
+ enqueueLoopsSync: vi.fn(async () => undefined),
+}));
+vi.mock("@cap/web-backend/src/Storage/index", () => ({
+ Storage: {
+ getWritableAccessForUser: () =>
+ Effect.succeed({
+ bucketId: Option.none(),
+ storageIntegrationId: Option.none(),
+ }),
+ },
+}));
+vi.mock("@/lib/workflow-runtime", () => ({
+ runWorkflowPromise: (effect: Effect.Effect ) =>
+ Effect.runPromise(effect),
+}));
+vi.mock("workflow", () => ({
+ sleep: async (duration: string) => {
+ const seconds = Number.parseFloat(duration);
+ await new Promise((resolve) =>
+ setTimeout(resolve, seconds * 1000 * fixture.sleepScale),
+ );
+ },
+}));
+vi.mock("workflow/api", () => ({ start: fixture.start }));
+vi.mock("@/workflows/import-loom-video", () => ({
+ importLoomVideoWorkflow: "importLoomVideoWorkflow",
+}));
+
+import {
+ dispatchLoomImportForVideo,
+ dispatchLoomImports,
+ loomImportConcurrency,
+ loomImportGlobalConcurrency,
+} from "@/lib/loom-import/dispatch";
+import {
+ cancelLoomImportJob,
+ createLoomImportJob,
+} from "@/lib/loom-import/jobs";
+import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot";
+import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
+
+const databaseUrl = process.env.CAP_LOOM_IMPORT_TEST_DATABASE_URL;
+const enabled =
+ Boolean(databaseUrl) && process.env.CAP_LOOM_IMPORT_SCALE_TEST === "1";
+
+const LOOM_CAPACITY = 16;
+const MEDIA_CAPACITY = 9;
+const FAILURE_RATE = 0.01;
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+function database() {
+ if (!fixture.database) throw new Error("Test database is not connected.");
+ return fixture.database;
+}
+
+function id() {
+ return randomUUID().replaceAll("-", "").slice(0, 15);
+}
+
+function percentile(values: number[], fraction: number) {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((left, right) => left - right);
+ return (
+ sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ??
+ 0
+ );
+}
+
+function stats(values: number[]) {
+ return {
+ count: values.length,
+ p50: Math.round(percentile(values, 0.5)),
+ p95: Math.round(percentile(values, 0.95)),
+ max: Math.round(Math.max(0, ...values)),
+ };
+}
+
+type Simulation = {
+ loom: {
+ active: number;
+ most: number;
+ requests: number;
+ limited: number;
+ errors: number;
+ };
+ media: {
+ active: number;
+ most: number;
+ turnedAway: number;
+ completed: number;
+ failed: number;
+ };
+ dispatchMs: number[];
+ running: Set>;
+ launched: number;
+ errors: number;
+ processing: [number, number];
+};
+
+function newSimulation(processing: [number, number]): Simulation {
+ return {
+ loom: { active: 0, most: 0, requests: 0, limited: 0, errors: 0 },
+ media: { active: 0, most: 0, turnedAway: 0, completed: 0, failed: 0 },
+ dispatchMs: [],
+ running: new Set(),
+ launched: 0,
+ errors: 0,
+ processing,
+ };
+}
+
+function fakeLoom(simulation: Simulation): typeof fetch {
+ return (async (_url: string | URL | Request, init?: RequestInit) => {
+ const { loom } = simulation;
+ loom.requests++;
+ if (loom.active >= LOOM_CAPACITY) {
+ loom.limited++;
+ return new Response("slow down", {
+ status: 429,
+ headers: { "Retry-After": "0.2" },
+ });
+ }
+ if (Math.random() < 0.02) {
+ loom.errors++;
+ return new Response("unavailable", { status: 503 });
+ }
+ loom.active++;
+ loom.most = Math.max(loom.most, loom.active);
+ try {
+ await wait(30 + Math.random() * 40);
+ const { variables } = JSON.parse(String(init?.body)) as {
+ variables: Record;
+ };
+ const data: Record = {};
+ for (const [key, loomId] of Object.entries(variables)) {
+ data[key.replace("id", "v")] = {
+ __typename: "RegularUserVideo",
+ name: `Recording ${loomId.slice(0, 6)}`,
+ createdAt: "2023-03-14T15:09:26.535Z",
+ thumbnails: {
+ default: `https://cdn.loom.com/sessions/thumbnails/${loomId}-00001.jpg`,
+ },
+ video_properties: { duration: 95, width: 1920, height: 1080 },
+ };
+ }
+ return Response.json({ data });
+ } finally {
+ loom.active--;
+ }
+ }) as typeof fetch;
+}
+
+async function timedDispatch(simulation: Simulation, videoId: string) {
+ const startedAt = performance.now();
+ await dispatchLoomImportForVideo(videoId);
+ simulation.dispatchMs.push(performance.now() - startedAt);
+}
+
+async function simulateVideo(simulation: Simulation, videoId: Video.VideoId) {
+ const { media } = simulation;
+ const upload = (values: Partial) =>
+ database()
+ .update(videoUploads)
+ .set({ ...values, updatedAt: new Date() })
+ .where(eq(videoUploads.videoId, videoId));
+
+ while (media.active >= MEDIA_CAPACITY) {
+ media.turnedAway++;
+ await upload({ processingMessage: "Queued for Loom import processing..." });
+ await wait(100 + Math.random() * 100);
+ }
+ media.active++;
+ media.most = Math.max(media.most, media.active);
+ await upload({
+ phase: "processing",
+ processingProgress: 0,
+ processingMessage: "Starting video processing...",
+ });
+ await timedDispatch(simulation, videoId);
+ const [shortest, longest] = simulation.processing;
+ const duration = shortest + Math.random() * (longest - shortest);
+ await wait(duration / 2);
+ await upload({
+ processingProgress: 50,
+ processingMessage: "Processing video",
+ });
+ await wait(duration / 2);
+ media.active--;
+ if (Math.random() < FAILURE_RATE) {
+ media.failed++;
+ await upload({
+ phase: "error",
+ processingError: "Simulated media failure",
+ });
+ } else {
+ media.completed++;
+ await database()
+ .delete(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ }
+ await timedDispatch(simulation, videoId);
+ await timedDispatch(simulation, videoId);
+}
+
+async function makePeople(count: number) {
+ const people: { ownerId: User.UserId; orgId: Organisation.OrganisationId }[] =
+ [];
+ for (let index = 0; index < count; index++) {
+ const ownerId = User.UserId.make(id());
+ const orgId = Organisation.OrganisationId.make(id());
+ await database()
+ .insert(users)
+ .values({
+ id: ownerId,
+ email: `${ownerId}@scale.test`,
+ name: `Person ${index}`,
+ activeOrganizationId: orgId,
+ defaultOrgId: orgId,
+ stripeSubscriptionStatus: "active",
+ });
+ await database()
+ .insert(organizations)
+ .values({ id: orgId, name: `Org ${index}`, ownerId });
+ await database().insert(organizationMembers).values({
+ id: id(),
+ organizationId: orgId,
+ userId: ownerId,
+ role: "owner",
+ });
+ people.push({ ownerId, orgId });
+ }
+ return people;
+}
+
+async function cleanUp(orgIds: string[]) {
+ await database().delete(loomImportJobItems);
+ await database().delete(loomImportJobs);
+ if (orgIds.length === 0) return;
+ const created = await database()
+ .select({ id: videos.id })
+ .from(videos)
+ .where(inArray(videos.orgId, orgIds as Organisation.OrganisationId[]));
+ for (let index = 0; index < created.length; index += 1000) {
+ const ids = created
+ .slice(index, index + 1000)
+ .map((row) => Video.VideoId.make(row.id));
+ await database()
+ .delete(videoUploads)
+ .where(inArray(videoUploads.videoId, ids));
+ await database()
+ .delete(importedVideos)
+ .where(inArray(importedVideos.id, ids));
+ await database().delete(videos).where(inArray(videos.id, ids));
+ }
+}
+
+async function inFlightCount() {
+ const [row] = await database()
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(loomImportJobItems)
+ .where(eq(loomImportJobItems.status, "importing"));
+ return row?.count ?? 0;
+}
+
+async function statusesByJob() {
+ return database()
+ .select({
+ jobId: loomImportJobItems.jobId,
+ status: loomImportJobItems.status,
+ count: sql`COUNT(*)`.mapWith(Number),
+ })
+ .from(loomImportJobItems)
+ .groupBy(loomImportJobItems.jobId, loomImportJobItems.status);
+}
+
+function wireStart(simulation: Simulation) {
+ fixture.start
+ .mockReset()
+ .mockImplementation(
+ async (_workflow: unknown, [payload]: [{ videoId: string }]) => {
+ simulation.launched++;
+ const run = simulateVideo(
+ simulation,
+ Video.VideoId.make(payload.videoId),
+ ).catch((error) => {
+ simulation.errors++;
+ console.error("[scale] simulated video failed", error);
+ });
+ simulation.running.add(run);
+ run.finally(() => simulation.running.delete(run));
+ return { runId: payload.videoId };
+ },
+ );
+}
+
+async function measure(runs: number, run: () => Promise) {
+ const ms: number[] = [];
+ const queries: number[] = [];
+ let result: T | undefined;
+ for (let index = 0; index < runs; index++) {
+ const before = fixture.queries;
+ const startedAt = performance.now();
+ result = await run();
+ ms.push(performance.now() - startedAt);
+ queries.push(fixture.queries - before);
+ }
+ return {
+ result: result as T,
+ ms: stats(ms),
+ queries: Math.max(...queries),
+ };
+}
+
+describe.runIf(enabled)("Loom CSV imports under load", () => {
+ let pool: Pool | undefined;
+ const orgIds: string[] = [];
+
+ beforeAll(async () => {
+ if (!databaseUrl) throw new Error("Missing isolated test database URL.");
+ const url = new URL(databaseUrl);
+ if (
+ url.protocol !== "mysql:" ||
+ !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) ||
+ !/^\/cap_loom_import_[a-z0-9_]+$/.test(url.pathname)
+ ) {
+ throw new Error("Loom import tests require a local test database.");
+ }
+ pool = createPool({ uri: databaseUrl, connectionLimit: 60 });
+ fixture.database = drizzle(pool, {
+ logger: {
+ logQuery: () => {
+ fixture.queries++;
+ },
+ },
+ });
+ await cleanUp([]);
+ });
+
+ afterAll(async () => {
+ await cleanUp(orgIds);
+ await pool?.end();
+ fixture.database = undefined;
+ });
+
+ it(
+ "keeps each scheduling pass and each poll small with 100,000 rows queued",
+ async () => {
+ const people = await makePeople(50);
+ orgIds.push(...people.map((person) => person.orgId));
+ fixture.start.mockReset().mockResolvedValue({ runId: "run" });
+ const now = new Date();
+ const jobIds: string[] = [];
+ for (const [index, person] of people.entries()) {
+ const jobId = id();
+ jobIds.push(jobId);
+ await database()
+ .insert(loomImportJobs)
+ .values({
+ id: jobId,
+ orgId: person.orgId,
+ createdById: person.ownerId,
+ fileName: `cost-${index}.csv`,
+ status: "importing",
+ totalCount: 2000,
+ startedAt: now,
+ });
+ const rows = Array.from({ length: 2000 }, (_, row) => {
+ const loomVideoId = randomUUID().replaceAll("-", "");
+ return {
+ id: id(),
+ jobId,
+ rowNumber: row + 2,
+ loomUrl: `https://www.loom.com/share/${loomVideoId}`,
+ loomVideoId,
+ status: "ready" as const,
+ ownerId: person.ownerId,
+ title: `Recording ${row}`,
+ durationSeconds: 95,
+ updatedAt: now,
+ };
+ });
+ for (let offset = 0; offset < rows.length; offset += 500) {
+ await database()
+ .insert(loomImportJobItems)
+ .values(rows.slice(offset, offset + 500));
+ }
+ }
+
+ const cold = await measure(1, () => dispatchLoomImports());
+ await dispatchLoomImports();
+ await dispatchLoomImports();
+ expect(await inFlightCount()).toBe(12);
+ const full = await measure(20, () => dispatchLoomImports());
+ expect(full.result.started).toBe(0);
+
+ const finishOne = await measure(20, async () => {
+ const [running] = await database()
+ .select({ videoId: loomImportJobItems.videoId })
+ .from(loomImportJobItems)
+ .where(eq(loomImportJobItems.status, "importing"))
+ .limit(1);
+ const videoId = running?.videoId as Video.VideoId;
+ await database()
+ .delete(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ const before = fixture.queries;
+ const startedAt = performance.now();
+ const outcome = await dispatchLoomImportForVideo(videoId);
+ const elapsed = performance.now() - startedAt;
+ const repeatBefore = fixture.queries;
+ await dispatchLoomImportForVideo(videoId);
+ return {
+ outcome,
+ elapsed,
+ queries: repeatBefore - before,
+ repeatQueries: fixture.queries - repeatBefore,
+ };
+ });
+ expect(finishOne.result.outcome?.started).toBe(1);
+ expect(await inFlightCount()).toBe(12);
+
+ const [busy] = await database()
+ .select({ videoId: loomImportJobItems.videoId })
+ .from(loomImportJobItems)
+ .where(eq(loomImportJobItems.status, "importing"))
+ .limit(1);
+ const admission = await measure(20, () =>
+ dispatchLoomImportForVideo(busy?.videoId as Video.VideoId),
+ );
+
+ const owner = people[0] as (typeof people)[number];
+ const jobId = jobIds[0] as string;
+ const first = await getLoomImportSnapshot({
+ jobId,
+ userId: owner.ownerId,
+ });
+ const deltaPoll = await measure(20, () =>
+ getLoomImportSnapshot({
+ jobId,
+ userId: owner.ownerId,
+ since: first?.cursor,
+ }),
+ );
+ const fullPoll = await measure(5, () =>
+ getLoomImportSnapshot({ jobId, userId: owner.ownerId }),
+ );
+
+ const explain = async (label: string, query: string) => {
+ const [rows] = (await pool?.query(`EXPLAIN ANALYZE ${query}`)) as [
+ Array>,
+ unknown,
+ ];
+ return { label, plan: Object.values(rows[0] ?? {})[0] };
+ };
+ const plans = [
+ await explain(
+ "in-flight rows across all imports",
+ "SELECT i.id FROM loom_import_job_items i JOIN loom_import_jobs j ON j.id = i.job_id LEFT JOIN videos v ON v.id = i.video_id LEFT JOIN video_uploads u ON u.video_id = i.video_id WHERE i.status = 'importing'",
+ ),
+ await explain(
+ "next queued row of one import, without the index hint",
+ `SELECT id FROM loom_import_job_items WHERE job_id = '${jobId}' AND status = 'ready' ORDER BY csv_row LIMIT 1`,
+ ),
+ await explain(
+ "next queued row of one import, as the dispatcher asks",
+ `SELECT id FROM loom_import_job_items FORCE INDEX (job_status_row_idx) WHERE job_id = '${jobId}' AND status = 'ready' ORDER BY csv_row LIMIT 1`,
+ ),
+
+ await explain(
+ "rows changed since the last poll",
+ `SELECT id FROM loom_import_job_items WHERE job_id = '${jobId}' AND updated_at > NOW(3) - INTERVAL 3 SECOND`,
+ ),
+ ];
+
+ await database()
+ .update(loomImportJobItems)
+ .set({ status: "complete" })
+ .where(
+ and(
+ eq(loomImportJobItems.status, "ready"),
+ sql`${loomImportJobItems.rowNumber} <= 1991`,
+ ),
+ );
+ await wait(15_000);
+ await pool?.query(
+ "TRUNCATE performance_schema.events_statements_summary_by_digest",
+ );
+ const lateFull = await measure(20, () => dispatchLoomImports());
+ const [slowest] = (await pool?.query(
+ "SELECT LEFT(DIGEST_TEXT, 160) AS statement, COUNT_STAR AS calls, ROUND(AVG_TIMER_WAIT / 1e9, 2) AS avg_ms, SUM_ROWS_EXAMINED AS rows_examined FROM performance_schema.events_statements_summary_by_digest WHERE SCHEMA_NAME = DATABASE() ORDER BY SUM_TIMER_WAIT DESC LIMIT 6",
+ )) as [Array>, unknown];
+ console.info(
+ `[loom-import cost] slowest statements late in imports\n${JSON.stringify(slowest, null, 1)}`,
+ );
+ const lateFinish = await measure(10, async () => {
+ const [running] = await database()
+ .select({ videoId: loomImportJobItems.videoId })
+ .from(loomImportJobItems)
+ .where(eq(loomImportJobItems.status, "importing"))
+ .limit(1);
+ const videoId = running?.videoId as Video.VideoId;
+ await database()
+ .delete(videoUploads)
+ .where(eq(videoUploads.videoId, videoId));
+ return dispatchLoomImportForVideo(videoId);
+ });
+ expect(lateFinish.result?.started).toBe(1);
+ const lateDelta = await measure(20, () =>
+ getLoomImportSnapshot({
+ jobId,
+ userId: owner.ownerId,
+ since: Date.now(),
+ }),
+ );
+ plans.push(
+ await explain(
+ "imports with nothing left, late in imports",
+ `SELECT j.id FROM loom_import_jobs j WHERE j.id IN (${jobIds
+ .map((value) => `'${value}'`)
+ .join(",")}) AND j.status = 'importing' ${[
+ "pending",
+ "ready",
+ "importing",
+ ]
+ .map(
+ (status) =>
+ `AND NOT EXISTS (SELECT 1 FROM loom_import_job_items i FORCE INDEX (job_status_row_idx) WHERE i.job_id = j.id AND i.status = '${status}')`,
+ )
+ .join(" ")}`,
+ ),
+ await explain(
+ "next queued row late in an import, without the index hint",
+ `SELECT id FROM loom_import_job_items WHERE job_id = '${jobId}' AND status = 'ready' ORDER BY csv_row LIMIT 1`,
+ ),
+ await explain(
+ "next queued row late in an import, as the dispatcher asks",
+ `SELECT id FROM loom_import_job_items FORCE INDEX (job_status_row_idx) WHERE job_id = '${jobId}' AND status = 'ready' ORDER BY csv_row LIMIT 1`,
+ ),
+ );
+
+ const report = {
+ queuedRows: 100_000,
+ activeImports: jobIds.length,
+ lateInImports: {
+ passWhenFull: { queries: lateFull.queries, ms: lateFull.ms },
+ videoFinished: { queries: lateFinish.queries, ms: lateFinish.ms },
+ deltaPoll: { queries: lateDelta.queries, ms: lateDelta.ms },
+ },
+ coldPass: { queries: cold.queries, ms: cold.ms.max },
+ passWhenFull: { queries: full.queries, ms: full.ms },
+ videoFinished: {
+ queries: finishOne.result.queries,
+ repeatTriggerQueries: finishOne.result.repeatQueries,
+ ms: finishOne.ms,
+ },
+ mediaServerAccepted: { queries: admission.queries, ms: admission.ms },
+ deltaPoll: {
+ queries: deltaPoll.queries,
+ ms: deltaPoll.ms,
+ rows: deltaPoll.result?.items.length,
+ },
+ fullPoll: {
+ queries: fullPoll.queries,
+ ms: fullPoll.ms,
+ rows: fullPoll.result?.items.length,
+ },
+ };
+ console.info(`[loom-import cost] ${JSON.stringify(report, null, 1)}`);
+ for (const plan of plans) {
+ console.info(`[loom-import plan] ${plan.label}\n${plan.plan}`);
+ }
+
+ expect(full.queries).toBeLessThanOrEqual(8);
+ expect(finishOne.result.repeatQueries).toBe(1);
+ expect(deltaPoll.result?.items.length).toBeLessThanOrEqual(4);
+ await cleanUp([]);
+ },
+ 10 * 60 * 1000,
+ );
+
+ it(
+ "50 people importing 2,000 videos each stay within capacity and all make progress",
+ async () => {
+ const people = await makePeople(50);
+ orgIds.push(...people.map((person) => person.orgId));
+ const simulation = newSimulation([150, 350]);
+ wireStart(simulation);
+ vi.stubGlobal("fetch", fakeLoom(simulation));
+
+ const createdAt = performance.now();
+ const jobIds: string[] = [];
+ for (const person of people) {
+ const { jobId } = await createLoomImportJob({
+ userId: person.ownerId,
+ orgId: person.orgId,
+ fileName: "loom-export.csv",
+ rows: Array.from({ length: 2000 }, (_, index) => ({
+ rowNumber: index + 2,
+ loomUrl: `https://www.loom.com/share/${randomUUID().replaceAll("-", "")}`,
+ })),
+ });
+ jobIds.push(jobId);
+ }
+ const createMs = performance.now() - createdAt;
+
+ const globalLimit = loomImportGlobalConcurrency();
+ const perJob = loomImportConcurrency();
+ let most = 0;
+ let sampling = true;
+ const sampler = (async () => {
+ while (sampling) {
+ most = Math.max(most, await inFlightCount());
+ await wait(100);
+ }
+ })();
+
+ const pollMs: number[] = [];
+ const pollRows: number[] = [];
+ let polling = true;
+ const pollers = people.map(async (person, index) => {
+ const jobId = jobIds[index] as string;
+ const first = await getLoomImportSnapshot({
+ jobId,
+ userId: person.ownerId,
+ });
+ let cursor = first?.cursor ?? Date.now();
+ while (polling) {
+ await wait(2000);
+ const startedAt = performance.now();
+ const delta = await getLoomImportSnapshot({
+ jobId,
+ userId: person.ownerId,
+ since: cursor,
+ });
+ pollMs.push(performance.now() - startedAt);
+ pollRows.push(delta?.items.length ?? 0);
+ cursor = delta?.cursor ?? cursor;
+ }
+ });
+
+ const workflowsStartedAt = performance.now();
+ const results = await Promise.all(
+ jobIds.map((jobId) => loomImportJobWorkflow({ jobId })),
+ );
+ const checkedMs = performance.now() - workflowsStartedAt;
+ expect(results.every((result) => result.status === "importing")).toBe(
+ true,
+ );
+
+ const afterChecks = await statusesByJob();
+ const lookupFailures = afterChecks
+ .filter((row) => row.status === "failed" || row.status === "pending")
+ .reduce((total, row) => total + row.count, 0);
+
+ await wait(90_000);
+
+ for (const jobId of jobIds) await cancelLoomImportJob(jobId);
+ polling = false;
+ await Promise.all(pollers);
+ while (simulation.running.size > 0) {
+ await Promise.all(Array.from(simulation.running));
+ }
+ sampling = false;
+ await sampler;
+
+ const finalStatuses = await statusesByJob();
+ const completedByJob = new Map();
+ for (const row of finalStatuses) {
+ if (row.status === "complete") completedByJob.set(row.jobId, row.count);
+ }
+ const perJobCompleted = jobIds.map(
+ (jobId) => completedByJob.get(jobId) ?? 0,
+ );
+ const stillImporting = finalStatuses
+ .filter((row) => row.status === "importing")
+ .reduce((total, row) => total + row.count, 0);
+
+ const fullStartedAt = performance.now();
+ const full = await getLoomImportSnapshot({
+ jobId: jobIds[0] as string,
+ userId: people[0]?.ownerId as User.UserId,
+ });
+ const fullMs = performance.now() - fullStartedAt;
+
+ const report = {
+ people: people.length,
+ rows: people.length * 2000,
+ limits: { globalLimit, perJob, mediaCapacity: MEDIA_CAPACITY },
+ createAllJobsMs: Math.round(createMs),
+ allLinksCheckedMs: Math.round(checkedMs),
+ lookupFailures,
+ loom: simulation.loom,
+ mostInFlight: most,
+ media: simulation.media,
+ videosLaunched: simulation.launched,
+ completedPerImport: {
+ min: Math.min(...perJobCompleted),
+ max: Math.max(...perJobCompleted),
+ total: perJobCompleted.reduce((total, value) => total + value, 0),
+ },
+ stillImportingAfterDrain: stillImporting,
+ simulationErrors: simulation.errors,
+ dispatchMs: stats(simulation.dispatchMs),
+ deltaPollMs: stats(pollMs),
+ deltaPollRows: stats(pollRows),
+ fullSnapshotMs: Math.round(fullMs),
+ fullSnapshotRows: full?.items.length ?? 0,
+ };
+ console.info(`[loom-import scale] ${JSON.stringify(report, null, 1)}`);
+
+ expect(lookupFailures).toBe(0);
+ expect(most).toBeLessThanOrEqual(globalLimit);
+ expect(simulation.media.most).toBeLessThanOrEqual(MEDIA_CAPACITY);
+ expect(Math.min(...perJobCompleted)).toBeGreaterThan(0);
+ expect(stillImporting).toBe(0);
+ expect(simulation.errors).toBe(0);
+ },
+ 20 * 60 * 1000,
+ );
+
+ it(
+ "50 people importing at the same time all finish, with only real failures left",
+ async () => {
+ await cleanUp([]);
+ const people = await makePeople(50);
+ orgIds.push(...people.map((person) => person.orgId));
+ const simulation = newSimulation([40, 120]);
+ wireStart(simulation);
+ vi.stubGlobal("fetch", fakeLoom(simulation));
+
+ const jobIds: string[] = [];
+ for (const person of people) {
+ const { jobId } = await createLoomImportJob({
+ userId: person.ownerId,
+ orgId: person.orgId,
+ fileName: "small.csv",
+ rows: Array.from({ length: 40 }, (_, index) => ({
+ rowNumber: index + 2,
+ loomUrl: `https://www.loom.com/share/${randomUUID().replaceAll("-", "")}`,
+ })),
+ });
+ jobIds.push(jobId);
+ }
+
+ const startedAt = performance.now();
+ await Promise.all(
+ jobIds.map((jobId) => loomImportJobWorkflow({ jobId })),
+ );
+ const deadline = Date.now() + 10 * 60 * 1000;
+ let most = 0;
+ while (Date.now() < deadline) {
+ most = Math.max(most, await inFlightCount());
+ const [open] = await database()
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(loomImportJobs)
+ .where(
+ and(
+ inArray(loomImportJobs.id, jobIds),
+ ne(loomImportJobs.status, "completed"),
+ ),
+ );
+ if (!open?.count && simulation.running.size === 0) break;
+ await wait(200);
+ }
+ const elapsed = performance.now() - startedAt;
+
+ const finalStatuses = await statusesByJob();
+ const totals = new Map();
+ for (const row of finalStatuses) {
+ totals.set(row.status, (totals.get(row.status) ?? 0) + row.count);
+ }
+ const jobs = await database()
+ .select({ status: loomImportJobs.status })
+ .from(loomImportJobs)
+ .where(inArray(loomImportJobs.id, jobIds));
+ const report = {
+ people: people.length,
+ videos: people.length * 40,
+ finishedInMs: Math.round(elapsed),
+ mostInFlight: most,
+ media: simulation.media,
+ itemStatuses: Object.fromEntries(totals),
+ simulationErrors: simulation.errors,
+ jobsCompleted: jobs.filter((job) => job.status === "completed").length,
+ dispatchMs: stats(simulation.dispatchMs),
+ };
+ console.info(`[loom-import scale] ${JSON.stringify(report, null, 1)}`);
+
+ expect(report.jobsCompleted).toBe(50);
+ expect(simulation.errors).toBe(0);
+ expect(totals.get("complete")).toBe(simulation.media.completed);
+ expect(totals.get("failed") ?? 0).toBe(simulation.media.failed);
+ expect((totals.get("complete") ?? 0) + (totals.get("failed") ?? 0)).toBe(
+ 2000,
+ );
+ expect(most).toBeLessThanOrEqual(loomImportGlobalConcurrency());
+ },
+ 20 * 60 * 1000,
+ );
+});
From 4a06aadf6fa893cc507a170078612ba393f5db4d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 02:15:58 +0000
Subject: [PATCH 23/39] fix: free up to 500 silent Loom import slots per
recovery run in one update
Co-authored-by: Richie McIlroy
---
apps/web/lib/loom-import/recovery.ts | 63 ++++++++++++++--------------
1 file changed, 32 insertions(+), 31 deletions(-)
diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts
index b8bd34899d9..c25c43dcc4c 100644
--- a/apps/web/lib/loom-import/recovery.ts
+++ b/apps/web/lib/loom-import/recovery.ts
@@ -15,6 +15,7 @@ import { affectedRows } from "./jobs";
const STALE_CHECKING_MS = 10 * 60 * 1000;
const STUCK_START_MS = 30 * 60 * 1000;
const SILENT_IMPORT_MS = 2 * 60 * 60 * 1000;
+const SILENT_IMPORT_BATCH = 500;
export const LOOM_IMPORT_SILENT_ERROR =
"This video stopped responding while it was copying. Try it again.";
@@ -126,12 +127,15 @@ async function restartStuckStarts(now: Date, limit: number) {
return restarted;
}
-async function failSilentImports(now: Date, limit: number) {
+async function failSilentImports(now: Date) {
+ const silentSince = new Date(now.getTime() - SILENT_IMPORT_MS);
+ const silentPhases = [
+ "uploading",
+ "processing",
+ "generating_thumbnail",
+ ] as const;
const silent = await db()
- .select({
- videoId: videoUploads.videoId,
- updatedAt: videoUploads.updatedAt,
- })
+ .select({ videoId: videoUploads.videoId })
.from(loomImportJobItems)
.innerJoin(
videoUploads,
@@ -140,41 +144,38 @@ async function failSilentImports(now: Date, limit: number) {
.where(
and(
eq(loomImportJobItems.status, "importing"),
- inArray(videoUploads.phase, [
- "uploading",
- "processing",
- "generating_thumbnail",
- ]),
- lt(videoUploads.updatedAt, new Date(now.getTime() - SILENT_IMPORT_MS)),
+ inArray(videoUploads.phase, silentPhases),
+ lt(videoUploads.updatedAt, silentSince),
),
)
- .limit(limit);
+ .limit(SILENT_IMPORT_BATCH);
+ if (silent.length === 0) return 0;
- let failed = 0;
- for (const row of silent) {
- const result = await db()
- .update(videoUploads)
- .set({
- phase: "error",
- processingError: LOOM_IMPORT_SILENT_ERROR,
- processingMessage: "Loom import failed",
- updatedAt: now,
- })
- .where(
- and(
- eq(videoUploads.videoId, row.videoId),
- eq(videoUploads.updatedAt, row.updatedAt),
+ const result = await db()
+ .update(videoUploads)
+ .set({
+ phase: "error",
+ processingError: LOOM_IMPORT_SILENT_ERROR,
+ processingMessage: "Loom import failed",
+ updatedAt: now,
+ })
+ .where(
+ and(
+ inArray(
+ videoUploads.videoId,
+ silent.map((row) => row.videoId),
),
- );
- failed += affectedRows(result);
- }
- return failed;
+ inArray(videoUploads.phase, silentPhases),
+ lt(videoUploads.updatedAt, silentSince),
+ ),
+ );
+ return affectedRows(result);
}
export async function recoverLoomImportJobs(now = new Date(), limit = 20) {
const { checking, resumed } = await resumeStaleChecks(now, limit);
const restarted = await restartStuckStarts(now, limit);
- const silent = await failSilentImports(now, limit);
+ const silent = await failSilentImports(now);
const dispatched = await dispatchLoomImports();
return {
checking,
From a5415a0b182154630ca6277c5ecdcaa17cfc780a Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 02:36:34 +0000
Subject: [PATCH 24/39] fix: show a Loom video retried from its page as copying
in its bulk import
Co-authored-by: Richie McIlroy
---
.../unit/video-processing-recovery.test.ts | 6 +++++
apps/web/actions/video/retry-processing.ts | 2 ++
apps/web/lib/loom-import/retry.ts | 23 +++++++++++++++++++
apps/web/lib/video-processing-recovery.ts | 2 ++
4 files changed, 33 insertions(+)
create mode 100644 apps/web/lib/loom-import/retry.ts
diff --git a/apps/web/__tests__/unit/video-processing-recovery.test.ts b/apps/web/__tests__/unit/video-processing-recovery.test.ts
index e55f634d71c..cb60278cc5e 100644
--- a/apps/web/__tests__/unit/video-processing-recovery.test.ts
+++ b/apps/web/__tests__/unit/video-processing-recovery.test.ts
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.fn();
const mockStart = vi.fn();
const mockSetVideoProcessingError = vi.fn();
+const mockMarkLoomImportRetrying = vi.fn();
vi.mock("@cap/database", () => ({
db: mockDb,
@@ -53,6 +54,10 @@ vi.mock("@/lib/video-processing", () => ({
setVideoProcessingError: mockSetVideoProcessingError,
}));
+vi.mock("@/lib/loom-import/retry", () => ({
+ markLoomImportRetrying: mockMarkLoomImportRetrying,
+}));
+
vi.mock("@/workflows/process-video", () => ({
processVideoWorkflow: vi.fn(),
}));
@@ -198,6 +203,7 @@ describe("recoverFailedVideoProcessing", () => {
}),
]);
expect(result.statuses).toEqual({ started: 1 });
+ expect(mockMarkLoomImportRetrying).toHaveBeenCalledWith("video-1");
});
it("keeps a failed Loom workflow start eligible for Loom-specific recovery", async () => {
diff --git a/apps/web/actions/video/retry-processing.ts b/apps/web/actions/video/retry-processing.ts
index fa53dabec2b..02820f6a440 100644
--- a/apps/web/actions/video/retry-processing.ts
+++ b/apps/web/actions/video/retry-processing.ts
@@ -6,6 +6,7 @@ import { importedVideos, videos, videoUploads } from "@cap/database/schema";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { start } from "workflow/api";
+import { markLoomImportRetrying } from "@/lib/loom-import/retry";
import {
setVideoProcessingError,
startVideoProcessingWorkflow,
@@ -114,6 +115,7 @@ export async function retryVideoProcessing({
throw normalizedError;
}
+ await markLoomImportRetrying(videoId);
return { success: true, status: "started" };
}
diff --git a/apps/web/lib/loom-import/retry.ts b/apps/web/lib/loom-import/retry.ts
new file mode 100644
index 00000000000..bee2c674a0b
--- /dev/null
+++ b/apps/web/lib/loom-import/retry.ts
@@ -0,0 +1,23 @@
+import { db } from "@cap/database";
+import { loomImportJobItems } from "@cap/database/schema";
+import type { Video } from "@cap/web-domain";
+import { and, eq } from "drizzle-orm";
+
+export async function markLoomImportRetrying(videoId: Video.VideoId) {
+ try {
+ await db()
+ .update(loomImportJobItems)
+ .set({ status: "importing", error: null, updatedAt: new Date() })
+ .where(
+ and(
+ eq(loomImportJobItems.videoId, videoId),
+ eq(loomImportJobItems.status, "failed"),
+ ),
+ );
+ } catch (error) {
+ console.warn("[loom-import] Could not show a retry on its import", {
+ videoId,
+ error,
+ });
+ }
+}
diff --git a/apps/web/lib/video-processing-recovery.ts b/apps/web/lib/video-processing-recovery.ts
index c7fce3e6685..768042a4caf 100644
--- a/apps/web/lib/video-processing-recovery.ts
+++ b/apps/web/lib/video-processing-recovery.ts
@@ -14,6 +14,7 @@ import {
sql,
} from "drizzle-orm";
import { start } from "workflow/api";
+import { markLoomImportRetrying } from "@/lib/loom-import/retry";
import { setVideoProcessingError } from "@/lib/video-processing";
import { WORKFLOW_UPGRADE_ERROR_FRAGMENT } from "@/lib/workflow-recovery";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
@@ -181,6 +182,7 @@ async function recoverLoomCandidate({
reuseExistingRawUpload: phase === "error",
},
]);
+ if (phase === "error") await markLoomImportRetrying(videoId);
return "started";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
From 08bb6bee3fcfb88c645ce57139afb810ab3ebf96 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 02:36:34 +0000
Subject: [PATCH 25/39] fix: restart a stalled Loom upload once so recovery
can't keep it holding a slot
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 84 ++++++++++++++++++-
apps/web/lib/loom-import/recovery.ts | 14 +++-
2 files changed, 95 insertions(+), 3 deletions(-)
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index 26f250089d3..c1d6a414d1b 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -90,9 +90,11 @@ import {
resolveLoomImportJob,
} from "@/lib/loom-import/jobs";
import {
+ LOOM_IMPORT_RESTART_MESSAGE,
LOOM_IMPORT_SILENT_ERROR,
recoverLoomImportJobs,
} from "@/lib/loom-import/recovery";
+import { markLoomImportRetrying } from "@/lib/loom-import/retry";
import {
getLoomImportSnapshot,
LOOM_IMPORT_CURSOR_OVERLAP_MS,
@@ -741,7 +743,7 @@ describe.runIf(Boolean(databaseUrl))(
.where(eq(videoUploads.videoId, videoId));
expect(upload).toMatchObject({
phase: "uploading",
- processingMessage: "Retrying Loom import...",
+ processingMessage: LOOM_IMPORT_RESTART_MESSAGE,
});
fixture.start.mockClear();
@@ -1021,5 +1023,85 @@ describe.runIf(Boolean(databaseUrl))(
});
expect(second?.status).toBe("importing");
});
+
+ it("restarts a stalled upload once, then frees its slot if the restart never moves", async () => {
+ fixture.concurrency = "1";
+ const owner = await makeOrganization({ pro: true });
+ const jobId = await readyJob(owner, 2);
+ await dispatchLoomImportJob(jobId);
+ const [stalled] = await inFlight();
+ const videoId = stalled?.videoId as Video.VideoId;
+ const age = async (hours: number) =>
+ database()
+ .update(videoUploads)
+ .set({ updatedAt: new Date(Date.now() - hours * 60 * 60 * 1000) })
+ .where(eq(videoUploads.videoId, videoId));
+
+ await age(1);
+ expect(await recoverLoomImportJobs()).toMatchObject({
+ restarted: 1,
+ silent: 0,
+ });
+ await age(1);
+ expect(await recoverLoomImportJobs()).toMatchObject({
+ restarted: 0,
+ silent: 0,
+ });
+ await age(3);
+ expect(await recoverLoomImportJobs()).toMatchObject({
+ restarted: 0,
+ silent: 1,
+ started: 1,
+ });
+ const [first, second] = await items(jobId);
+ expect(first).toMatchObject({
+ status: "failed",
+ error: LOOM_IMPORT_SILENT_ERROR,
+ });
+ expect(second?.status).toBe("importing");
+ });
+
+ it("shows a retry from the video page as copying while it runs", async () => {
+ const owner = await makeOrganization({ pro: true });
+ const jobId = await readyJob(owner, 1);
+ await dispatchLoomImportJob(jobId);
+ const videoId = (await items(jobId))[0]?.videoId as Video.VideoId;
+ await failVideo(videoId, "Media server unavailable");
+ expect(await jobStatus(jobId)).toBe("completed");
+ await database()
+ .update(loomImportJobItems)
+ .set({ updatedAt: new Date(Date.now() - 60_000) })
+ .where(eq(loomImportJobItems.jobId, jobId));
+ const before = await getLoomImportSnapshot({
+ jobId,
+ userId: owner.ownerId,
+ });
+
+ await database()
+ .update(videoUploads)
+ .set({
+ phase: "processing",
+ processingProgress: 35,
+ processingError: null,
+ processingMessage: "Retrying Loom import...",
+ })
+ .where(eq(videoUploads.videoId, videoId));
+ await markLoomImportRetrying(videoId);
+ const delta = await getLoomImportSnapshot({
+ jobId,
+ userId: owner.ownerId,
+ since: before?.cursor,
+ });
+ expect(delta?.items).toMatchObject([
+ { status: "importing", progress: 35, videoId },
+ ]);
+ expect((await dispatchLoomImports()).inFlight).toBe(1);
+
+ await finishVideo(videoId);
+ expect((await items(jobId))[0]).toMatchObject({
+ status: "complete",
+ error: null,
+ });
+ });
},
);
diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts
index c25c43dcc4c..577addab03c 100644
--- a/apps/web/lib/loom-import/recovery.ts
+++ b/apps/web/lib/loom-import/recovery.ts
@@ -5,7 +5,7 @@ import {
videos,
videoUploads,
} from "@cap/database/schema";
-import { and, eq, inArray, lt } from "drizzle-orm";
+import { and, eq, inArray, isNull, lt, ne, or } from "drizzle-orm";
import { start } from "workflow/api";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
import { loomImportJobWorkflow } from "@/workflows/loom-import-job";
@@ -18,6 +18,14 @@ const SILENT_IMPORT_MS = 2 * 60 * 60 * 1000;
const SILENT_IMPORT_BATCH = 500;
export const LOOM_IMPORT_SILENT_ERROR =
"This video stopped responding while it was copying. Try it again.";
+export const LOOM_IMPORT_RESTART_MESSAGE =
+ "Restarting a Loom import that stalled before copying...";
+
+const notRestartedYet = () =>
+ or(
+ isNull(videoUploads.processingMessage),
+ ne(videoUploads.processingMessage, LOOM_IMPORT_RESTART_MESSAGE),
+ );
async function resumeStaleChecks(now: Date, limit: number) {
const staleChecking = await db()
@@ -81,6 +89,7 @@ async function restartStuckStarts(now: Date, limit: number) {
eq(loomImportJobItems.status, "importing"),
eq(videoUploads.phase, "uploading"),
lt(videoUploads.updatedAt, new Date(now.getTime() - STUCK_START_MS)),
+ notRestartedYet(),
),
)
.limit(limit);
@@ -93,7 +102,7 @@ async function restartStuckStarts(now: Date, limit: number) {
const claim = await db()
.update(videoUploads)
.set({
- processingMessage: "Retrying Loom import...",
+ processingMessage: LOOM_IMPORT_RESTART_MESSAGE,
rawFileKey,
updatedAt: now,
})
@@ -102,6 +111,7 @@ async function restartStuckStarts(now: Date, limit: number) {
eq(videoUploads.videoId, row.videoId),
eq(videoUploads.phase, "uploading"),
eq(videoUploads.updatedAt, row.uploadUpdatedAt),
+ notRestartedYet(),
),
);
if (affectedRows(claim) !== 1) continue;
From 1d37a5c8c8c0bd695c213019843b615afb9dd096 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:26:37 +0000
Subject: [PATCH 26/39] fix: give long videos on the media server time in
proportion to their length
Co-authored-by: Richie McIlroy
---
.../src/__tests__/lib/job-manager.test.ts | 35 +++++++++++++++++++
apps/media-server/src/lib/job-manager.ts | 32 ++++++++++++++++-
apps/media-server/src/lib/media-video.ts | 2 +-
apps/media-server/src/routes/video.ts | 2 ++
4 files changed, 69 insertions(+), 2 deletions(-)
diff --git a/apps/media-server/src/__tests__/lib/job-manager.test.ts b/apps/media-server/src/__tests__/lib/job-manager.test.ts
index ff2b43e690b..62f606eaebf 100644
--- a/apps/media-server/src/__tests__/lib/job-manager.test.ts
+++ b/apps/media-server/src/__tests__/lib/job-manager.test.ts
@@ -6,6 +6,7 @@ import {
cleanupExpiredJobs,
createJob,
deleteJob,
+ extendJobLifetimeForMedia,
getJob,
type JobProgress,
type RecordingWorkerAcknowledgement,
@@ -521,4 +522,38 @@ describe("job cleanup", () => {
expect(currentJob?.phase).toBe("error");
expect(currentJob?.error).toContain("maximum lifetime of 60 minutes");
});
+
+ test("gives a long video time to finish in proportion to its length", () => {
+ const now = Date.now();
+ const running = createTrackedJob("job-long-media-running");
+ running.phase = "processing";
+ running.createdAt = now - 2 * 60 * 60 * 1000;
+ running.updatedAt = now;
+ extendJobLifetimeForMedia(running.jobId, 3 * 60 * 60);
+ const overdue = createTrackedJob("job-long-media-overdue");
+ overdue.phase = "processing";
+ overdue.createdAt = now - 3 * 60 * 60 * 1000 - 60_000;
+ overdue.updatedAt = now;
+ extendJobLifetimeForMedia(overdue.jobId, 3 * 60 * 60);
+
+ expect(cleanupExpiredJobs()).toBe(1);
+ expect(getJob(running.jobId)?.phase).toBe("processing");
+ expect(getJob(overdue.jobId)?.error).toContain(
+ "maximum lifetime of 180 minutes",
+ );
+ });
+
+ test("keeps the one-hour cap for short videos", () => {
+ const job = createTrackedJob("job-short-media");
+ const now = Date.now();
+ job.phase = "processing";
+ job.createdAt = now - 61 * 60 * 1000;
+ job.updatedAt = now;
+ extendJobLifetimeForMedia(job.jobId, 4 * 60);
+
+ expect(cleanupExpiredJobs()).toBe(1);
+ expect(getJob(job.jobId)?.error).toContain(
+ "maximum lifetime of 60 minutes",
+ );
+ });
});
diff --git a/apps/media-server/src/lib/job-manager.ts b/apps/media-server/src/lib/job-manager.ts
index 74caa0deaa7..57152c353a9 100644
--- a/apps/media-server/src/lib/job-manager.ts
+++ b/apps/media-server/src/lib/job-manager.ts
@@ -124,6 +124,7 @@ export interface Job {
webhookLastAttemptAt?: number;
recordingVerificationDeadlineAt?: number;
recordingProcessingDeadlineAt?: number;
+ mediaDeadlineAt?: number;
recordingVerification?: RecordingVerificationProof;
manifestSha256?: string;
jobId: string;
@@ -150,6 +151,8 @@ const jobs = new Map();
const JOB_TTL_MS = 60 * 60 * 1000;
const STALE_JOB_MS = 15 * 60 * 1000;
const MAX_JOB_LIFETIME_MS = 60 * 60 * 1000;
+const MEDIA_JOB_LIFETIME_BASE_MS = 30 * 60 * 1000;
+const MAX_MEDIA_JOB_LIFETIME_MS = 3 * 60 * 60 * 1000;
const MAX_RECORDING_PROCESSING_BUDGET_MS = 3 * 60 * 60 * 1000;
const WEBHOOK_MAX_ATTEMPTS = 3;
const WEBHOOK_RETRY_BASE_MS = 500;
@@ -475,6 +478,32 @@ export function beginRecordingProcessing(
return true;
}
+export function extendJobLifetimeForMedia(
+ jobId: string,
+ durationSeconds: number,
+): number | undefined {
+ const job = jobs.get(jobId);
+ if (
+ !job ||
+ !isActivePhase(job.phase) ||
+ !Number.isFinite(durationSeconds) ||
+ durationSeconds <= 0
+ )
+ return job?.mediaDeadlineAt;
+ const lifetime = Math.min(
+ MAX_MEDIA_JOB_LIFETIME_MS,
+ Math.max(
+ MAX_JOB_LIFETIME_MS,
+ MEDIA_JOB_LIFETIME_BASE_MS + Math.ceil(durationSeconds * 1000),
+ ),
+ );
+ job.mediaDeadlineAt = Math.max(
+ job.mediaDeadlineAt ?? 0,
+ job.createdAt + lifetime,
+ );
+ return job.mediaDeadlineAt;
+}
+
export function deleteJob(jobId: string): boolean {
const job = jobs.get(jobId);
if (job) {
@@ -585,6 +614,7 @@ export function cleanupExpiredJobs(): number {
const deadline =
job.recordingVerificationDeadlineAt ??
job.recordingProcessingDeadlineAt ??
+ job.mediaDeadlineAt ??
job.createdAt + MAX_JOB_LIFETIME_MS;
if (isActivePhase(job.phase) && now > deadline) {
console.warn(
@@ -596,7 +626,7 @@ export function cleanupExpiredJobs(): number {
error:
job.recordingVerificationDeadlineAt === undefined &&
job.recordingProcessingDeadlineAt === undefined
- ? `Job exceeded maximum lifetime of ${Math.round(MAX_JOB_LIFETIME_MS / 60000)} minutes`
+ ? `Job exceeded maximum lifetime of ${Math.round((deadline - job.createdAt) / 60000)} minutes`
: "Recording verification timed out",
message: "Processing failed (timeout)",
});
diff --git a/apps/media-server/src/lib/media-video.ts b/apps/media-server/src/lib/media-video.ts
index 8f87ca0cec3..94c599a7099 100644
--- a/apps/media-server/src/lib/media-video.ts
+++ b/apps/media-server/src/lib/media-video.ts
@@ -29,7 +29,7 @@ import {
} from "./temp-files";
const PROCESS_TIMEOUT_PER_SECOND_MS = 20_000;
-const MAX_PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000;
+const MAX_PROCESS_TIMEOUT_MS = 3 * 60 * 60 * 1000;
// HLS/DASH sources are pulled as many sequential segment requests rather than
// one streamed fetch, so per-request overhead scales with video length. A
// flat 10-minute budget is enough for typical short recordings but not for a
diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts
index cd2b44e68b5..fadeb806ef9 100644
--- a/apps/media-server/src/routes/video.ts
+++ b/apps/media-server/src/routes/video.ts
@@ -17,6 +17,7 @@ import {
claimRecordingWorker,
createJob,
deleteJob,
+ extendJobLifetimeForMedia,
forceCleanupActiveJobs,
generateJobId,
getActiveVideoProcessCount,
@@ -1346,6 +1347,7 @@ async function processVideoAsync(
);
repairedTempFile = repairedFile;
updateJob(jobId, { metadata });
+ extendJobLifetimeForMedia(jobId, metadata.duration);
const processingInputPath = repairedFile
? repairedFile.path
From 894d65cc4a8d151f1114498eea353812fa4a98b4 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:26:38 +0000
Subject: [PATCH 27/39] fix: wait for long Loom imports as long as the media
server allows
Co-authored-by: Richie McIlroy
---
.../unit/media-processing-workflows.test.ts | 14 ++++++-
apps/web/workflows/import-loom-video.ts | 42 ++++++++++++++++---
apps/web/workflows/video-processing-status.ts | 3 +-
3 files changed, 52 insertions(+), 7 deletions(-)
diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts
index d58ec46650f..f7cf5298ff6 100644
--- a/apps/web/__tests__/unit/media-processing-workflows.test.ts
+++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts
@@ -69,7 +69,10 @@ vi.mock("workflow", () => ({
RetryableError: class RetryableError extends Error {},
}));
-import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
+import {
+ importLoomVideoWorkflow,
+ loomImportProcessingWaitMs,
+} from "@/workflows/import-loom-video";
import { processVideoWorkflow } from "@/workflows/process-video";
const payload = {
@@ -207,6 +210,15 @@ describe("media processing workflows", () => {
expect(mocks.continueLoomImport.mock.calls).toEqual([["video"], ["video"]]);
});
+ it("waits longer for long Loom videos, in step with the media server", () => {
+ const minutes = (ms: number) => ms / 60_000;
+ expect(minutes(loomImportProcessingWaitMs(4 * 60))).toBe(75);
+ expect(minutes(loomImportProcessingWaitMs(60 * 60))).toBe(105);
+ expect(minutes(loomImportProcessingWaitMs(3 * 60 * 60))).toBe(195);
+ expect(minutes(loomImportProcessingWaitMs(10 * 60 * 60))).toBe(195);
+ expect(minutes(loomImportProcessingWaitMs(null))).toBe(195);
+ });
+
it("waits for Loom instead of failing when Loom rate limits the download link", async () => {
let loomCalls = 0;
mocks.fetch.mockImplementation(async (url: string) => {
diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts
index 70f453fb3af..7c0a76228bc 100644
--- a/apps/web/workflows/import-loom-video.ts
+++ b/apps/web/workflows/import-loom-video.ts
@@ -36,8 +36,13 @@ const LOOM_UNAVAILABLE_RETRY_AFTER_MS = 20_000;
const LOOM_UNAVAILABLE_MAX_WAITS = 6;
const MEDIA_SERVER_START_MAX_ATTEMPTS = 2;
const MEDIA_SERVER_START_RETRY_BASE_MS = 250;
-const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 3 * 60 * 60;
-const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 3 * 60 * 60;
+const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 4 * 60 * 60;
+const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 4 * 60 * 60;
+const MINUTE_MS = 60 * 1000;
+const MEDIA_SERVER_MIN_LIFETIME_MS = 60 * MINUTE_MS;
+const MEDIA_SERVER_LIFETIME_BASE_MS = 30 * MINUTE_MS;
+const MEDIA_SERVER_MAX_LIFETIME_MS = 3 * 60 * MINUTE_MS;
+const PROCESSING_WAIT_MARGIN_MS = 15 * MINUTE_MS;
function getValidDuration(duration: number) {
return Number.isFinite(duration) && duration > 0 ? duration : undefined;
@@ -48,6 +53,22 @@ function isStreamingUrl(url: string): boolean {
return path.endsWith(".m3u8") || path.endsWith(".mpd");
}
+export function loomImportProcessingWaitMs(
+ durationSeconds: number | null | undefined,
+) {
+ const lifetime =
+ durationSeconds && Number.isFinite(durationSeconds) && durationSeconds > 0
+ ? Math.min(
+ MEDIA_SERVER_MAX_LIFETIME_MS,
+ Math.max(
+ MEDIA_SERVER_MIN_LIFETIME_MS,
+ MEDIA_SERVER_LIFETIME_BASE_MS + Math.ceil(durationSeconds * 1000),
+ ),
+ )
+ : MEDIA_SERVER_MAX_LIFETIME_MS;
+ return lifetime + PROCESSING_WAIT_MARGIN_MS;
+}
+
function isLoomUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(LOOM_UNAVAILABLE_ERROR);
@@ -231,11 +252,16 @@ export async function importLoomVideoWorkflow(
let metadata: ProcessedVideoMetadata;
let loomWaits = 0;
+ let durationSeconds: number | null = null;
for (let processingAttempt = 0; ; processingAttempt++) {
let capacityRetryCount = 0;
while (true) {
try {
- await processVideoOnMediaServer(payload, processingInput);
+ const started = await processVideoOnMediaServer(
+ payload,
+ processingInput,
+ );
+ durationSeconds = started?.durationSeconds ?? durationSeconds;
break;
} catch (error) {
if (
@@ -254,7 +280,10 @@ export async function importLoomVideoWorkflow(
}
}
try {
- metadata = await waitForVideoProcessing(payload.videoId);
+ metadata = await waitForVideoProcessing(
+ payload.videoId,
+ loomImportProcessingWaitMs(durationSeconds),
+ );
break;
} catch (error) {
if (
@@ -428,7 +457,7 @@ async function startMediaServerProcessJob(
async function processVideoOnMediaServer(
payload: ImportLoomPayload,
processingInput: LoomProcessingInput,
-): Promise {
+): Promise<{ durationSeconds: number | null }> {
"use step";
const { videoId, userId, rawFileKey, loomVideoId } = payload;
@@ -450,6 +479,7 @@ async function processVideoOnMediaServer(
outputPresignedUrl,
thumbnailPresignedUrl,
previewGifPresignedUrl,
+ durationSeconds,
} = await Effect.gen(function* () {
const [video] = yield* Effect.promise(() =>
db()
@@ -516,6 +546,7 @@ async function processVideoOnMediaServer(
outputPresignedUrl,
thumbnailPresignedUrl,
previewGifPresignedUrl,
+ durationSeconds: getValidDuration(video.duration ?? 0) ?? null,
};
}).pipe(runWorkflowPromise);
@@ -547,6 +578,7 @@ async function processVideoOnMediaServer(
priority: "bulk",
});
await continueLoomImports(videoId);
+ return { durationSeconds };
}
async function saveMetadataAndComplete(
diff --git a/apps/web/workflows/video-processing-status.ts b/apps/web/workflows/video-processing-status.ts
index 8ff993a2ed0..6abf15e55ef 100644
--- a/apps/web/workflows/video-processing-status.ts
+++ b/apps/web/workflows/video-processing-status.ts
@@ -94,8 +94,9 @@ export async function readVideoProcessingStatus(
export async function waitForVideoProcessing(
videoId: string,
+ maxWaitMs = 60 * 60 * 1000,
): Promise {
- const deadline = Date.now() + 60 * 60 * 1000;
+ const deadline = Date.now() + maxWaitMs;
let lastStatus = "processing";
for (let attempt = 0; Date.now() < deadline; attempt++) {
const result = await readVideoProcessingStatus(videoId);
From 556b7f895fd3cc7e5b3ad3877ac0118b5d316a91 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:26:38 +0000
Subject: [PATCH 28/39] improve: wait out longer Loom rate limits before
failing link checks
Co-authored-by: Richie McIlroy
---
apps/web/workflows/loom-import-job.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/workflows/loom-import-job.ts b/apps/web/workflows/loom-import-job.ts
index 0196edc9223..b976b428206 100644
--- a/apps/web/workflows/loom-import-job.ts
+++ b/apps/web/workflows/loom-import-job.ts
@@ -9,7 +9,7 @@ interface LoomImportJobPayload {
jobId: string;
}
-const RESOLVE_PATIENCE = 6;
+const RESOLVE_PATIENCE = 10;
const RESOLVE_MAX_PASSES = 60;
async function resolveLoomImportJobStep(jobId: string, giveUp: boolean) {
From c8c29cc5add8d88d3cf529dc1c4c84e642cbf674 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:26:38 +0000
Subject: [PATCH 29/39] test: load test 29 people importing at once with long
recordings
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-scale.test.ts | 155 +++++++++++++++++-
1 file changed, 146 insertions(+), 9 deletions(-)
diff --git a/apps/web/__tests__/integration/loom-import-scale.test.ts b/apps/web/__tests__/integration/loom-import-scale.test.ts
index 3245c6be870..bead4665073 100644
--- a/apps/web/__tests__/integration/loom-import-scale.test.ts
+++ b/apps/web/__tests__/integration/loom-import-scale.test.ts
@@ -10,7 +10,7 @@ import {
videoUploads,
} from "@cap/database/schema";
import { Organisation, User, Video } from "@cap/web-domain";
-import { and, eq, inArray, ne, sql } from "drizzle-orm";
+import { and, eq, inArray, ne, or, sql } from "drizzle-orm";
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
import { Effect, Option } from "effect";
import { createPool, type Pool } from "mysql2/promise";
@@ -87,6 +87,7 @@ const enabled =
const LOOM_CAPACITY = 16;
const MEDIA_CAPACITY = 9;
const FAILURE_RATE = 0.01;
+const SIMULATED_MEDIA_FAILURE = "Simulated media failure";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -137,6 +138,8 @@ type Simulation = {
launched: number;
errors: number;
processing: [number, number];
+ slow?: { owners: Set; share: number; factor: number };
+ slowRuns: number;
};
function newSimulation(processing: [number, number]): Simulation {
@@ -148,6 +151,7 @@ function newSimulation(processing: [number, number]): Simulation {
launched: 0,
errors: 0,
processing,
+ slowRuns: 0,
};
}
@@ -198,7 +202,11 @@ async function timedDispatch(simulation: Simulation, videoId: string) {
simulation.dispatchMs.push(performance.now() - startedAt);
}
-async function simulateVideo(simulation: Simulation, videoId: Video.VideoId) {
+async function simulateVideo(
+ simulation: Simulation,
+ videoId: Video.VideoId,
+ ownerId: string,
+) {
const { media } = simulation;
const upload = (values: Partial) =>
database()
@@ -220,7 +228,13 @@ async function simulateVideo(simulation: Simulation, videoId: Video.VideoId) {
});
await timedDispatch(simulation, videoId);
const [shortest, longest] = simulation.processing;
- const duration = shortest + Math.random() * (longest - shortest);
+ const slow =
+ simulation.slow?.owners.has(ownerId) &&
+ Math.random() < simulation.slow.share;
+ if (slow) simulation.slowRuns++;
+ const duration =
+ (shortest + Math.random() * (longest - shortest)) *
+ (slow ? (simulation.slow?.factor ?? 1) : 1);
await wait(duration / 2);
await upload({
processingProgress: 50,
@@ -232,7 +246,7 @@ async function simulateVideo(simulation: Simulation, videoId: Video.VideoId) {
media.failed++;
await upload({
phase: "error",
- processingError: "Simulated media failure",
+ processingError: SIMULATED_MEDIA_FAILURE,
});
} else {
media.completed++;
@@ -296,6 +310,22 @@ async function cleanUp(orgIds: string[]) {
}
}
+async function lookupFailureCount() {
+ const [row] = await database()
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(loomImportJobItems)
+ .where(
+ or(
+ eq(loomImportJobItems.status, "pending"),
+ and(
+ eq(loomImportJobItems.status, "failed"),
+ ne(loomImportJobItems.error, SIMULATED_MEDIA_FAILURE),
+ ),
+ ),
+ );
+ return row?.count ?? 0;
+}
+
async function inFlightCount() {
const [row] = await database()
.select({ count: sql`COUNT(*)`.mapWith(Number) })
@@ -319,11 +349,15 @@ function wireStart(simulation: Simulation) {
fixture.start
.mockReset()
.mockImplementation(
- async (_workflow: unknown, [payload]: [{ videoId: string }]) => {
+ async (
+ _workflow: unknown,
+ [payload]: [{ videoId: string; userId: string }],
+ ) => {
simulation.launched++;
const run = simulateVideo(
simulation,
Video.VideoId.make(payload.videoId),
+ payload.userId,
).catch((error) => {
simulation.errors++;
console.error("[scale] simulated video failed", error);
@@ -689,10 +723,7 @@ describe.runIf(enabled)("Loom CSV imports under load", () => {
true,
);
- const afterChecks = await statusesByJob();
- const lookupFailures = afterChecks
- .filter((row) => row.status === "failed" || row.status === "pending")
- .reduce((total, row) => total + row.count, 0);
+ const lookupFailures = await lookupFailureCount();
await wait(90_000);
@@ -839,4 +870,110 @@ describe.runIf(enabled)("Loom CSV imports under load", () => {
},
20 * 60 * 1000,
);
+
+ it(
+ "29 people importing 2,000 videos each, some with 3 hour recordings, all keep moving",
+ async () => {
+ await cleanUp([]);
+ const people = await makePeople(29);
+ orgIds.push(...people.map((person) => person.orgId));
+ const simulation = newSimulation([150, 350]);
+ const longOwners = new Set(
+ people.slice(0, 10).map((person) => person.ownerId),
+ );
+ simulation.slow = { owners: longOwners, share: 0.25, factor: 30 };
+ wireStart(simulation);
+ vi.stubGlobal("fetch", fakeLoom(simulation));
+
+ const jobIds: string[] = [];
+ for (const person of people) {
+ const { jobId } = await createLoomImportJob({
+ userId: person.ownerId,
+ orgId: person.orgId,
+ fileName: "loom-export.csv",
+ rows: Array.from({ length: 2000 }, (_, index) => ({
+ rowNumber: index + 2,
+ loomUrl: `https://www.loom.com/share/${randomUUID().replaceAll("-", "")}`,
+ })),
+ });
+ jobIds.push(jobId);
+ }
+ const jobOwner = new Map(
+ jobIds.map((jobId, index) => [jobId, people[index]?.ownerId ?? ""]),
+ );
+
+ let most = 0;
+ let sampling = true;
+ const sampler = (async () => {
+ while (sampling) {
+ most = Math.max(most, await inFlightCount());
+ await wait(100);
+ }
+ })();
+
+ const checkedAt = performance.now();
+ await Promise.all(
+ jobIds.map((jobId) => loomImportJobWorkflow({ jobId })),
+ );
+ const checkedMs = performance.now() - checkedAt;
+ const lookupFailures = await lookupFailureCount();
+
+ await wait(90_000);
+ for (const jobId of jobIds) await cancelLoomImportJob(jobId);
+ while (simulation.running.size > 0) {
+ await Promise.all(Array.from(simulation.running));
+ }
+ sampling = false;
+ await sampler;
+
+ const finalStatuses = await statusesByJob();
+ const completed = new Map();
+ for (const row of finalStatuses) {
+ if (row.status === "complete") completed.set(row.jobId, row.count);
+ }
+ const withLong: number[] = [];
+ const shortOnly: number[] = [];
+ for (const jobId of jobIds) {
+ const count = completed.get(jobId) ?? 0;
+ if (longOwners.has(jobOwner.get(jobId) ?? "")) withLong.push(count);
+ else shortOnly.push(count);
+ }
+ const stillImporting = finalStatuses
+ .filter((row) => row.status === "importing")
+ .reduce((total, row) => total + row.count, 0);
+ const report = {
+ people: people.length,
+ rows: people.length * 2000,
+ peopleWithLongVideos: longOwners.size,
+ longVideoRuns: simulation.slowRuns,
+ allLinksCheckedMs: Math.round(checkedMs),
+ lookupFailures,
+ loom: simulation.loom,
+ mostInFlight: most,
+ media: simulation.media,
+ completedPerImport: {
+ withLongVideos: {
+ min: Math.min(...withLong),
+ max: Math.max(...withLong),
+ },
+ shortVideosOnly: {
+ min: Math.min(...shortOnly),
+ max: Math.max(...shortOnly),
+ },
+ },
+ stillImportingAfterDrain: stillImporting,
+ simulationErrors: simulation.errors,
+ dispatchMs: stats(simulation.dispatchMs),
+ };
+ console.info(`[loom-import scale] ${JSON.stringify(report, null, 1)}`);
+
+ expect(lookupFailures).toBe(0);
+ expect(most).toBeLessThanOrEqual(loomImportGlobalConcurrency());
+ expect(simulation.media.most).toBeLessThanOrEqual(MEDIA_CAPACITY);
+ expect(Math.min(...withLong, ...shortOnly)).toBeGreaterThan(0);
+ expect(stillImporting).toBe(0);
+ expect(simulation.errors).toBe(0);
+ },
+ 20 * 60 * 1000,
+ );
});
From 2b50fbef7a8434bf24bb556ca50a95bd13170ea3 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:56:58 +0000
Subject: [PATCH 30/39] fix: stop media jobs that stop making progress instead
of capping long videos
Co-authored-by: Richie McIlroy
---
.../src/__tests__/lib/idle-timeout.test.ts | 37 ++++
.../src/__tests__/lib/job-manager.test.ts | 71 +++++---
.../lib/media-video.integration.test.ts | 75 +++++++++
apps/media-server/src/lib/job-manager.ts | 66 +++++---
apps/media-server/src/lib/media-common.ts | 36 ++++
apps/media-server/src/lib/media-video.ts | 159 +++++++++++-------
apps/media-server/src/routes/video.ts | 60 ++++++-
7 files changed, 384 insertions(+), 120 deletions(-)
create mode 100644 apps/media-server/src/__tests__/lib/idle-timeout.test.ts
diff --git a/apps/media-server/src/__tests__/lib/idle-timeout.test.ts b/apps/media-server/src/__tests__/lib/idle-timeout.test.ts
new file mode 100644
index 00000000000..54c6c564749
--- /dev/null
+++ b/apps/media-server/src/__tests__/lib/idle-timeout.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test";
+import { withIdleTimeout } from "../../lib/media-common";
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+describe("withIdleTimeout", () => {
+ test("lets slow work finish for as long as it keeps reporting progress", async () => {
+ const result = await withIdleTimeout(async (touch) => {
+ for (let step = 0; step < 8; step++) {
+ await wait(30);
+ touch();
+ }
+ return "done";
+ }, 80);
+ expect(result).toBe("done");
+ });
+
+ test("stops work that goes quiet and runs its cleanup", async () => {
+ let cleaned = false;
+ const started = performance.now();
+ await expect(
+ withIdleTimeout(
+ async (touch) => {
+ touch();
+ await wait(1_000);
+ return "late";
+ },
+ 60,
+ () => {
+ cleaned = true;
+ },
+ ),
+ ).rejects.toThrow("Stopped making progress");
+ expect(performance.now() - started).toBeLessThan(500);
+ expect(cleaned).toBe(true);
+ });
+});
diff --git a/apps/media-server/src/__tests__/lib/job-manager.test.ts b/apps/media-server/src/__tests__/lib/job-manager.test.ts
index 62f606eaebf..5f2dc321540 100644
--- a/apps/media-server/src/__tests__/lib/job-manager.test.ts
+++ b/apps/media-server/src/__tests__/lib/job-manager.test.ts
@@ -6,13 +6,15 @@ import {
cleanupExpiredJobs,
createJob,
deleteJob,
- extendJobLifetimeForMedia,
getJob,
+ JOB_PROGRESS_STALL_MS,
type JobProgress,
+ markJobProgress,
type RecordingWorkerAcknowledgement,
sendWebhook,
touchJob,
updateJob,
+ watchJobProgress,
} from "../../lib/job-manager";
const createdJobs: string[] = [];
@@ -523,37 +525,58 @@ describe("job cleanup", () => {
expect(currentJob?.error).toContain("maximum lifetime of 60 minutes");
});
- test("gives a long video time to finish in proportion to its length", () => {
+ test("keeps a job that is still making progress running for hours", () => {
+ const job = createTrackedJob("job-long-but-moving");
const now = Date.now();
- const running = createTrackedJob("job-long-media-running");
- running.phase = "processing";
- running.createdAt = now - 2 * 60 * 60 * 1000;
- running.updatedAt = now;
- extendJobLifetimeForMedia(running.jobId, 3 * 60 * 60);
- const overdue = createTrackedJob("job-long-media-overdue");
- overdue.phase = "processing";
- overdue.createdAt = now - 3 * 60 * 60 * 1000 - 60_000;
- overdue.updatedAt = now;
- extendJobLifetimeForMedia(overdue.jobId, 3 * 60 * 60);
+ job.phase = "processing";
+ watchJobProgress(job.jobId);
+ job.createdAt = now - 3 * 60 * 60 * 1000;
+ job.updatedAt = now;
+ job.progressAt = now - 60_000;
- expect(cleanupExpiredJobs()).toBe(1);
- expect(getJob(running.jobId)?.phase).toBe("processing");
- expect(getJob(overdue.jobId)?.error).toContain(
- "maximum lifetime of 180 minutes",
- );
+ expect(cleanupExpiredJobs()).toBe(0);
+ expect(getJob(job.jobId)?.phase).toBe("processing");
});
- test("keeps the one-hour cap for short videos", () => {
- const job = createTrackedJob("job-short-media");
+ test("fails a job that stopped making progress, however short it is", () => {
+ const job = createTrackedJob("job-short-but-stuck");
const now = Date.now();
job.phase = "processing";
- job.createdAt = now - 61 * 60 * 1000;
+ watchJobProgress(job.jobId);
+ job.createdAt = now - 20 * 60 * 1000;
job.updatedAt = now;
- extendJobLifetimeForMedia(job.jobId, 4 * 60);
+ job.progressAt = now - JOB_PROGRESS_STALL_MS - 60_000;
expect(cleanupExpiredJobs()).toBe(1);
- expect(getJob(job.jobId)?.error).toContain(
- "maximum lifetime of 60 minutes",
- );
+ expect(getJob(job.jobId)).toMatchObject({
+ phase: "error",
+ message: "Processing failed (stalled)",
+ });
+ expect(getJob(job.jobId)?.error).toContain("stopped making progress");
+ });
+
+ test("counts rising progress and new phases as progress, not heartbeats", () => {
+ const job = createTrackedJob("job-progress-signals");
+ job.phase = "processing";
+ job.progress = 20;
+ watchJobProgress(job.jobId);
+ const old = Date.now() - 10 * 60 * 1000;
+
+ job.progressAt = old;
+ touchJob(job.jobId);
+ updateJob(job.jobId, { message: "Still here" });
+ updateJob(job.jobId, { progress: 20 });
+ expect(job.progressAt).toBe(old);
+
+ updateJob(job.jobId, { progress: 21 });
+ expect(job.progressAt).toBeGreaterThan(old);
+
+ job.progressAt = old;
+ updateJob(job.jobId, { phase: "uploading" });
+ expect(job.progressAt).toBeGreaterThan(old);
+
+ job.progressAt = old;
+ markJobProgress(job.jobId);
+ expect(job.progressAt).toBeGreaterThan(old);
});
});
diff --git a/apps/media-server/src/__tests__/lib/media-video.integration.test.ts b/apps/media-server/src/__tests__/lib/media-video.integration.test.ts
index dd9844d100c..a18837e4eb9 100644
--- a/apps/media-server/src/__tests__/lib/media-video.integration.test.ts
+++ b/apps/media-server/src/__tests__/lib/media-video.integration.test.ts
@@ -32,6 +32,7 @@ import {
repairContainer,
uploadFileToS3,
uploadFileToStorage,
+ uploadTimeoutMs,
uploadToS3,
} from "../../lib/media-video";
@@ -1008,6 +1009,80 @@ describe("processVideo integration tests", () => {
expect(existsSync(tempFile.path)).toBe(false);
}, 60000);
+ test("lets an encode run past its idle limit while frames keep advancing", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "advancing-encode-"));
+ const longInput = join(dir, "input.mp4");
+ execFileSync("ffmpeg", [
+ "-hide_banner",
+ "-v",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ "testsrc2=size=1280x720:rate=30",
+ "-t",
+ "60",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "ultrafast",
+ "-pix_fmt",
+ "yuv420p",
+ "-y",
+ longInput,
+ ]);
+ const idleTimeoutMs = 1_500;
+ try {
+ const metadata = await probeVideo(`file://${longInput}`);
+ const progressUpdates: number[] = [];
+ const started = performance.now();
+
+ const tempFile = await processVideo(
+ longInput,
+ metadata,
+ { maxWidth: 960, maxHeight: 540, preset: "medium", idleTimeoutMs },
+ (progress) => progressUpdates.push(progress),
+ );
+ tempFiles.push(tempFile.path);
+
+ expect(performance.now() - started).toBeGreaterThan(idleTimeoutMs * 2);
+ expect(progressUpdates.length).toBeGreaterThan(2);
+ for (let index = 1; index < progressUpdates.length; index++) {
+ expect(progressUpdates[index]).toBeGreaterThan(
+ progressUpdates[index - 1] ?? -1,
+ );
+ }
+ await tempFile.cleanup();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }, 120000);
+
+ test("stops an encode whose input stops producing frames", async () => {
+ const metadata = await probeVideo(`file://${TEST_VIDEO_WITH_AUDIO}`);
+ const dir = mkdtempSync(join(tmpdir(), "stalled-encode-"));
+ const stalledInput = join(dir, "input.mp4");
+ execFileSync("mkfifo", [stalledInput]);
+ try {
+ const started = performance.now();
+ await expect(
+ processVideo(stalledInput, metadata, {
+ remuxOnly: true,
+ idleTimeoutMs: 1_000,
+ }),
+ ).rejects.toThrow("Stopped making progress");
+ expect(performance.now() - started).toBeLessThan(15_000);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }, 30000);
+
+ test("gives large uploads time in proportion to their size", () => {
+ expect(uploadTimeoutMs(0)).toBe(10 * 60 * 1000);
+ expect(uploadTimeoutMs(200 * 1024 * 1024)).toBe(10 * 60 * 1000);
+ expect(uploadTimeoutMs(3 * 1024 ** 3)).toBe(3 * 1024 * 1000);
+ });
+
test("respects CRF setting", async () => {
const metadata = await probeVideo(`file://${TEST_VIDEO_WITH_AUDIO}`);
diff --git a/apps/media-server/src/lib/job-manager.ts b/apps/media-server/src/lib/job-manager.ts
index 57152c353a9..8de45892d29 100644
--- a/apps/media-server/src/lib/job-manager.ts
+++ b/apps/media-server/src/lib/job-manager.ts
@@ -124,7 +124,8 @@ export interface Job {
webhookLastAttemptAt?: number;
recordingVerificationDeadlineAt?: number;
recordingProcessingDeadlineAt?: number;
- mediaDeadlineAt?: number;
+ progressWatched?: boolean;
+ progressAt?: number;
recordingVerification?: RecordingVerificationProof;
manifestSha256?: string;
jobId: string;
@@ -151,8 +152,7 @@ const jobs = new Map();
const JOB_TTL_MS = 60 * 60 * 1000;
const STALE_JOB_MS = 15 * 60 * 1000;
const MAX_JOB_LIFETIME_MS = 60 * 60 * 1000;
-const MEDIA_JOB_LIFETIME_BASE_MS = 30 * 60 * 1000;
-const MAX_MEDIA_JOB_LIFETIME_MS = 3 * 60 * 60 * 1000;
+export const JOB_PROGRESS_STALL_MS = 15 * 60 * 1000;
const MAX_RECORDING_PROCESSING_BUDGET_MS = 3 * 60 * 60 * 1000;
const WEBHOOK_MAX_ATTEMPTS = 3;
const WEBHOOK_RETRY_BASE_MS = 500;
@@ -405,6 +405,13 @@ export function updateJob(
if (!job) return undefined;
if (!isActivePhase(job.phase)) return undefined;
+ if (
+ job.progressWatched &&
+ ((updates.phase !== undefined && updates.phase !== job.phase) ||
+ (updates.progress !== undefined && updates.progress > job.progress))
+ ) {
+ job.progressAt = Date.now();
+ }
Object.assign(job, updates, { updatedAt: Date.now() });
if (job.recordingWorkerVersion) {
job.recordingWorkerSequence = (job.recordingWorkerSequence ?? 0) + 1;
@@ -478,30 +485,16 @@ export function beginRecordingProcessing(
return true;
}
-export function extendJobLifetimeForMedia(
- jobId: string,
- durationSeconds: number,
-): number | undefined {
+export function watchJobProgress(jobId: string) {
const job = jobs.get(jobId);
- if (
- !job ||
- !isActivePhase(job.phase) ||
- !Number.isFinite(durationSeconds) ||
- durationSeconds <= 0
- )
- return job?.mediaDeadlineAt;
- const lifetime = Math.min(
- MAX_MEDIA_JOB_LIFETIME_MS,
- Math.max(
- MAX_JOB_LIFETIME_MS,
- MEDIA_JOB_LIFETIME_BASE_MS + Math.ceil(durationSeconds * 1000),
- ),
- );
- job.mediaDeadlineAt = Math.max(
- job.mediaDeadlineAt ?? 0,
- job.createdAt + lifetime,
- );
- return job.mediaDeadlineAt;
+ if (!job) return;
+ job.progressWatched = true;
+ job.progressAt = Date.now();
+}
+
+export function markJobProgress(jobId: string) {
+ const job = jobs.get(jobId);
+ if (job?.progressWatched) job.progressAt = Date.now();
}
export function deleteJob(jobId: string): boolean {
@@ -611,10 +604,27 @@ export function cleanupExpiredJobs(): number {
continue;
}
+ if (job.progressWatched) {
+ const quietFor = now - (job.progressAt ?? job.createdAt);
+ if (isActivePhase(job.phase) && quietFor > JOB_PROGRESS_STALL_MS) {
+ console.warn(
+ `[job-manager] Marking job ${jobId} as error after ${Math.round(quietFor / 60000)}m without progress (phase=${job.phase}, age=${Math.round(age / 60000)}m)`,
+ );
+ job.abortController?.abort();
+ updateJob(jobId, {
+ phase: "error",
+ error: `Processing stopped making progress for ${Math.round(quietFor / 60000)} minutes`,
+ message: "Processing failed (stalled)",
+ });
+ void sendWebhook(job);
+ cleaned++;
+ }
+ continue;
+ }
+
const deadline =
job.recordingVerificationDeadlineAt ??
job.recordingProcessingDeadlineAt ??
- job.mediaDeadlineAt ??
job.createdAt + MAX_JOB_LIFETIME_MS;
if (isActivePhase(job.phase) && now > deadline) {
console.warn(
@@ -626,7 +636,7 @@ export function cleanupExpiredJobs(): number {
error:
job.recordingVerificationDeadlineAt === undefined &&
job.recordingProcessingDeadlineAt === undefined
- ? `Job exceeded maximum lifetime of ${Math.round((deadline - job.createdAt) / 60000)} minutes`
+ ? `Job exceeded maximum lifetime of ${Math.round(MAX_JOB_LIFETIME_MS / 60000)} minutes`
: "Recording verification timed out",
message: "Processing failed (timeout)",
});
diff --git a/apps/media-server/src/lib/media-common.ts b/apps/media-server/src/lib/media-common.ts
index ad3c692242e..d844b880c40 100644
--- a/apps/media-server/src/lib/media-common.ts
+++ b/apps/media-server/src/lib/media-common.ts
@@ -61,6 +61,42 @@ export async function withTimeout(
}
}
+export async function withIdleTimeout(
+ run: (touch: () => void) => Promise,
+ idleMs: number,
+ cleanup?: () => void | Promise,
+): Promise {
+ let timeoutId: ReturnType | undefined;
+ let rejectIdle: ((error: Error) => void) | undefined;
+ let cleanupPromise: Promise | undefined;
+ const idlePromise = new Promise((_, reject) => {
+ rejectIdle = reject;
+ });
+ const touch = () => {
+ if (timeoutId) clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ cleanupPromise = Promise.resolve()
+ .then(() => cleanup?.())
+ .then(() => undefined);
+ rejectIdle?.(
+ new Error(
+ `Stopped making progress for ${Math.round(idleMs / 1000)} seconds`,
+ ),
+ );
+ }, idleMs);
+ };
+
+ touch();
+ try {
+ return await Promise.race([run(touch), idlePromise]);
+ } catch (err) {
+ if (cleanupPromise) await cleanupPromise;
+ throw err;
+ } finally {
+ if (timeoutId) clearTimeout(timeoutId);
+ }
+}
+
function isHttpUrl(path: string): boolean {
try {
const url = new URL(path);
diff --git a/apps/media-server/src/lib/media-video.ts b/apps/media-server/src/lib/media-video.ts
index 94c599a7099..4e49a1492e6 100644
--- a/apps/media-server/src/lib/media-video.ts
+++ b/apps/media-server/src/lib/media-video.ts
@@ -11,6 +11,7 @@ import {
PROCESS_TIMEOUT_MS,
type ProgressCallback,
UPLOAD_TIMEOUT_MS,
+ withIdleTimeout,
withTimeout,
} from "./media-common";
import { probeVideoFile } from "./media-probe";
@@ -29,7 +30,8 @@ import {
} from "./temp-files";
const PROCESS_TIMEOUT_PER_SECOND_MS = 20_000;
-const MAX_PROCESS_TIMEOUT_MS = 3 * 60 * 60 * 1000;
+const MAX_PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000;
+const DOWNLOAD_IDLE_TIMEOUT_MS = 3 * 60 * 1000;
// HLS/DASH sources are pulled as many sequential segment requests rather than
// one streamed fetch, so per-request overhead scales with video length. A
// flat 10-minute budget is enough for typical short recordings but not for a
@@ -42,6 +44,7 @@ const PROBE_H264_LEVEL_TIMEOUT_MS = 10_000;
const FFMPEG_HLS_CAPABILITY_TIMEOUT_MS = 10_000;
const UPLOAD_MAX_RETRIES = 4;
const UPLOAD_RETRY_BASE_MS = 250;
+const UPLOAD_MIN_BYTES_PER_SECOND = 1024 * 1024;
const MAX_STDERR_BYTES = 64 * 1024;
const REPAIR_TIMEOUT_MS = 5 * 60 * 1000;
const MAX_LEVEL_4_2_WIDTH = 2048;
@@ -93,6 +96,7 @@ export interface VideoProcessingOptions {
remuxOnly?: boolean;
normalizeH264Level?: boolean;
timeoutMs?: number;
+ idleTimeoutMs?: number;
}
export interface ThumbnailOptions {
@@ -169,6 +173,7 @@ const DEFAULT_OPTIONS: Required = {
remuxOnly: false,
normalizeH264Level: false,
timeoutMs: PROCESS_TIMEOUT_MS,
+ idleTimeoutMs: 0,
};
const DEFAULT_THUMBNAIL_OPTIONS: Required = {
@@ -983,14 +988,10 @@ async function storageResponseError(
);
}
-function parseProgressFromStderr(
- stderrLine: string,
- totalDurationUs: number,
-): number | null {
+function parseOutTimeUs(stderrLine: string): number | null {
const match = stderrLine.match(/out_time_us=(\d+)/);
if (!match) return null;
- const currentUs = Number.parseInt(match[1] ?? "0", 10);
- return Math.min(100, (currentUs / totalDurationUs) * 100);
+ return Number.parseInt(match[1] ?? "0", 10);
}
async function runFfmpegCommand(
@@ -1228,6 +1229,7 @@ export async function downloadVideoToTemp(
videoUrl: string,
inputExtension?: string,
abortSignal?: AbortSignal,
+ onBytes?: (bytes: number) => void,
): Promise {
if (isStreamingUrl(videoUrl)) {
return await downloadStreamingVideoToTemp(videoUrl, abortSignal);
@@ -1240,11 +1242,26 @@ export async function downloadVideoToTemp(
normalizeVideoInputExtension(inputExtension),
);
+ const idle = new AbortController();
+ let idleTimer: ReturnType | undefined;
+ const stillReceiving = () => {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(
+ () =>
+ idle.abort(
+ new Error(
+ `Download stopped receiving data for ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds`,
+ ),
+ ),
+ DOWNLOAD_IDLE_TIMEOUT_MS,
+ );
+ };
+
try {
- const timeoutSignal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
+ stillReceiving();
const combinedSignal = abortSignal
- ? AbortSignal.any([abortSignal, timeoutSignal])
- : timeoutSignal;
+ ? AbortSignal.any([abortSignal, idle.signal])
+ : idle.signal;
const response = await fetchMedia(videoUrl, {
signal: combinedSignal,
@@ -1263,11 +1280,15 @@ export async function downloadVideoToTemp(
const reader = response.body.getReader();
const writer = file(tempFile.path).writer();
+ let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
writer.write(value);
+ received += value.byteLength;
+ stillReceiving();
+ onBytes?.(received);
}
await writer.end();
} finally {
@@ -1281,7 +1302,11 @@ export async function downloadVideoToTemp(
return tempFile;
} catch (err) {
await tempFile.cleanup();
- throw err;
+ throw idle.signal.aborted && !abortSignal?.aborted
+ ? idle.signal.reason
+ : err;
+ } finally {
+ if (idleTimer) clearTimeout(idleTimer);
}
}
@@ -1599,56 +1624,65 @@ export async function processVideo(
const stderrLines: string[] = [];
const maxStderrLines = 50;
- try {
- await withTimeout(
- (async () => {
- void drainStream(proc.stdout as ReadableStream);
+ const encode = async (touch?: () => void) => {
+ void drainStream(proc.stdout as ReadableStream);
- const stderrReader = (
- proc.stderr as ReadableStream
- ).getReader();
- const decoder = new TextDecoder();
- let stderrBuffer = "";
-
- try {
- while (true) {
- const { done, value } = await stderrReader.read();
- if (done) break;
-
- stderrBuffer += decoder.decode(value, { stream: true });
- const lines = stderrBuffer.split("\n");
- stderrBuffer = lines.pop() ?? "";
-
- for (const line of lines) {
- stderrLines.push(line);
- if (stderrLines.length > maxStderrLines) {
- stderrLines.shift();
- }
- const progress = parseProgressFromStderr(line, totalDurationUs);
- if (progress !== null && onProgress) {
- onProgress(progress, `Encoding: ${Math.round(progress)}%`);
- }
- }
+ const stderrReader = (
+ proc.stderr as ReadableStream
+ ).getReader();
+ const decoder = new TextDecoder();
+ let stderrBuffer = "";
+ let encodedUs = -1;
+
+ try {
+ while (true) {
+ const { done, value } = await stderrReader.read();
+ if (done) break;
+
+ stderrBuffer += decoder.decode(value, { stream: true });
+ const lines = stderrBuffer.split("\n");
+ stderrBuffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ stderrLines.push(line);
+ if (stderrLines.length > maxStderrLines) {
+ stderrLines.shift();
}
- } finally {
- stderrReader.releaseLock();
+ const outTimeUs = parseOutTimeUs(line);
+ if (outTimeUs === null || outTimeUs <= encodedUs) continue;
+ encodedUs = outTimeUs;
+ touch?.();
+ const progress = Math.min(100, (outTimeUs / totalDurationUs) * 100);
+ onProgress?.(progress, `Encoding: ${Math.round(progress)}%`);
}
+ }
+ } finally {
+ stderrReader.releaseLock();
+ }
- const exitCode = await proc.exited;
- if (exitCode !== 0) {
- throw new Error(
- `FFmpeg exited with code ${exitCode}. Last stderr: ${stderrLines.slice(-10).join(" | ")}`,
- );
- }
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(
+ `FFmpeg exited with code ${exitCode}. Last stderr: ${stderrLines.slice(-10).join(" | ")}`,
+ );
+ }
- const outputSize = await file(outputTempFile.path).size;
- if (outputSize === 0) {
- throw new Error("FFmpeg produced empty output file");
- }
- })(),
- processTimeoutMs,
- () => terminateProcess(proc),
- );
+ const outputSize = await file(outputTempFile.path).size;
+ if (outputSize === 0) {
+ throw new Error("FFmpeg produced empty output file");
+ }
+ };
+
+ try {
+ if (opts.idleTimeoutMs > 0) {
+ await withIdleTimeout(encode, opts.idleTimeoutMs, () =>
+ terminateProcess(proc),
+ );
+ } else {
+ await withTimeout(encode(), processTimeoutMs, () =>
+ terminateProcess(proc),
+ );
+ }
return outputTempFile;
} catch (err) {
@@ -2106,8 +2140,15 @@ async function readUploadReceipt(
return { objectIdentity: `"cap-drive-content-v1:${digest}"` };
}
-function uploadSignal(abortSignal?: AbortSignal) {
- const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
+export function uploadTimeoutMs(contentLength = 0) {
+ return Math.max(
+ UPLOAD_TIMEOUT_MS,
+ Math.ceil(contentLength / UPLOAD_MIN_BYTES_PER_SECOND) * 1000,
+ );
+}
+
+function uploadSignal(abortSignal?: AbortSignal, contentLength?: number) {
+ const timeout = AbortSignal.timeout(uploadTimeoutMs(contentLength));
return abortSignal ? AbortSignal.any([abortSignal, timeout]) : timeout;
}
@@ -2152,7 +2193,7 @@ async function uploadWithRetry(
method: "PUT",
headers,
body: bodyFactory(),
- signal: uploadSignal(abortSignal),
+ signal: uploadSignal(abortSignal, contentLength),
});
} catch (err) {
abortSignal?.throwIfAborted();
diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts
index fadeb806ef9..82caa6bc00d 100644
--- a/apps/media-server/src/routes/video.ts
+++ b/apps/media-server/src/routes/video.ts
@@ -17,7 +17,6 @@ import {
claimRecordingWorker,
createJob,
deleteJob,
- extendJobLifetimeForMedia,
forceCleanupActiveJobs,
generateJobId,
getActiveVideoProcessCount,
@@ -27,9 +26,11 @@ import {
getMaxConcurrentVideoProcesses,
getSystemResources,
hasCriticalMemoryPressure,
+ markJobProgress,
sendWebhook,
touchJob,
updateJob,
+ watchJobProgress,
} from "../lib/job-manager";
import { PROCESS_TIMEOUT_MS } from "../lib/media-common";
import { renderEditedVideo } from "../lib/media-edit";
@@ -82,6 +83,9 @@ import { validateVideoInput } from "../lib/video-input-validation";
const video = new Hono();
const PROCESSING_HEARTBEAT_MS = 60 * 1000;
+const LIVENESS_WEBHOOK_TICKS = 5;
+const PROGRESS_WEBHOOK_INTERVAL_MS = 15 * 1000;
+const FFMPEG_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const RECORDING_WORKER_INSTANCE = randomUUID();
const SEGMENTED_RECORDING_TIMEOUT_MS = 3 * PROCESS_TIMEOUT_MS + 35 * 60 * 1000;
const POST_VERIFICATION_ASSET_BUDGET_MS = 5 * 60 * 1000;
@@ -346,6 +350,36 @@ async function withJobHeartbeat(
}
}
+async function withBoundedTransfer(
+ jobId: string,
+ operation: () => Promise,
+): Promise {
+ let ticks = 0;
+ const interval = setInterval(() => {
+ const job = getJob(jobId);
+ if (
+ !job ||
+ job.phase === "complete" ||
+ job.phase === "error" ||
+ job.phase === "cancelled"
+ ) {
+ clearInterval(interval);
+ return;
+ }
+ touchJob(jobId);
+ markJobProgress(jobId);
+ ticks++;
+ if (ticks % LIVENESS_WEBHOOK_TICKS === 0) void sendWebhook(job);
+ }, PROCESSING_HEARTBEAT_MS);
+ interval.unref?.();
+
+ try {
+ return await operation();
+ } finally {
+ clearInterval(interval);
+ }
+}
+
async function withMuxMemoryGuard(
abortController: AbortController,
operation: () => Promise,
@@ -927,11 +961,16 @@ async function processWithResilientRetry(
preset: options.preset,
remuxOnly: options.remuxOnly,
normalizeH264Level: options.normalizeH264Level,
+ idleTimeoutMs: FFMPEG_IDLE_TIMEOUT_MS,
};
+ let progressWebhookAt = 0;
const onProgress = (progress: number, message: string) => {
const scaledProgress = 10 + progress * 0.7;
updateJob(jobId, { progress: scaledProgress, message });
+ const now = Date.now();
+ if (now - progressWebhookAt < PROGRESS_WEBHOOK_INTERVAL_MS) return;
+ progressWebhookAt = now;
const currentJob = getJob(jobId);
if (currentJob) {
void sendWebhook(currentJob).catch((error) =>
@@ -1290,6 +1329,7 @@ async function processVideoAsync(
const abortController = new AbortController();
updateJob(jobId, { abortController });
+ watchJobProgress(jobId);
let repairedTempFile: TempFileHandle | null = null;
let lastResortRepairFile: TempFileHandle | null = null;
@@ -1302,11 +1342,12 @@ async function processVideoAsync(
});
await sendWebhook(job);
- const inputTempFile = await withJobHeartbeat(jobId, () =>
+ const inputTempFile = await withBoundedTransfer(jobId, () =>
downloadVideoToTemp(
videoUrl,
options.inputExtension,
abortController.signal,
+ () => markJobProgress(jobId),
),
);
updateJob(jobId, { inputTempFile });
@@ -1315,7 +1356,7 @@ async function processVideoAsync(
if (sourcePresignedUrl) {
updateJob(jobId, { message: "Saving original video..." });
await sendWebhook(job);
- await withJobHeartbeat(jobId, () =>
+ await withBoundedTransfer(jobId, () =>
uploadFileToS3(
inputTempFile.path,
sourcePresignedUrl,
@@ -1347,7 +1388,6 @@ async function processVideoAsync(
);
repairedTempFile = repairedFile;
updateJob(jobId, { metadata });
- extendJobLifetimeForMedia(jobId, metadata.duration);
const processingInputPath = repairedFile
? repairedFile.path
@@ -1382,11 +1422,13 @@ async function processVideoAsync(
});
await sendWebhook(job);
- const uploadReceipt = await uploadFileToS3(
- outputTempFile.path,
- outputPresignedUrl,
- "video/mp4",
- abortController.signal,
+ const uploadReceipt = await withBoundedTransfer(jobId, () =>
+ uploadFileToS3(
+ outputTempFile.path,
+ outputPresignedUrl,
+ "video/mp4",
+ abortController.signal,
+ ),
);
if (thumbnailPresignedUrl || previewGifPresignedUrl) {
From efd8c277082c35c1ce105394b9daa8185c4f996f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:56:58 +0000
Subject: [PATCH 31/39] fix: wait on video processing for as long as it makes
progress
Co-authored-by: Richie McIlroy
---
.../integration/loom-import-jobs.test.ts | 10 ++--
.../unit/media-processing-workflows.test.ts | 48 ++++++++++++++-----
.../unit/video-processing-status.test.ts | 39 ++++++++++++---
apps/web/lib/loom-import/recovery.ts | 2 +-
apps/web/workflows/import-loom-video.ts | 45 ++++-------------
apps/web/workflows/video-processing-status.ts | 30 ++++++++----
6 files changed, 104 insertions(+), 70 deletions(-)
diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts
index c1d6a414d1b..48dbf2c61be 100644
--- a/apps/web/__tests__/integration/loom-import-jobs.test.ts
+++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts
@@ -1031,23 +1031,23 @@ describe.runIf(Boolean(databaseUrl))(
await dispatchLoomImportJob(jobId);
const [stalled] = await inFlight();
const videoId = stalled?.videoId as Video.VideoId;
- const age = async (hours: number) =>
+ const age = async (minutes: number) =>
database()
.update(videoUploads)
- .set({ updatedAt: new Date(Date.now() - hours * 60 * 60 * 1000) })
+ .set({ updatedAt: new Date(Date.now() - minutes * 60 * 1000) })
.where(eq(videoUploads.videoId, videoId));
- await age(1);
+ await age(35);
expect(await recoverLoomImportJobs()).toMatchObject({
restarted: 1,
silent: 0,
});
- await age(1);
+ await age(35);
expect(await recoverLoomImportJobs()).toMatchObject({
restarted: 0,
silent: 0,
});
- await age(3);
+ await age(60);
expect(await recoverLoomImportJobs()).toMatchObject({
restarted: 0,
silent: 1,
diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts
index f7cf5298ff6..06152ecaf88 100644
--- a/apps/web/__tests__/unit/media-processing-workflows.test.ts
+++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts
@@ -69,10 +69,7 @@ vi.mock("workflow", () => ({
RetryableError: class RetryableError extends Error {},
}));
-import {
- importLoomVideoWorkflow,
- loomImportProcessingWaitMs,
-} from "@/workflows/import-loom-video";
+import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
import { processVideoWorkflow } from "@/workflows/process-video";
const payload = {
@@ -210,15 +207,6 @@ describe("media processing workflows", () => {
expect(mocks.continueLoomImport.mock.calls).toEqual([["video"], ["video"]]);
});
- it("waits longer for long Loom videos, in step with the media server", () => {
- const minutes = (ms: number) => ms / 60_000;
- expect(minutes(loomImportProcessingWaitMs(4 * 60))).toBe(75);
- expect(minutes(loomImportProcessingWaitMs(60 * 60))).toBe(105);
- expect(minutes(loomImportProcessingWaitMs(3 * 60 * 60))).toBe(195);
- expect(minutes(loomImportProcessingWaitMs(10 * 60 * 60))).toBe(195);
- expect(minutes(loomImportProcessingWaitMs(null))).toBe(195);
- });
-
it("waits for Loom instead of failing when Loom rate limits the download link", async () => {
let loomCalls = 0;
mocks.fetch.mockImplementation(async (url: string) => {
@@ -307,6 +295,40 @@ describe("media processing workflows", () => {
);
});
+ it("restarts a Loom video from its preserved original when the worker goes quiet", async () => {
+ let now = 0;
+ const clock = vi.spyOn(Date, "now").mockImplementation(() => now);
+ mocks.sleep.mockImplementation(async (delay: unknown) => {
+ if (typeof delay === "number") now += delay;
+ });
+ const quiet = { ...pending, updatedAt: new Date(0) };
+ try {
+ mocks.rows = [
+ [video],
+ ...Array.from({ length: 23 }, () => [quiet]),
+ [video],
+ [video],
+ [],
+ [metadata],
+ ];
+ await expect(importLoomVideoWorkflow(payload)).resolves.toMatchObject({
+ success: true,
+ });
+ expect(now).toBeGreaterThan(20 * 60 * 1000);
+ expect(mocks.fetch.mock.calls.map(([url]) => url)).toEqual([
+ "https://www.loom.com/api/campaigns/sessions/loom-video/transcoded-url",
+ "https://worker.example.com/video/import",
+ "https://worker.example.com/video/process",
+ ]);
+ expect(mocks.get).toHaveBeenCalledWith(
+ payload.rawFileKey,
+ expect.anything(),
+ );
+ } finally {
+ clock.mockRestore();
+ }
+ });
+
it("supports Loom streaming fallbacks without reading a nonexistent raw object", async () => {
mocks.fetch.mockImplementation(async (url: string) =>
Response.json(
diff --git a/apps/web/__tests__/unit/video-processing-status.test.ts b/apps/web/__tests__/unit/video-processing-status.test.ts
index 979821b2ad1..6259fbfdf13 100644
--- a/apps/web/__tests__/unit/video-processing-status.test.ts
+++ b/apps/web/__tests__/unit/video-processing-status.test.ts
@@ -26,6 +26,8 @@ vi.mock("workflow", () => ({
import {
readVideoProcessingStatus,
+ VIDEO_PROCESSING_STALL_MS,
+ VideoProcessingFailedError,
waitForVideoProcessing,
} from "@/workflows/video-processing-status";
@@ -96,13 +98,38 @@ describe("durable video processing completion", () => {
});
});
- it("bounds waiting when a worker never completes", async () => {
- mocks.where.mockResolvedValue([pending]);
- await expect(waitForVideoProcessing("video")).rejects.toThrow(
- "Video processing timed out while processing 25% Processing video...",
+ it("keeps waiting on a long recording for as long as the worker reports in", async () => {
+ const fourHours = 4 * 60 * 60 * 1000;
+ const heartbeatMs = 15 * 60 * 1000;
+ mocks.rows = [[], [metadata]];
+ mocks.where.mockImplementation(async () =>
+ mocks.now < fourHours
+ ? [
+ {
+ ...pending,
+ updatedAt: new Date(
+ Math.floor(mocks.now / heartbeatMs) * heartbeatMs,
+ ),
+ },
+ ]
+ : mocks.rows.shift(),
+ );
+ await expect(
+ waitForVideoProcessing("video", { maxPollMs: 2 * 60 * 1000 }),
+ ).resolves.toEqual(metadata);
+ expect(mocks.now).toBeGreaterThanOrEqual(fourHours);
+ expect(mocks.sleep.mock.calls.length).toBeLessThan(150);
+ });
+
+ it("gives up on processing that stops changing, however short the video", async () => {
+ mocks.where.mockResolvedValue([{ ...pending, updatedAt: new Date(0) }]);
+ const waiting = waitForVideoProcessing("video");
+ await expect(waiting).rejects.toBeInstanceOf(VideoProcessingFailedError);
+ await expect(waiting).rejects.toThrow(
+ "Video processing stopped making progress while processing 25% Processing video...",
);
- expect(mocks.now).toBeLessThanOrEqual(60 * 60 * 1000 + 30_000);
- expect(mocks.sleep.mock.calls.length).toBeLessThan(130);
+ expect(mocks.now).toBeGreaterThan(VIDEO_PROCESSING_STALL_MS);
+ expect(mocks.now).toBeLessThanOrEqual(VIDEO_PROCESSING_STALL_MS + 30_000);
expect(Math.max(...mocks.sleep.mock.calls.map(([delay]) => delay))).toBe(
30_000,
);
diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts
index 577addab03c..2d69d4caee1 100644
--- a/apps/web/lib/loom-import/recovery.ts
+++ b/apps/web/lib/loom-import/recovery.ts
@@ -14,7 +14,7 @@ import { affectedRows } from "./jobs";
const STALE_CHECKING_MS = 10 * 60 * 1000;
const STUCK_START_MS = 30 * 60 * 1000;
-const SILENT_IMPORT_MS = 2 * 60 * 60 * 1000;
+const SILENT_IMPORT_MS = 45 * 60 * 1000;
const SILENT_IMPORT_BATCH = 500;
export const LOOM_IMPORT_SILENT_ERROR =
"This video stopped responding while it was copying. Try it again.";
diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts
index 7c0a76228bc..1083530a50a 100644
--- a/apps/web/workflows/import-loom-video.ts
+++ b/apps/web/workflows/import-loom-video.ts
@@ -36,13 +36,9 @@ const LOOM_UNAVAILABLE_RETRY_AFTER_MS = 20_000;
const LOOM_UNAVAILABLE_MAX_WAITS = 6;
const MEDIA_SERVER_START_MAX_ATTEMPTS = 2;
const MEDIA_SERVER_START_RETRY_BASE_MS = 250;
-const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 4 * 60 * 60;
-const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 4 * 60 * 60;
-const MINUTE_MS = 60 * 1000;
-const MEDIA_SERVER_MIN_LIFETIME_MS = 60 * MINUTE_MS;
-const MEDIA_SERVER_LIFETIME_BASE_MS = 30 * MINUTE_MS;
-const MEDIA_SERVER_MAX_LIFETIME_MS = 3 * 60 * MINUTE_MS;
-const PROCESSING_WAIT_MARGIN_MS = 15 * MINUTE_MS;
+const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 24 * 60 * 60;
+const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 24 * 60 * 60;
+const PROCESSING_POLL_MAX_MS = 2 * 60 * 1000;
function getValidDuration(duration: number) {
return Number.isFinite(duration) && duration > 0 ? duration : undefined;
@@ -53,22 +49,6 @@ function isStreamingUrl(url: string): boolean {
return path.endsWith(".m3u8") || path.endsWith(".mpd");
}
-export function loomImportProcessingWaitMs(
- durationSeconds: number | null | undefined,
-) {
- const lifetime =
- durationSeconds && Number.isFinite(durationSeconds) && durationSeconds > 0
- ? Math.min(
- MEDIA_SERVER_MAX_LIFETIME_MS,
- Math.max(
- MEDIA_SERVER_MIN_LIFETIME_MS,
- MEDIA_SERVER_LIFETIME_BASE_MS + Math.ceil(durationSeconds * 1000),
- ),
- )
- : MEDIA_SERVER_MAX_LIFETIME_MS;
- return lifetime + PROCESSING_WAIT_MARGIN_MS;
-}
-
function isLoomUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(LOOM_UNAVAILABLE_ERROR);
@@ -252,16 +232,11 @@ export async function importLoomVideoWorkflow(
let metadata: ProcessedVideoMetadata;
let loomWaits = 0;
- let durationSeconds: number | null = null;
for (let processingAttempt = 0; ; processingAttempt++) {
let capacityRetryCount = 0;
while (true) {
try {
- const started = await processVideoOnMediaServer(
- payload,
- processingInput,
- );
- durationSeconds = started?.durationSeconds ?? durationSeconds;
+ await processVideoOnMediaServer(payload, processingInput);
break;
} catch (error) {
if (
@@ -280,10 +255,9 @@ export async function importLoomVideoWorkflow(
}
}
try {
- metadata = await waitForVideoProcessing(
- payload.videoId,
- loomImportProcessingWaitMs(durationSeconds),
- );
+ metadata = await waitForVideoProcessing(payload.videoId, {
+ maxPollMs: PROCESSING_POLL_MAX_MS,
+ });
break;
} catch (error) {
if (
@@ -457,7 +431,7 @@ async function startMediaServerProcessJob(
async function processVideoOnMediaServer(
payload: ImportLoomPayload,
processingInput: LoomProcessingInput,
-): Promise<{ durationSeconds: number | null }> {
+): Promise {
"use step";
const { videoId, userId, rawFileKey, loomVideoId } = payload;
@@ -479,7 +453,6 @@ async function processVideoOnMediaServer(
outputPresignedUrl,
thumbnailPresignedUrl,
previewGifPresignedUrl,
- durationSeconds,
} = await Effect.gen(function* () {
const [video] = yield* Effect.promise(() =>
db()
@@ -546,7 +519,6 @@ async function processVideoOnMediaServer(
outputPresignedUrl,
thumbnailPresignedUrl,
previewGifPresignedUrl,
- durationSeconds: getValidDuration(video.duration ?? 0) ?? null,
};
}).pipe(runWorkflowPromise);
@@ -578,7 +550,6 @@ async function processVideoOnMediaServer(
priority: "bulk",
});
await continueLoomImports(videoId);
- return { durationSeconds };
}
async function saveMetadataAndComplete(
diff --git a/apps/web/workflows/video-processing-status.ts b/apps/web/workflows/video-processing-status.ts
index 6abf15e55ef..b03cfeb7eee 100644
--- a/apps/web/workflows/video-processing-status.ts
+++ b/apps/web/workflows/video-processing-status.ts
@@ -13,7 +13,7 @@ export interface ProcessedVideoMetadata {
type ProcessingStatus =
| { status: "complete"; metadata: ProcessedVideoMetadata }
- | { status: "pending"; message: string }
+ | { status: "pending"; message: string; updatedAt: number | null }
| { status: "failed"; message: string }
| { status: "error"; message: string };
@@ -34,6 +34,7 @@ export async function readVideoProcessingStatus(
processingProgress: videoUploads.processingProgress,
processingMessage: videoUploads.processingMessage,
processingError: videoUploads.processingError,
+ updatedAt: videoUploads.updatedAt,
})
.from(videoUploads)
.where(eq(videoUploads.videoId, Video.VideoId.make(videoId)));
@@ -89,24 +90,37 @@ export async function readVideoProcessingStatus(
]
.filter(Boolean)
.join(" "),
+ updatedAt: upload.updatedAt ? upload.updatedAt.getTime() : null,
};
}
+export const VIDEO_PROCESSING_STALL_MS = 20 * 60 * 1000;
+
export async function waitForVideoProcessing(
videoId: string,
- maxWaitMs = 60 * 60 * 1000,
+ {
+ stallMs = VIDEO_PROCESSING_STALL_MS,
+ maxPollMs = 30_000,
+ }: { stallMs?: number; maxPollMs?: number } = {},
): Promise {
- const deadline = Date.now() + maxWaitMs;
- let lastStatus = "processing";
- for (let attempt = 0; Date.now() < deadline; attempt++) {
+ let lastSeen: string | null = null;
+ let lastChangeAt = Date.now();
+ for (let attempt = 0; ; attempt++) {
const result = await readVideoProcessingStatus(videoId);
if (result.status === "complete") return result.metadata;
if (result.status === "failed") {
throw new VideoProcessingFailedError(result.message);
}
if (result.status === "error") throw new FatalError(result.message);
- lastStatus = result.message;
- await sleep(Math.min(5_000 * (attempt + 1), 30_000));
+ const seen = `${result.message}|${result.updatedAt ?? ""}`;
+ if (seen !== lastSeen) {
+ lastSeen = seen;
+ lastChangeAt = Date.now();
+ } else if (Date.now() - lastChangeAt > stallMs) {
+ throw new VideoProcessingFailedError(
+ `Video processing stopped making progress while ${result.message}`,
+ );
+ }
+ await sleep(Math.min(5_000 * (attempt + 1), maxPollMs));
}
- throw new FatalError(`Video processing timed out while ${lastStatus}`);
}
From d6532e70da70ff7a63d4a47fc35901a23f97a689 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 04:56:58 +0000
Subject: [PATCH 32/39] improve: poll Loom import progress less often and not
in background tabs
Co-authored-by: Richie McIlroy
---
.../unit/loom-import-job-view.test.ts | 55 ++++++++++++++++++-
.../loom/[jobId]/use-loom-import-job.ts | 15 ++---
2 files changed, 60 insertions(+), 10 deletions(-)
diff --git a/apps/web/__tests__/unit/loom-import-job-view.test.ts b/apps/web/__tests__/unit/loom-import-job-view.test.ts
index 7fcebd268b8..64da5415795 100644
--- a/apps/web/__tests__/unit/loom-import-job-view.test.ts
+++ b/apps/web/__tests__/unit/loom-import-job-view.test.ts
@@ -177,7 +177,7 @@ describe("useLoomImportJob", () => {
});
await act(async () => {
- vi.advanceTimersByTime(2_000);
+ vi.advanceTimersByTime(4_000);
});
expect(requests[1]?.url).toBe(
"/api/import/loom/jobs?jobId=job-1&since=2000",
@@ -228,6 +228,59 @@ describe("useLoomImportJob", () => {
"failed",
]);
});
+
+ it("stops polling in a background tab and catches up when it comes back", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
+ let hidden = false;
+ Object.defineProperty(document, "hidden", {
+ configurable: true,
+ get: () => hidden,
+ });
+ const setHidden = async (value: boolean) => {
+ hidden = value;
+ await act(async () => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ };
+ const Harness = ({ initial }: { initial: LoomImportSnapshot }) => {
+ useLoomImportJob(initial, { watchForUpgrade: false });
+ return null;
+ };
+ try {
+ await act(async () => {
+ root.render(
+ React.createElement(Harness, {
+ initial: snapshot({
+ items: [item(0, { status: "importing" })],
+ full: true,
+ cursor: 1_000,
+ }),
+ }),
+ );
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(4_000);
+ });
+ expect(requests).toHaveLength(1);
+ requests[0]?.resolve(snapshot({ items: [], full: false, cursor: 1_000 }));
+ await flush();
+
+ await setHidden(true);
+ await act(async () => {
+ vi.advanceTimersByTime(30 * 60_000);
+ });
+ expect(requests).toHaveLength(1);
+
+ await setHidden(false);
+ await flush();
+ expect(requests.map((request) => request.url)).toEqual([
+ "/api/import/loom/jobs?jobId=job-1&since=1000",
+ "/api/import/loom/jobs?jobId=job-1",
+ ]);
+ } finally {
+ Reflect.deleteProperty(document, "hidden");
+ }
+ });
});
describe("VirtualImportList", () => {
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index 50b883471d0..eae5c7d86c2 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -12,9 +12,8 @@ import {
summarizeLoomImportItems,
} from "@/lib/loom-import/status";
-const ACTIVE_POLL_MS = 2000;
-const IDLE_POLL_MS = 10_000;
-const HIDDEN_POLL_MS = 15_000;
+const ACTIVE_POLL_MS = 4000;
+const IDLE_POLL_MS = 15_000;
const UPGRADE_POLL_MS = 2500;
const FULL_REFRESH_MS = 180_000;
const SNAPSHOT_TIMEOUT_MS = 20_000;
@@ -205,12 +204,9 @@ export function useLoomImportJob(
const schedule = () => {
window.clearTimeout(timer);
- if (stopped) return;
- const wait = document.hidden
- ? HIDDEN_POLL_MS
- : status === "awaiting_upgrade"
- ? UPGRADE_POLL_MS
- : pollDelayRef.current;
+ if (stopped || document.hidden) return;
+ const wait =
+ status === "awaiting_upgrade" ? UPGRADE_POLL_MS : pollDelayRef.current;
timer = window.setTimeout(tick, wait);
};
@@ -225,6 +221,7 @@ export function useLoomImportJob(
const onVisibility = () => {
if (document.hidden) {
hiddenAt = Date.now();
+ window.clearTimeout(timer);
return;
}
const full = hiddenAt !== null && Date.now() - hiddenAt > 60_000;
From 50a9d3914229b8f82920173c0ff6b3df0469b27d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 05:09:38 +0000
Subject: [PATCH 33/39] fix: keep regular upload links valid for as long as
processing makes progress
Co-authored-by: Richie McIlroy
---
apps/web/workflows/process-video.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/workflows/process-video.ts b/apps/web/workflows/process-video.ts
index 52cb4ca899a..1f17f79c53e 100644
--- a/apps/web/workflows/process-video.ts
+++ b/apps/web/workflows/process-video.ts
@@ -146,8 +146,8 @@ async function validateProcessingRequest(
const MEDIA_SERVER_START_MAX_ATTEMPTS = 2;
const MEDIA_SERVER_START_RETRY_BASE_MS = 250;
-const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 3 * 60 * 60;
-const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 3 * 60 * 60;
+const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 24 * 60 * 60;
+const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 24 * 60 * 60;
function getInputExtension(rawFileKey: string): string {
const parts = rawFileKey.split(".");
From 1bc9e91ec334b1c76417989438a00f532558df60 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 05:27:28 +0000
Subject: [PATCH 34/39] fix: count decoded frames as progress while checking
long WebM recordings
Co-authored-by: Richie McIlroy
---
...video-input-validation.integration.test.ts | 33 ++++++++++
.../src/lib/video-input-validation.ts | 66 ++++++++++++++++---
apps/media-server/src/routes/video.ts | 10 ++-
3 files changed, 100 insertions(+), 9 deletions(-)
diff --git a/apps/media-server/src/__tests__/lib/video-input-validation.integration.test.ts b/apps/media-server/src/__tests__/lib/video-input-validation.integration.test.ts
index be9c875a566..7256e901176 100644
--- a/apps/media-server/src/__tests__/lib/video-input-validation.integration.test.ts
+++ b/apps/media-server/src/__tests__/lib/video-input-validation.integration.test.ts
@@ -208,4 +208,37 @@ describe("raw recording input validation", () => {
"timed out",
);
});
+
+ test("reports decoding progress while it checks a clean recording", async () => {
+ let reports = 0;
+ await expect(
+ validateVideoInput(clean, undefined, undefined, {
+ idleTimeoutMs: 5_000,
+ onProgress: () => reports++,
+ }),
+ ).resolves.toBeUndefined();
+ expect(reports).toBeGreaterThan(0);
+ });
+
+ test("still rejects a damaged recording while tracking progress", async () => {
+ await expect(
+ validateVideoInput(corrupt, undefined, undefined, {
+ idleTimeoutMs: 5_000,
+ onProgress: () => {},
+ }),
+ ).rejects.toThrow("original upload has been preserved");
+ });
+
+ test("stops a check whose input stops producing frames", async () => {
+ const stalled = join(directory, "stalled.webm");
+ execFileSync("mkfifo", [stalled]);
+ let reports = 0;
+ await expect(
+ validateVideoInput(stalled, undefined, undefined, {
+ idleTimeoutMs: 1_000,
+ onProgress: () => reports++,
+ }),
+ ).rejects.toThrow("Stopped making progress");
+ expect(reports).toBe(0);
+ }, 30_000);
});
diff --git a/apps/media-server/src/lib/video-input-validation.ts b/apps/media-server/src/lib/video-input-validation.ts
index 41e26532384..87905b314f3 100644
--- a/apps/media-server/src/lib/video-input-validation.ts
+++ b/apps/media-server/src/lib/video-input-validation.ts
@@ -1,13 +1,23 @@
import { spawn } from "bun";
-import { PROCESS_TIMEOUT_MS, withTimeout } from "./media-common";
+import {
+ PROCESS_TIMEOUT_MS,
+ withIdleTimeout,
+ withTimeout,
+} from "./media-common";
import { registerSubprocess, terminateProcess } from "./subprocess";
const MAX_DIAGNOSTIC_LENGTH = 8_192;
+export interface ValidationProgress {
+ idleTimeoutMs: number;
+ onProgress: () => void;
+}
+
export async function validateVideoInput(
inputPath: string,
abortSignal?: AbortSignal,
timeoutMs = PROCESS_TIMEOUT_MS,
+ progress?: ValidationProgress,
): Promise {
abortSignal?.throwIfAborted();
const proc = registerSubprocess(
@@ -35,11 +45,12 @@ export async function validateVideoInput(
"demux",
"-abort_on",
"empty_output",
+ ...(progress ? ["-progress", "pipe:1"] : []),
"-f",
"null",
"-",
],
- stdout: "ignore",
+ stdout: progress ? "pipe" : "ignore",
stderr: "pipe",
}),
);
@@ -65,13 +76,52 @@ export async function validateVideoInput(
reader.releaseLock();
}
};
- const completion = Promise.all([proc.exited, readDiagnostics()]);
+ const readProgress = async (touch: () => void) => {
+ if (!progress) return;
+ const reader = (proc.stdout as ReadableStream).getReader();
+ const decoder = new TextDecoder();
+ let buffered = "";
+ let decodedUs = -1;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffered += decoder.decode(value, { stream: true });
+ const lines = buffered.split("\n");
+ buffered = lines.pop() ?? "";
+ for (const line of lines) {
+ const match = line.match(/^out_time_us=(\d+)/);
+ const outTimeUs = match ? Number.parseInt(match[1] ?? "0", 10) : -1;
+ if (outTimeUs <= decodedUs) continue;
+ decodedUs = outTimeUs;
+ touch();
+ progress.onProgress();
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ };
+ let completion: Promise = Promise.resolve();
+ const run = (touch: () => void) => {
+ const settled = Promise.all([
+ proc.exited,
+ readDiagnostics(),
+ readProgress(touch),
+ ]);
+ completion = settled;
+ return settled;
+ };
try {
- const [exitCode, diagnostics] = await withTimeout(
- completion,
- timeoutMs,
- () => terminateProcess(proc),
- );
+ const [exitCode, diagnostics] = progress
+ ? await withIdleTimeout(run, progress.idleTimeoutMs, () =>
+ terminateProcess(proc),
+ )
+ : await withTimeout(
+ run(() => {}),
+ timeoutMs,
+ () => terminateProcess(proc),
+ );
abortSignal?.throwIfAborted();
if (diagnostics) {
console.warn("[video/input-validation] Source decoding diagnostics", {
diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts
index 82caa6bc00d..fb5a623a351 100644
--- a/apps/media-server/src/routes/video.ts
+++ b/apps/media-server/src/routes/video.ts
@@ -1370,7 +1370,15 @@ async function processVideoAsync(
if (isWebm) {
updateJob(jobId, { message: "Checking the original recording..." });
await withJobHeartbeat(jobId, () =>
- validateVideoInput(inputTempFile.path, abortController.signal),
+ validateVideoInput(
+ inputTempFile.path,
+ abortController.signal,
+ undefined,
+ {
+ idleTimeoutMs: FFMPEG_IDLE_TIMEOUT_MS,
+ onProgress: () => markJobProgress(jobId),
+ },
+ ),
);
}
From 8dedf3adc43f847a8d75412441d7a11440cc8455 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 05:27:28 +0000
Subject: [PATCH 35/39] fix: ask the media server about a quiet job before
starting another copy
Co-authored-by: Richie McIlroy
---
.../unit/media-processing-workflows.test.ts | 43 +++++++++++
.../unit/video-processing-status.test.ts | 72 +++++++++++++++++++
apps/web/workflows/import-loom-video.ts | 9 ++-
apps/web/workflows/process-video.ts | 9 +--
apps/web/workflows/video-processing-status.ts | 39 ++++++++--
5 files changed, 160 insertions(+), 12 deletions(-)
diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts
index 06152ecaf88..a0b0142b919 100644
--- a/apps/web/__tests__/unit/media-processing-workflows.test.ts
+++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts
@@ -13,6 +13,11 @@ const mocks = vi.hoisted(() => ({
fetch: vi.fn(),
sleep: vi.fn(),
continueLoomImport: vi.fn(),
+ observe: vi.fn(),
+}));
+
+vi.mock("@/lib/desktop-recording-job-status", () => ({
+ observeDesktopRecordingJob: mocks.observe,
}));
vi.mock("@cap/database", () => ({
@@ -117,6 +122,9 @@ describe("media processing workflows", () => {
mocks.remove.mockImplementation(() => Effect.void);
mocks.sleep.mockResolvedValue(undefined);
mocks.continueLoomImport.mockReset().mockResolvedValue(null);
+ mocks.observe
+ .mockReset()
+ .mockResolvedValue({ status: "unavailable", delivered: false });
vi.stubGlobal("fetch", mocks.fetch);
mocks.fetch.mockReset().mockImplementation(async (url: string) => {
if (url.startsWith("https://www.loom.com/")) {
@@ -315,6 +323,9 @@ describe("media processing workflows", () => {
success: true,
});
expect(now).toBeGreaterThan(20 * 60 * 1000);
+ expect(mocks.observe).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ videoId: "video", jobId: "job-1" }),
+ );
expect(mocks.fetch.mock.calls.map(([url]) => url)).toEqual([
"https://www.loom.com/api/campaigns/sessions/loom-video/transcoded-url",
"https://worker.example.com/video/import",
@@ -329,6 +340,38 @@ describe("media processing workflows", () => {
}
});
+ it("does not start a second copy while the media server still has the Loom video", async () => {
+ let now = 0;
+ const clock = vi.spyOn(Date, "now").mockImplementation(() => now);
+ mocks.sleep.mockImplementation(async (delay: unknown) => {
+ if (typeof delay === "number") now += delay;
+ });
+ mocks.observe
+ .mockResolvedValueOnce({ status: "active", delivered: false })
+ .mockResolvedValueOnce({ status: "active", delivered: false })
+ .mockResolvedValueOnce({ status: "terminal", delivered: true });
+ const quiet = { ...pending, updatedAt: new Date(0) };
+ try {
+ mocks.rows = [
+ [video],
+ ...Array.from({ length: 25 }, () => [quiet]),
+ [],
+ [metadata],
+ ];
+ await expect(importLoomVideoWorkflow(payload)).resolves.toMatchObject({
+ success: true,
+ metadata,
+ });
+ expect(mocks.observe).toHaveBeenCalledTimes(3);
+ expect(mocks.fetch.mock.calls.map(([url]) => url)).toEqual([
+ "https://www.loom.com/api/campaigns/sessions/loom-video/transcoded-url",
+ "https://worker.example.com/video/import",
+ ]);
+ } finally {
+ clock.mockRestore();
+ }
+ });
+
it("supports Loom streaming fallbacks without reading a nonexistent raw object", async () => {
mocks.fetch.mockImplementation(async (url: string) =>
Response.json(
diff --git a/apps/web/__tests__/unit/video-processing-status.test.ts b/apps/web/__tests__/unit/video-processing-status.test.ts
index 6259fbfdf13..8c84141569f 100644
--- a/apps/web/__tests__/unit/video-processing-status.test.ts
+++ b/apps/web/__tests__/unit/video-processing-status.test.ts
@@ -4,9 +4,21 @@ const mocks = vi.hoisted(() => ({
rows: [] as unknown[][],
where: vi.fn(),
sleep: vi.fn(),
+ observe: vi.fn(),
now: 0,
}));
+vi.mock("@cap/env", () => ({
+ serverEnv: () => ({
+ MEDIA_SERVER_URL: "https://worker.example.com",
+ WEB_URL: "https://cap.example.com",
+ MEDIA_SERVER_WEBHOOK_SECRET: "test-secret",
+ }),
+}));
+vi.mock("@/lib/desktop-recording-job-status", () => ({
+ observeDesktopRecordingJob: mocks.observe,
+}));
+
vi.mock("@cap/database", () => ({
db: () => ({
select: () => ({ from: () => ({ where: mocks.where }) }),
@@ -48,6 +60,9 @@ describe("durable video processing completion", () => {
mocks.sleep.mockReset().mockImplementation(async (delay: number) => {
mocks.now += delay;
});
+ mocks.observe
+ .mockReset()
+ .mockResolvedValue({ status: "unavailable", delivered: false });
});
afterEach(() => vi.restoreAllMocks());
@@ -133,5 +148,62 @@ describe("durable video processing completion", () => {
expect(Math.max(...mocks.sleep.mock.calls.map(([delay]) => delay))).toBe(
30_000,
);
+ expect(mocks.observe).not.toHaveBeenCalled();
+ });
+
+ it("asks the media server before giving up, and gives up once it no longer has the job", async () => {
+ mocks.where.mockResolvedValue([{ ...pending, updatedAt: new Date(0) }]);
+ await expect(
+ waitForVideoProcessing("video", { jobId: "job-1" }),
+ ).rejects.toBeInstanceOf(VideoProcessingFailedError);
+ expect(mocks.observe).toHaveBeenCalledExactlyOnceWith({
+ videoId: "video",
+ jobId: "job-1",
+ mediaServerUrl: "https://worker.example.com",
+ webhookUrl:
+ "https://cap.example.com/api/webhooks/media-server/progress?retryable=true",
+ secret: "test-secret",
+ });
+ });
+
+ it("keeps waiting without a second copy while the media server still runs the job", async () => {
+ const stuck = { ...pending, updatedAt: new Date(0) };
+ let finishedAt = Number.POSITIVE_INFINITY;
+ mocks.observe.mockImplementation(async () => {
+ if (mocks.now > 90 * 60 * 1000) {
+ finishedAt = mocks.now;
+ return { status: "terminal", delivered: true };
+ }
+ return { status: "active", delivered: false };
+ });
+ mocks.rows = [[], [metadata]];
+ mocks.where.mockImplementation(async () =>
+ mocks.now <= finishedAt ? [stuck] : mocks.rows.shift(),
+ );
+ await expect(
+ waitForVideoProcessing("video", { jobId: "job-1" }),
+ ).resolves.toEqual(metadata);
+ expect(finishedAt).toBeGreaterThan(90 * 60 * 1000);
+ const checks = mocks.observe.mock.calls.length;
+ expect(checks).toBeGreaterThan(100);
+ expect(checks).toBeLessThan(mocks.where.mock.calls.length);
+ });
+
+ it("goes back to quiet waiting once updates arrive again", async () => {
+ let updatedAt = 0;
+ mocks.observe.mockImplementation(async () => {
+ updatedAt = mocks.now;
+ return { status: "active", delivered: false };
+ });
+ mocks.rows = [[], [metadata]];
+ mocks.where.mockImplementation(async () =>
+ mocks.now < 3 * 60 * 60 * 1000
+ ? [{ ...pending, updatedAt: new Date(updatedAt) }]
+ : mocks.rows.shift(),
+ );
+ await expect(
+ waitForVideoProcessing("video", { jobId: "job-1" }),
+ ).resolves.toEqual(metadata);
+ expect(mocks.observe.mock.calls.length).toBeLessThanOrEqual(9);
});
});
diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts
index 1083530a50a..f947740f7c1 100644
--- a/apps/web/workflows/import-loom-video.ts
+++ b/apps/web/workflows/import-loom-video.ts
@@ -234,9 +234,10 @@ export async function importLoomVideoWorkflow(
let loomWaits = 0;
for (let processingAttempt = 0; ; processingAttempt++) {
let capacityRetryCount = 0;
+ let jobId: string;
while (true) {
try {
- await processVideoOnMediaServer(payload, processingInput);
+ jobId = await processVideoOnMediaServer(payload, processingInput);
break;
} catch (error) {
if (
@@ -256,6 +257,7 @@ export async function importLoomVideoWorkflow(
}
try {
metadata = await waitForVideoProcessing(payload.videoId, {
+ jobId,
maxPollMs: PROCESSING_POLL_MAX_MS,
});
break;
@@ -431,7 +433,7 @@ async function startMediaServerProcessJob(
async function processVideoOnMediaServer(
payload: ImportLoomPayload,
processingInput: LoomProcessingInput,
-): Promise {
+): Promise {
"use step";
const { videoId, userId, rawFileKey, loomVideoId } = payload;
@@ -536,7 +538,7 @@ async function processVideoOnMediaServer(
})
.where(eq(videoUploads.videoId, videoId as Video.VideoId));
- await startMediaServerProcessJob(mediaServerUrl, {
+ const jobId = await startMediaServerProcessJob(mediaServerUrl, {
videoId,
userId,
videoUrl: rawVideoUrl,
@@ -550,6 +552,7 @@ async function processVideoOnMediaServer(
priority: "bulk",
});
await continueLoomImports(videoId);
+ return jobId;
}
async function saveMetadataAndComplete(
diff --git a/apps/web/workflows/process-video.ts b/apps/web/workflows/process-video.ts
index 1f17f79c53e..078d39219cd 100644
--- a/apps/web/workflows/process-video.ts
+++ b/apps/web/workflows/process-video.ts
@@ -54,9 +54,10 @@ export async function processVideoWorkflow(
let metadata: ProcessedVideoMetadata;
for (let processingAttempt = 0; ; processingAttempt++) {
let capacityRetryCount = 0;
+ let jobId: string;
while (true) {
try {
- await processVideoOnMediaServer(
+ jobId = await processVideoOnMediaServer(
videoId,
userId,
rawFileKey,
@@ -71,7 +72,7 @@ export async function processVideoWorkflow(
}
}
try {
- metadata = await waitForVideoProcessing(videoId);
+ metadata = await waitForVideoProcessing(videoId, { jobId });
break;
} catch (error) {
if (
@@ -260,7 +261,7 @@ async function processVideoOnMediaServer(
userId: string,
rawFileKey: string,
_bucketId: string | null,
-): Promise {
+): Promise {
"use step";
const mediaServerUrl = serverEnv().MEDIA_SERVER_URL;
@@ -339,7 +340,7 @@ async function processVideoOnMediaServer(
})
.where(eq(videoUploads.videoId, videoId as Video.VideoId));
- await startMediaServerProcessJob(mediaServerUrl, {
+ return await startMediaServerProcessJob(mediaServerUrl, {
audioLevels: video.source.type === "webMP4",
videoId,
userId,
diff --git a/apps/web/workflows/video-processing-status.ts b/apps/web/workflows/video-processing-status.ts
index b03cfeb7eee..d335e71e33a 100644
--- a/apps/web/workflows/video-processing-status.ts
+++ b/apps/web/workflows/video-processing-status.ts
@@ -1,8 +1,10 @@
import { db } from "@cap/database";
import { videos, videoUploads } from "@cap/database/schema";
+import { serverEnv } from "@cap/env";
import { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { FatalError, sleep } from "workflow";
+import { observeDesktopRecordingJob } from "@/lib/desktop-recording-job-status";
export interface ProcessedVideoMetadata {
duration: number;
@@ -94,17 +96,38 @@ export async function readVideoProcessingStatus(
};
}
+export async function checkMediaServerJob(
+ videoId: string,
+ jobId: string,
+): Promise<"active" | "settled" | "missing"> {
+ "use step";
+
+ const env = serverEnv();
+ if (!env.MEDIA_SERVER_URL) return "missing";
+ const observation = await observeDesktopRecordingJob({
+ videoId,
+ jobId,
+ mediaServerUrl: env.MEDIA_SERVER_URL,
+ webhookUrl: `${env.MEDIA_SERVER_WEBHOOK_URL || env.WEB_URL}/api/webhooks/media-server/progress?retryable=true`,
+ secret: env.MEDIA_SERVER_WEBHOOK_SECRET,
+ });
+ if (observation.status === "active") return "active";
+ return observation.delivered ? "settled" : "missing";
+}
+
export const VIDEO_PROCESSING_STALL_MS = 20 * 60 * 1000;
export async function waitForVideoProcessing(
videoId: string,
{
+ jobId,
stallMs = VIDEO_PROCESSING_STALL_MS,
maxPollMs = 30_000,
- }: { stallMs?: number; maxPollMs?: number } = {},
+ }: { jobId?: string; stallMs?: number; maxPollMs?: number } = {},
): Promise {
let lastSeen: string | null = null;
let lastChangeAt = Date.now();
+ let updatesMissing = false;
for (let attempt = 0; ; attempt++) {
const result = await readVideoProcessingStatus(videoId);
if (result.status === "complete") return result.metadata;
@@ -116,10 +139,16 @@ export async function waitForVideoProcessing(
if (seen !== lastSeen) {
lastSeen = seen;
lastChangeAt = Date.now();
- } else if (Date.now() - lastChangeAt > stallMs) {
- throw new VideoProcessingFailedError(
- `Video processing stopped making progress while ${result.message}`,
- );
+ updatesMissing = false;
+ } else if (updatesMissing || Date.now() - lastChangeAt > stallMs) {
+ const job = jobId ? await checkMediaServerJob(videoId, jobId) : "missing";
+ if (job === "missing") {
+ throw new VideoProcessingFailedError(
+ `Video processing stopped making progress while ${result.message}`,
+ );
+ }
+ updatesMissing = job === "active";
+ lastChangeAt = Date.now();
}
await sleep(Math.min(5_000 * (attempt + 1), maxPollMs));
}
From 8fa4b21edab42ffa3523204c02858fe688aebeac Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 06:29:20 +0000
Subject: [PATCH 36/39] fix: restart a quiet video only after its job stays
unseen for ten minutes
Co-authored-by: Richie McIlroy
---
.../unit/media-processing-workflows.test.ts | 11 ++--
.../unit/video-processing-status.test.ts | 62 ++++++++++++++----
apps/web/workflows/video-processing-status.ts | 65 +++++++++++++------
3 files changed, 102 insertions(+), 36 deletions(-)
diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts
index a0b0142b919..cb8076bf87c 100644
--- a/apps/web/__tests__/unit/media-processing-workflows.test.ts
+++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts
@@ -313,7 +313,7 @@ describe("media processing workflows", () => {
try {
mocks.rows = [
[video],
- ...Array.from({ length: 23 }, () => [quiet]),
+ ...Array.from({ length: 43 }, () => [quiet]),
[video],
[video],
[],
@@ -322,10 +322,11 @@ describe("media processing workflows", () => {
await expect(importLoomVideoWorkflow(payload)).resolves.toMatchObject({
success: true,
});
- expect(now).toBeGreaterThan(20 * 60 * 1000);
- expect(mocks.observe).toHaveBeenCalledExactlyOnceWith(
- expect.objectContaining({ videoId: "video", jobId: "job-1" }),
- );
+ expect(now).toBeGreaterThan(30 * 60 * 1000);
+ expect(mocks.observe).toHaveBeenCalledTimes(21);
+ for (const [lookup] of mocks.observe.mock.calls) {
+ expect(lookup).toMatchObject({ videoId: "video", jobId: "job-1" });
+ }
expect(mocks.fetch.mock.calls.map(([url]) => url)).toEqual([
"https://www.loom.com/api/campaigns/sessions/loom-video/transcoded-url",
"https://worker.example.com/video/import",
diff --git a/apps/web/__tests__/unit/video-processing-status.test.ts b/apps/web/__tests__/unit/video-processing-status.test.ts
index 8c84141569f..f7fe58103f1 100644
--- a/apps/web/__tests__/unit/video-processing-status.test.ts
+++ b/apps/web/__tests__/unit/video-processing-status.test.ts
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
rows: [] as unknown[][],
where: vi.fn(),
+ write: vi.fn(),
sleep: vi.fn(),
observe: vi.fn(),
now: 0,
@@ -22,6 +23,7 @@ vi.mock("@/lib/desktop-recording-job-status", () => ({
vi.mock("@cap/database", () => ({
db: () => ({
select: () => ({ from: () => ({ where: mocks.where }) }),
+ update: () => ({ set: () => ({ where: mocks.write }) }),
}),
}));
vi.mock("@cap/database/schema", () => ({
@@ -38,6 +40,7 @@ vi.mock("workflow", () => ({
import {
readVideoProcessingStatus,
+ UNSEEN_JOB_GRACE_MS,
VIDEO_PROCESSING_STALL_MS,
VideoProcessingFailedError,
waitForVideoProcessing,
@@ -63,6 +66,7 @@ describe("durable video processing completion", () => {
mocks.observe
.mockReset()
.mockResolvedValue({ status: "unavailable", delivered: false });
+ mocks.write.mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.restoreAllMocks());
@@ -151,19 +155,46 @@ describe("durable video processing completion", () => {
expect(mocks.observe).not.toHaveBeenCalled();
});
- it("asks the media server before giving up, and gives up once it no longer has the job", async () => {
+ it("restarts only after the media server has not seen the job for ten minutes of checks", async () => {
mocks.where.mockResolvedValue([{ ...pending, updatedAt: new Date(0) }]);
await expect(
waitForVideoProcessing("video", { jobId: "job-1" }),
).rejects.toBeInstanceOf(VideoProcessingFailedError);
- expect(mocks.observe).toHaveBeenCalledExactlyOnceWith({
- videoId: "video",
- jobId: "job-1",
- mediaServerUrl: "https://worker.example.com",
- webhookUrl:
- "https://cap.example.com/api/webhooks/media-server/progress?retryable=true",
- secret: "test-secret",
+ expect(mocks.now).toBeGreaterThanOrEqual(
+ VIDEO_PROCESSING_STALL_MS + UNSEEN_JOB_GRACE_MS,
+ );
+ expect(mocks.observe.mock.calls.length).toBeGreaterThanOrEqual(20);
+ for (const [lookup] of mocks.observe.mock.calls) {
+ expect(lookup).toEqual({
+ videoId: "video",
+ jobId: "job-1",
+ mediaServerUrl: "https://worker.example.com",
+ webhookUrl:
+ "https://cap.example.com/api/webhooks/media-server/progress?retryable=true",
+ secret: "test-secret",
+ });
+ }
+ expect(mocks.write).not.toHaveBeenCalled();
+ });
+
+ it("does not start another copy when status checks fail for a few minutes", async () => {
+ const stuck = { ...pending, updatedAt: new Date(0) };
+ let checks = 0;
+ mocks.observe.mockImplementation(async () => {
+ checks++;
+ if (checks <= 8) return { status: "unavailable", delivered: false };
+ if (checks <= 12) return { status: "active", delivered: false };
+ return { status: "terminal", delivered: true };
});
+ mocks.rows = [[], [metadata]];
+ mocks.where.mockImplementation(async () =>
+ checks < 13 ? [stuck] : mocks.rows.shift(),
+ );
+ await expect(
+ waitForVideoProcessing("video", { jobId: "job-1" }),
+ ).resolves.toEqual(metadata);
+ expect(checks).toBe(13);
+ expect(mocks.write).toHaveBeenCalledTimes(4);
});
it("keeps waiting without a second copy while the media server still runs the job", async () => {
@@ -187,18 +218,25 @@ describe("durable video processing completion", () => {
const checks = mocks.observe.mock.calls.length;
expect(checks).toBeGreaterThan(100);
expect(checks).toBeLessThan(mocks.where.mock.calls.length);
+ expect(mocks.write).toHaveBeenCalledTimes(checks - 1);
});
- it("goes back to quiet waiting once updates arrive again", async () => {
- let updatedAt = 0;
+ it("goes back to quiet waiting once progress arrives again", async () => {
+ let progress = 25;
mocks.observe.mockImplementation(async () => {
- updatedAt = mocks.now;
+ progress++;
return { status: "active", delivered: false };
});
mocks.rows = [[], [metadata]];
mocks.where.mockImplementation(async () =>
mocks.now < 3 * 60 * 60 * 1000
- ? [{ ...pending, updatedAt: new Date(updatedAt) }]
+ ? [
+ {
+ ...pending,
+ processingProgress: progress,
+ updatedAt: new Date(0),
+ },
+ ]
: mocks.rows.shift(),
);
await expect(
diff --git a/apps/web/workflows/video-processing-status.ts b/apps/web/workflows/video-processing-status.ts
index d335e71e33a..528a6812cae 100644
--- a/apps/web/workflows/video-processing-status.ts
+++ b/apps/web/workflows/video-processing-status.ts
@@ -99,11 +99,11 @@ export async function readVideoProcessingStatus(
export async function checkMediaServerJob(
videoId: string,
jobId: string,
-): Promise<"active" | "settled" | "missing"> {
+): Promise<"active" | "settled" | "unseen"> {
"use step";
const env = serverEnv();
- if (!env.MEDIA_SERVER_URL) return "missing";
+ if (!env.MEDIA_SERVER_URL) return "unseen";
const observation = await observeDesktopRecordingJob({
videoId,
jobId,
@@ -111,11 +111,19 @@ export async function checkMediaServerJob(
webhookUrl: `${env.MEDIA_SERVER_WEBHOOK_URL || env.WEB_URL}/api/webhooks/media-server/progress?retryable=true`,
secret: env.MEDIA_SERVER_WEBHOOK_SECRET,
});
- if (observation.status === "active") return "active";
- return observation.delivered ? "settled" : "missing";
+ if (observation.status === "active") {
+ await db()
+ .update(videoUploads)
+ .set({ updatedAt: new Date() })
+ .where(eq(videoUploads.videoId, Video.VideoId.make(videoId)));
+ return "active";
+ }
+ return observation.delivered ? "settled" : "unseen";
}
export const VIDEO_PROCESSING_STALL_MS = 20 * 60 * 1000;
+export const UNSEEN_JOB_GRACE_MS = 10 * 60 * 1000;
+const UNSEEN_JOB_POLL_MS = 30_000;
export async function waitForVideoProcessing(
videoId: string,
@@ -125,9 +133,11 @@ export async function waitForVideoProcessing(
maxPollMs = 30_000,
}: { jobId?: string; stallMs?: number; maxPollMs?: number } = {},
): Promise {
- let lastSeen: string | null = null;
+ let lastMessage: string | null = null;
+ let lastUpdatedAt: number | null = null;
let lastChangeAt = Date.now();
- let updatesMissing = false;
+ let jobActive = false;
+ let unseenSince: number | null = null;
for (let attempt = 0; ; attempt++) {
const result = await readVideoProcessingStatus(videoId);
if (result.status === "complete") return result.metadata;
@@ -135,21 +145,38 @@ export async function waitForVideoProcessing(
throw new VideoProcessingFailedError(result.message);
}
if (result.status === "error") throw new FatalError(result.message);
- const seen = `${result.message}|${result.updatedAt ?? ""}`;
- if (seen !== lastSeen) {
- lastSeen = seen;
+ const changed =
+ result.message !== lastMessage ||
+ (!jobActive && result.updatedAt !== lastUpdatedAt);
+ lastMessage = result.message;
+ lastUpdatedAt = result.updatedAt;
+ if (changed) {
lastChangeAt = Date.now();
- updatesMissing = false;
- } else if (updatesMissing || Date.now() - lastChangeAt > stallMs) {
- const job = jobId ? await checkMediaServerJob(videoId, jobId) : "missing";
- if (job === "missing") {
- throw new VideoProcessingFailedError(
- `Video processing stopped making progress while ${result.message}`,
- );
+ jobActive = false;
+ unseenSince = null;
+ } else if (
+ jobActive ||
+ unseenSince !== null ||
+ Date.now() - lastChangeAt > stallMs
+ ) {
+ const job = jobId ? await checkMediaServerJob(videoId, jobId) : "unseen";
+ jobActive = job === "active";
+ if (job === "unseen") {
+ unseenSince ??= Date.now();
+ if (!jobId || Date.now() - unseenSince >= UNSEEN_JOB_GRACE_MS) {
+ throw new VideoProcessingFailedError(
+ `Video processing stopped making progress while ${result.message}`,
+ );
+ }
+ } else {
+ unseenSince = null;
+ lastChangeAt = Date.now();
}
- updatesMissing = job === "active";
- lastChangeAt = Date.now();
}
- await sleep(Math.min(5_000 * (attempt + 1), maxPollMs));
+ await sleep(
+ unseenSince === null
+ ? Math.min(5_000 * (attempt + 1), maxPollMs)
+ : Math.min(UNSEEN_JOB_POLL_MS, maxPollMs),
+ );
}
}
From baedf7bed995c57ac1af84f9c60cf887f22ffad7 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 07:33:52 +0000
Subject: [PATCH 37/39] fix: keep regular upload progress live while throttling
bulk import updates
Co-authored-by: Richie McIlroy
---
.../routes/video-progress-webhooks.test.ts | 98 +++++++++++++++++++
apps/media-server/src/routes/video.ts | 9 +-
2 files changed, 105 insertions(+), 2 deletions(-)
create mode 100644 apps/media-server/src/__tests__/routes/video-progress-webhooks.test.ts
diff --git a/apps/media-server/src/__tests__/routes/video-progress-webhooks.test.ts b/apps/media-server/src/__tests__/routes/video-progress-webhooks.test.ts
new file mode 100644
index 00000000000..1354323ee73
--- /dev/null
+++ b/apps/media-server/src/__tests__/routes/video-progress-webhooks.test.ts
@@ -0,0 +1,98 @@
+import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test";
+import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import app from "../../app";
+import * as jobManager from "../../lib/job-manager";
+import { deleteJob, getJob } from "../../lib/job-manager";
+import * as mediaVideo from "../../lib/media-video";
+
+const fixture = join(import.meta.dir, "..", "fixtures", "test-with-audio.mp4");
+const directory = mkdtempSync(join(tmpdir(), "cap-progress-webhooks-"));
+const received: { videoId: string; phase: string; message?: string }[] = [];
+const receiver = Bun.serve({
+ port: 0,
+ async fetch(request) {
+ received.push(await request.json());
+ return Response.json({ success: true });
+ },
+});
+
+afterAll(() => {
+ receiver.stop(true);
+ rmSync(directory, { recursive: true, force: true });
+});
+
+const spies: { mockRestore: () => void }[] = [];
+afterEach(() => {
+ for (const spy of spies.splice(0)) spy.mockRestore();
+});
+
+async function encodeWithRapidProgress(priority: "normal" | "bulk") {
+ const videoId = `progress-${priority}`;
+ const input = join(directory, `${priority}-input.mp4`);
+ const output = join(directory, `${priority}-output.mp4`);
+ copyFileSync(fixture, input);
+ copyFileSync(fixture, output);
+ spies.push(
+ spyOn(jobManager, "canAcceptNewVideoProcess").mockReturnValue(true),
+ spyOn(mediaVideo, "downloadVideoToTemp").mockResolvedValue({
+ path: input,
+ cleanup: async () => {},
+ }),
+ spyOn(mediaVideo, "processVideo").mockImplementation(
+ async (_input, _metadata, _options, onProgress) => {
+ for (let step = 1; step <= 40; step++) {
+ onProgress?.(step * 2.5, `Encoding: ${step * 2.5}%`);
+ await Bun.sleep(60);
+ }
+ return { path: output, cleanup: async () => {} };
+ },
+ ),
+ spyOn(mediaVideo, "uploadFileToS3").mockResolvedValue({}),
+ );
+ const secret = process.env.MEDIA_SERVER_WEBHOOK_SECRET ?? "test-secret";
+ process.env.MEDIA_SERVER_WEBHOOK_SECRET = secret;
+ const response = await app.fetch(
+ new Request("http://localhost/video/process", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-media-server-secret": secret,
+ },
+ body: JSON.stringify({
+ videoId,
+ userId: "progress-test",
+ videoUrl: "https://example.com/raw.mp4",
+ outputPresignedUrl: "https://example.com/result.mp4",
+ webhookUrl: `http://127.0.0.1:${receiver.port}/progress`,
+ webhookSecret: secret,
+ priority,
+ }),
+ }),
+ );
+ expect(response.status).toBe(200);
+ const { jobId } = (await response.json()) as { jobId: string };
+ const deadline = Date.now() + 15_000;
+ while (Date.now() < deadline && getJob(jobId)?.phase !== "complete") {
+ await Bun.sleep(20);
+ }
+ expect(getJob(jobId)?.phase).toBe("complete");
+ deleteJob(jobId);
+ return received.filter(
+ (update) =>
+ update.videoId === videoId && update.message?.startsWith("Encoding"),
+ ).length;
+}
+
+describe("encoding progress webhooks", () => {
+ test("keeps regular uploads updating every second for the share page", async () => {
+ const sent = await encodeWithRapidProgress("normal");
+ expect(sent).toBeGreaterThanOrEqual(2);
+ expect(sent).toBeLessThanOrEqual(4);
+ }, 30_000);
+
+ test("sends bulk imports one update per five seconds", async () => {
+ expect(await encodeWithRapidProgress("bulk")).toBe(1);
+ }, 30_000);
+});
diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts
index fb5a623a351..3080f8dbc2c 100644
--- a/apps/media-server/src/routes/video.ts
+++ b/apps/media-server/src/routes/video.ts
@@ -84,7 +84,8 @@ import { validateVideoInput } from "../lib/video-input-validation";
const video = new Hono();
const PROCESSING_HEARTBEAT_MS = 60 * 1000;
const LIVENESS_WEBHOOK_TICKS = 5;
-const PROGRESS_WEBHOOK_INTERVAL_MS = 15 * 1000;
+const PROGRESS_WEBHOOK_INTERVAL_MS = 1000;
+const BULK_PROGRESS_WEBHOOK_INTERVAL_MS = 5 * 1000;
const FFMPEG_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const RECORDING_WORKER_INSTANCE = randomUUID();
const SEGMENTED_RECORDING_TIMEOUT_MS = 3 * PROCESS_TIMEOUT_MS + 35 * 60 * 1000;
@@ -964,12 +965,16 @@ async function processWithResilientRetry(
idleTimeoutMs: FFMPEG_IDLE_TIMEOUT_MS,
};
+ const progressWebhookIntervalMs =
+ options.priority === "bulk"
+ ? BULK_PROGRESS_WEBHOOK_INTERVAL_MS
+ : PROGRESS_WEBHOOK_INTERVAL_MS;
let progressWebhookAt = 0;
const onProgress = (progress: number, message: string) => {
const scaledProgress = 10 + progress * 0.7;
updateJob(jobId, { progress: scaledProgress, message });
const now = Date.now();
- if (now - progressWebhookAt < PROGRESS_WEBHOOK_INTERVAL_MS) return;
+ if (now - progressWebhookAt < progressWebhookIntervalMs) return;
progressWebhookAt = now;
const currentJob = getJob(jobId);
if (currentJob) {
From cccb38765c33feb82fa332fde3411931fae98b3f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 07:33:52 +0000
Subject: [PATCH 38/39] fix: keep the import page refreshing as often as before
while it is visible
Co-authored-by: Richie McIlroy
---
apps/web/__tests__/unit/loom-import-job-view.test.ts | 4 ++--
.../dashboard/import/loom/[jobId]/use-loom-import-job.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/apps/web/__tests__/unit/loom-import-job-view.test.ts b/apps/web/__tests__/unit/loom-import-job-view.test.ts
index 64da5415795..b3262bba60e 100644
--- a/apps/web/__tests__/unit/loom-import-job-view.test.ts
+++ b/apps/web/__tests__/unit/loom-import-job-view.test.ts
@@ -177,7 +177,7 @@ describe("useLoomImportJob", () => {
});
await act(async () => {
- vi.advanceTimersByTime(4_000);
+ vi.advanceTimersByTime(2_000);
});
expect(requests[1]?.url).toBe(
"/api/import/loom/jobs?jobId=job-1&since=2000",
@@ -259,7 +259,7 @@ describe("useLoomImportJob", () => {
);
});
await act(async () => {
- vi.advanceTimersByTime(4_000);
+ vi.advanceTimersByTime(2_000);
});
expect(requests).toHaveLength(1);
requests[0]?.resolve(snapshot({ items: [], full: false, cursor: 1_000 }));
diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
index eae5c7d86c2..fc31731dbd8 100644
--- a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
+++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts
@@ -12,8 +12,8 @@ import {
summarizeLoomImportItems,
} from "@/lib/loom-import/status";
-const ACTIVE_POLL_MS = 4000;
-const IDLE_POLL_MS = 15_000;
+const ACTIVE_POLL_MS = 2000;
+const IDLE_POLL_MS = 10_000;
const UPGRADE_POLL_MS = 2500;
const FULL_REFRESH_MS = 180_000;
const SNAPSHOT_TIMEOUT_MS = 20_000;
From a986bf224136442031154d492f4d354671dbaec5 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 8 Oct 2026 08:27:49 +0000
Subject: [PATCH 39/39] chore: rebuild the preview deployment
Co-authored-by: Richie McIlroy