diff --git a/drizzle-postgres/0004_charming_doomsday.sql b/drizzle-postgres/0004_charming_doomsday.sql new file mode 100644 index 000000000..dfc9e4681 --- /dev/null +++ b/drizzle-postgres/0004_charming_doomsday.sql @@ -0,0 +1,2 @@ +ALTER TABLE "workspace_items" DROP CONSTRAINT "workspace_items_type_check";--> statement-breakpoint +ALTER TABLE "workspace_items" ADD CONSTRAINT "workspace_items_type_check" CHECK ("workspace_items"."type" in ('folder', 'document', 'flashcard', 'quiz', 'file')); \ No newline at end of file diff --git a/drizzle-postgres/0005_dashing_masked_marvel.sql b/drizzle-postgres/0005_dashing_masked_marvel.sql new file mode 100644 index 000000000..8abf0f064 --- /dev/null +++ b/drizzle-postgres/0005_dashing_masked_marvel.sql @@ -0,0 +1,7 @@ +ALTER TABLE "workspace_items" ADD COLUMN "ref_key" text DEFAULT substr(translate(encode(decode(md5(gen_random_uuid()::text), 'hex'), 'base64'), '+/', 'Aa'), 1, 8) NOT NULL;--> statement-breakpoint +UPDATE "workspace_items" w SET "ref_key" = substr(translate(encode(decode(md5(gen_random_uuid()::text || w.id), 'hex'), 'base64'), '+/', 'Aa'), 1, 8) +WHERE EXISTS ( + SELECT 1 FROM "workspace_items" d + WHERE d."workspace_id" = w."workspace_id" AND d."ref_key" = w."ref_key" AND d."id" <> w."id" +);--> statement-breakpoint +CREATE UNIQUE INDEX "workspace_items_ref_key_unique" ON "workspace_items" USING btree ("workspace_id","ref_key"); \ No newline at end of file diff --git a/drizzle-postgres/meta/0004_snapshot.json b/drizzle-postgres/meta/0004_snapshot.json new file mode 100644 index 000000000..3b9dbe680 --- /dev/null +++ b/drizzle-postgres/meta/0004_snapshot.json @@ -0,0 +1,2478 @@ +{ + "id": "cff9bd5b-3d3d-4cc3-86d9-7236e8cef04d", + "prevId": "25aa9ff5-8b2a-4423-8d78-053fd64107c5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_id_idx": { + "name": "oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consent_user_id_idx": { + "name": "oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_assets": { + "name": "workspace_file_assets", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_object_key": { + "name": "source_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "preview_object_key": { + "name": "preview_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_size_bytes": { + "name": "preview_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_assets_item_id_workspace_items_id_fk": { + "name": "workspace_file_assets_item_id_workspace_items_id_fk", + "tableFrom": "workspace_file_assets", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_assets_source_object_key_unique": { + "name": "workspace_file_assets_source_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "source_object_key" + ] + }, + "workspace_file_assets_preview_object_key_unique": { + "name": "workspace_file_assets_preview_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "preview_object_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_pending_link_per_role": { + "name": "workspace_invites_pending_link_per_role", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'link' and \"workspace_invites\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_pending_email_per_workspace": { + "name": "workspace_invites_pending_email_per_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'email' and \"workspace_invites\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_created_by_user_id_idx": { + "name": "workspace_invites_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_created_by_user_id_user_id_fk": { + "name": "workspace_invites_created_by_user_id_user_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_invites_role_check": { + "name": "workspace_invites_role_check", + "value": "\"workspace_invites\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + }, + "workspace_invites_type_check": { + "name": "workspace_invites_type_check", + "value": "\"workspace_invites\".\"type\" in ('email', 'link')" + }, + "workspace_invites_status_check": { + "name": "workspace_invites_status_check", + "value": "\"workspace_invites\".\"status\" in ('pending', 'accepted', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_contents": { + "name": "workspace_item_contents", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_contents_item_id_workspace_items_id_fk": { + "name": "workspace_item_contents_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_contents", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_extractions": { + "name": "workspace_item_extractions", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_mode": { + "name": "provider_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_extractions_status_idx": { + "name": "workspace_item_extractions_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_extractions_item_fk": { + "name": "workspace_item_extractions_item_fk", + "tableFrom": "workspace_item_extractions", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_extractions_status_check": { + "name": "workspace_item_extractions_status_check", + "value": "\"workspace_item_extractions\".\"status\" in ('processing', 'ready', 'failed')" + }, + "workspace_item_extractions_tier_check": { + "name": "workspace_item_extractions_tier_check", + "value": "\"workspace_item_extractions\".\"tier\" is null or \"workspace_item_extractions\".\"tier\" in ('fast', 'enhanced')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_pages": { + "name": "workspace_item_pages", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "markdown_bytes": { + "name": "markdown_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_pages_item_id_workspace_items_id_fk": { + "name": "workspace_item_pages_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_pages", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_pages_item_id_page_number_pk": { + "name": "workspace_item_pages_item_id_page_number_pk", + "columns": [ + "item_id", + "page_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_pages_number_check": { + "name": "workspace_item_pages_number_check", + "value": "\"workspace_item_pages\".\"page_number\" > 0" + }, + "workspace_item_pages_bytes_check": { + "name": "workspace_item_pages_bytes_check", + "value": "\"workspace_item_pages\".\"markdown_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_relations": { + "name": "workspace_item_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_item_id": { + "name": "from_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_item_id": { + "name": "to_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_relations_unique": { + "name": "workspace_item_relations_unique", + "columns": [ + { + "expression": "from_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "note", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_item_relations_from_idx": { + "name": "workspace_item_relations_from_idx", + "columns": [ + { + "expression": "from_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_item_relations_to_idx": { + "name": "workspace_item_relations_to_idx", + "columns": [ + { + "expression": "to_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_relations_from_fk": { + "name": "workspace_item_relations_from_fk", + "tableFrom": "workspace_item_relations", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "from_item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_item_relations_to_fk": { + "name": "workspace_item_relations_to_fk", + "tableFrom": "workspace_item_relations", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "to_item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_relations_kind_check": { + "name": "workspace_item_relations_kind_check", + "value": "\"workspace_item_relations\".\"kind\" in ('derived_from', 'references')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_user_states": { + "name": "workspace_item_user_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_user_states_item_id_idx": { + "name": "workspace_item_user_states_item_id_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_user_states_user_id_user_id_fk": { + "name": "workspace_item_user_states_user_id_user_id_fk", + "tableFrom": "workspace_item_user_states", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_item_user_states_item_id_workspace_items_id_fk": { + "name": "workspace_item_user_states_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_user_states", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_user_states_user_id_item_id_pk": { + "name": "workspace_item_user_states_user_id_item_id_pk", + "columns": [ + "user_id", + "item_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_items": { + "name": "workspace_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_key": { + "name": "name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_items_tree_idx": { + "name": "workspace_items_tree_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_type_idx": { + "name": "workspace_items_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_root_name_unique": { + "name": "workspace_items_root_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_items\".\"parent_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_parent_name_unique": { + "name": "workspace_items_parent_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_items\".\"parent_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_items_workspace_id_workspaces_id_fk": { + "name": "workspace_items_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_items_parent_fk": { + "name": "workspace_items_parent_fk", + "tableFrom": "workspace_items", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "parent_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_items_workspace_id_id_unique": { + "name": "workspace_items_workspace_id_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_items_type_check": { + "name": "workspace_items_type_check", + "value": "\"workspace_items\".\"type\" in ('folder', 'document', 'flashcard', 'quiz', 'file')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_user_unique": { + "name": "workspace_members_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_last_opened_at_idx": { + "name": "workspace_members_user_last_opened_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_members_role_check": { + "name": "workspace_members_role_check", + "value": "\"workspace_members\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspaces_owner_id_idx": { + "name": "workspaces_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_user_id_fk": { + "name": "workspaces_owner_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-postgres/meta/0005_snapshot.json b/drizzle-postgres/meta/0005_snapshot.json new file mode 100644 index 000000000..08ed1fab4 --- /dev/null +++ b/drizzle-postgres/meta/0005_snapshot.json @@ -0,0 +1,2506 @@ +{ + "id": "d97ba543-c09c-4540-9682-a823520ae2c7", + "prevId": "cff9bd5b-3d3d-4cc3-86d9-7236e8cef04d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_id_idx": { + "name": "oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_consent_user_id_idx": { + "name": "oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_assets": { + "name": "workspace_file_assets", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_object_key": { + "name": "source_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "preview_object_key": { + "name": "preview_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_size_bytes": { + "name": "preview_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_assets_item_id_workspace_items_id_fk": { + "name": "workspace_file_assets_item_id_workspace_items_id_fk", + "tableFrom": "workspace_file_assets", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_assets_source_object_key_unique": { + "name": "workspace_file_assets_source_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "source_object_key" + ] + }, + "workspace_file_assets_preview_object_key_unique": { + "name": "workspace_file_assets_preview_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "preview_object_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_pending_link_per_role": { + "name": "workspace_invites_pending_link_per_role", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'link' and \"workspace_invites\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_pending_email_per_workspace": { + "name": "workspace_invites_pending_email_per_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'email' and \"workspace_invites\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_created_by_user_id_idx": { + "name": "workspace_invites_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_created_by_user_id_user_id_fk": { + "name": "workspace_invites_created_by_user_id_user_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_invites_role_check": { + "name": "workspace_invites_role_check", + "value": "\"workspace_invites\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + }, + "workspace_invites_type_check": { + "name": "workspace_invites_type_check", + "value": "\"workspace_invites\".\"type\" in ('email', 'link')" + }, + "workspace_invites_status_check": { + "name": "workspace_invites_status_check", + "value": "\"workspace_invites\".\"status\" in ('pending', 'accepted', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_contents": { + "name": "workspace_item_contents", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_contents_item_id_workspace_items_id_fk": { + "name": "workspace_item_contents_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_contents", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_extractions": { + "name": "workspace_item_extractions", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_mode": { + "name": "provider_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_extractions_status_idx": { + "name": "workspace_item_extractions_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_extractions_item_fk": { + "name": "workspace_item_extractions_item_fk", + "tableFrom": "workspace_item_extractions", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_extractions_status_check": { + "name": "workspace_item_extractions_status_check", + "value": "\"workspace_item_extractions\".\"status\" in ('processing', 'ready', 'failed')" + }, + "workspace_item_extractions_tier_check": { + "name": "workspace_item_extractions_tier_check", + "value": "\"workspace_item_extractions\".\"tier\" is null or \"workspace_item_extractions\".\"tier\" in ('fast', 'enhanced')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_pages": { + "name": "workspace_item_pages", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "markdown_bytes": { + "name": "markdown_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_item_pages_item_id_workspace_items_id_fk": { + "name": "workspace_item_pages_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_pages", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_pages_item_id_page_number_pk": { + "name": "workspace_item_pages_item_id_page_number_pk", + "columns": [ + "item_id", + "page_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_pages_number_check": { + "name": "workspace_item_pages_number_check", + "value": "\"workspace_item_pages\".\"page_number\" > 0" + }, + "workspace_item_pages_bytes_check": { + "name": "workspace_item_pages_bytes_check", + "value": "\"workspace_item_pages\".\"markdown_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_relations": { + "name": "workspace_item_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_item_id": { + "name": "from_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_item_id": { + "name": "to_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_relations_unique": { + "name": "workspace_item_relations_unique", + "columns": [ + { + "expression": "from_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "note", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_item_relations_from_idx": { + "name": "workspace_item_relations_from_idx", + "columns": [ + { + "expression": "from_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_item_relations_to_idx": { + "name": "workspace_item_relations_to_idx", + "columns": [ + { + "expression": "to_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_relations_from_fk": { + "name": "workspace_item_relations_from_fk", + "tableFrom": "workspace_item_relations", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "from_item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_item_relations_to_fk": { + "name": "workspace_item_relations_to_fk", + "tableFrom": "workspace_item_relations", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "to_item_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_item_relations_kind_check": { + "name": "workspace_item_relations_kind_check", + "value": "\"workspace_item_relations\".\"kind\" in ('derived_from', 'references')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_item_user_states": { + "name": "workspace_item_user_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_item_user_states_item_id_idx": { + "name": "workspace_item_user_states_item_id_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_user_states_user_id_user_id_fk": { + "name": "workspace_item_user_states_user_id_user_id_fk", + "tableFrom": "workspace_item_user_states", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_item_user_states_item_id_workspace_items_id_fk": { + "name": "workspace_item_user_states_item_id_workspace_items_id_fk", + "tableFrom": "workspace_item_user_states", + "tableTo": "workspace_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_user_states_user_id_item_id_pk": { + "name": "workspace_item_user_states_user_id_item_id_pk", + "columns": [ + "user_id", + "item_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_items": { + "name": "workspace_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_key": { + "name": "name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "substr(translate(encode(decode(md5(gen_random_uuid()::text), 'hex'), 'base64'), '+/', 'Aa'), 1, 8)" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_items_tree_idx": { + "name": "workspace_items_tree_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_type_idx": { + "name": "workspace_items_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_ref_key_unique": { + "name": "workspace_items_ref_key_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_root_name_unique": { + "name": "workspace_items_root_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_items\".\"parent_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_items_parent_name_unique": { + "name": "workspace_items_parent_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_items\".\"parent_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_items_workspace_id_workspaces_id_fk": { + "name": "workspace_items_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_items_parent_fk": { + "name": "workspace_items_parent_fk", + "tableFrom": "workspace_items", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "parent_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_items_workspace_id_id_unique": { + "name": "workspace_items_workspace_id_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_items_type_check": { + "name": "workspace_items_type_check", + "value": "\"workspace_items\".\"type\" in ('folder', 'document', 'flashcard', 'quiz', 'file')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_user_unique": { + "name": "workspace_members_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_last_opened_at_idx": { + "name": "workspace_members_user_last_opened_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_members_role_check": { + "name": "workspace_members_role_check", + "value": "\"workspace_members\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspaces_owner_id_idx": { + "name": "workspaces_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_user_id_fk": { + "name": "workspaces_owner_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-postgres/meta/_journal.json b/drizzle-postgres/meta/_journal.json index a7d5371d6..64ec43925 100644 --- a/drizzle-postgres/meta/_journal.json +++ b/drizzle-postgres/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1786643511996, "tag": "0003_glorious_wolf_cub", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1786760670175, + "tag": "0004_charming_doomsday", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1786767401526, + "tag": "0005_dashing_masked_marvel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/eval/datasets/workspace-tools.cases.ts b/eval/datasets/workspace-tools.cases.ts index 678b84dc6..b09f5e2a5 100644 --- a/eval/datasets/workspace-tools.cases.ts +++ b/eval/datasets/workspace-tools.cases.ts @@ -37,6 +37,15 @@ export const workspaceToolCases: WorkspaceToolCase[] = [ expectedTools: ["workspace_read_items", "workspace_edit_item"], requiresTargetedEditFromRead: true, }, + { + name: "create a quiz without leaking the answer key into the stems", + input: { + prompt: + "Create a quiz at /Study/Photosynthesis Quiz with 3 multiple-choice questions about photosynthesis basics.", + }, + expectedTools: ["workspace_create_items"], + forbiddenTools: ["workspace_delete_items"], + }, { name: "respects a read-only turn", input: { diff --git a/eval/support/harness.ts b/eval/support/harness.ts index 25a08c606..c832baf52 100644 --- a/eval/support/harness.ts +++ b/eval/support/harness.ts @@ -26,7 +26,7 @@ import { } from "#/features/workspaces/documents/document-ai-html"; import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema"; import type { WorkspaceContentReadResult } from "#/features/workspaces/content/workspace-content-contract"; -import { createWorkspaceReadReferences } from "#/features/workspaces/content/workspace-read-references"; +import { parseWorkspaceAddress } from "#/features/workspaces/locations/workspace-location"; /** A single tool call the model emitted, graded against the real zod schema. */ export interface WorkspaceAgentToolCall { @@ -55,11 +55,11 @@ export interface WorkspaceAgentInput { } const STANDUP_PATH = "/Notes/Standup.md"; +const STANDUP_REF_KEY = "standup1"; type EvalStandupFixture = { blocks: Map; content: string; - references: ReturnType; }; let evalStandupFixture: Promise | undefined; @@ -86,16 +86,7 @@ async function createEvalStandupFixture(): Promise { content.push(await serializeTiptapNodeToAiHtml(node)); } - const result = { - content: content.join(""), - format: "html" as const, - itemId: "standup-document", - location: { endBlock: 2, kind: "blocks" as const, startBlock: 1, totalBlocks: 2 }, - path: STANDUP_PATH, - status: "ready" as const, - type: "document" as const, - }; - return { blocks, content: result.content, references: createWorkspaceReadReferences([result]) }; + return { blocks, content: content.join("") }; } // Per-tool stubbed outputs. Reads return the realistic item for the requested @@ -107,18 +98,15 @@ async function evalToolFixture(toolName: string, input: unknown): Promise }) ?.requests; const results: WorkspaceContentReadResult[] = (requests ?? []).map((request) => { - if (request.path !== STANDUP_PATH) { - return { code: "path_not_found", path: request.path ?? "", status: "failed" }; - } if (request.mode === "ref") { - const record = fixture.references.find(({ ref }) => ref === request.ref); + const address = request.ref ? parseWorkspaceAddress(request.ref) : undefined; const contentRef = - record?.location.kind === "document-block" && record.revision - ? `${record.location.blockId}.r_${record.revision}` - : ""; - const content = fixture.blocks.get(contentRef); - if (!content) { - return { code: "ref_not_found", path: STANDUP_PATH, status: "failed" }; + address?.refKey === STANDUP_REF_KEY && address.unit + ? [...fixture.blocks.keys()].find((key) => key.startsWith(`${address.unit}.`)) + : undefined; + const content = contentRef ? fixture.blocks.get(contentRef) : undefined; + if (!contentRef || !content) { + return { code: "ref_not_found", ref: request.ref ?? "", status: "failed" }; } return { @@ -127,10 +115,14 @@ async function evalToolFixture(toolName: string, input: unknown): Promise { diff --git a/src/features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx b/src/components/code-block/mermaid-diagram.tsx similarity index 80% rename from src/features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx rename to src/components/code-block/mermaid-diagram.tsx index 9519beb48..21a9723ac 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMermaidDiagram.tsx +++ b/src/components/code-block/mermaid-diagram.tsx @@ -1,6 +1,5 @@ import { Check, Copy, GitBranch, Maximize2, Minimize2, Minus, Plus } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; -import { useIsCodeFenceIncomplete } from "streamdown"; import { useTheme } from "#/components/theme-provider"; import { @@ -28,6 +27,24 @@ type MermaidRenderResult = let mermaidRenderQueue = Promise.resolve(); +/** + * Rendered SVGs keyed by theme and source. Study surfaces mount the same + * diagram over and over — flipping a card back and forth, stepping through + * questions, or any edit that rebuilds the editor — and re-running mermaid + * each time would flash a placeholder over artwork the reader just saw. + * Cleared wholesale past a generous ceiling: an eviction policy would cost + * more than the few kilobytes it saves. + */ +const mermaidImageCache = new Map(); +const MAX_CACHED_DIAGRAMS = 64; + +function cacheMermaidImage(requestKey: string, image: MermaidImage) { + if (mermaidImageCache.size >= MAX_CACHED_DIAGRAMS) { + mermaidImageCache.clear(); + } + mermaidImageCache.set(requestKey, image); +} + function enqueueMermaidRender(input: { darkMode: boolean; id: string; source: string }) { const render = mermaidRenderQueue.then(async () => { const { default: mermaid } = await import("mermaid"); @@ -146,17 +163,30 @@ function useNearViewport() { return { containerRef, isNearViewport }; } -export function AiChatMermaidDiagram({ source }: { source: string }) { - const isIncomplete = useIsCodeFenceIncomplete(); +/** + * Renders one mermaid source block as a diagram. Shared by chat, where the + * source streams in, and by document code blocks, where it arrives whole — + * hence `isIncomplete` as a prop rather than a hook: only chat can observe a + * half-written fence, and a document must not wait for a stream that has + * already ended. + */ +export function MermaidDiagram({ + isIncomplete = false, + source, +}: { + isIncomplete?: boolean; + source: string; +}) { const { resolvedTheme } = useTheme(); const reactId = useId(); const { containerRef, isNearViewport } = useNearViewport(); const requestKey = `${resolvedTheme}:${source}`; const sourceIsTooLarge = source.length > MAX_MERMAID_SOURCE_LENGTH; + const cachedImage = mermaidImageCache.get(requestKey); const [result, setResult] = useState(null); useEffect(() => { - if (!isNearViewport || isIncomplete || sourceIsTooLarge) { + if (!isNearViewport || isIncomplete || sourceIsTooLarge || cachedImage) { return; } @@ -174,13 +204,15 @@ export function AiChatMermaidDiagram({ source }: { source: string }) { return; } - setResult({ image: prepareMermaidImage(svg), requestKey, status: "ready" }); + const image = prepareMermaidImage(svg); + cacheMermaidImage(requestKey, image); + setResult({ image, requestKey, status: "ready" }); } catch (error: unknown) { if (cancelled) { return; } - console.warn("[AiChatMermaidDiagram] Failed to render diagram", error); + console.warn("[MermaidDiagram] Failed to render diagram", error); setResult({ requestKey, status: "error" }); } })(); @@ -188,16 +220,39 @@ export function AiChatMermaidDiagram({ source }: { source: string }) { return () => { cancelled = true; }; - }, [isIncomplete, isNearViewport, reactId, requestKey, resolvedTheme, source, sourceIsTooLarge]); - - const state: MermaidRenderResult | { status: "loading" } = sourceIsTooLarge - ? { requestKey, status: "error" } - : isIncomplete || result?.requestKey !== requestKey - ? { status: "loading" } - : result; + }, [ + cachedImage, + isIncomplete, + isNearViewport, + reactId, + requestKey, + resolvedTheme, + source, + sourceIsTooLarge, + ]); + + // A cache hit is read straight through to the render rather than copied + // into state, so a remount paints finished artwork on the first frame. + // This render's own result still outranks it: an that failed to + // decode must not be answered with the cached image that just failed. + const state = ((): MermaidRenderResult | { status: "loading" } => { + if (sourceIsTooLarge) { + return { requestKey, status: "error" }; + } + if (isIncomplete) { + return { status: "loading" }; + } + if (result?.requestKey === requestKey) { + return result; + } + if (cachedImage) { + return { image: cachedImage, requestKey, status: "ready" }; + } + return { status: "loading" }; + })(); return ( -
+
{state.status === "loading" ? : null} {state.status === "error" ? : null} {state.status === "ready" ? ( @@ -233,7 +288,7 @@ function MermaidError({ source }: { source: string }) { Diagram unavailable
-

This diagram couldn’t be displayed, but the rest of the response is unaffected.

+

This diagram couldn’t be displayed. Its source is unchanged.

View diagram source @@ -260,7 +315,7 @@ function MermaidDiagramCard({ const [zoom, setZoom] = useState(1); const { copied, copy } = useCopyToClipboard({ onError: (error) => { - console.warn("[AiChatMermaidDiagram] Failed to copy source", error); + console.warn("[MermaidDiagram] Failed to copy source", error); }, }); diff --git a/src/db/schema.ts b/src/db/schema.ts index ffc550e61..c84a5db63 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -262,6 +262,16 @@ export const workspaceItems = pgTable( type: text("type", { enum: WORKSPACE_ITEM_TYPES }).notNull(), name: text("name").notNull(), nameKey: text("name_key").notNull(), + // Short, stable, model-facing handle. The volatile default backfills + // existing rows from a ~47-bit alphanumeric space (base64 of random + // bytes, +/ mapped to letters) so the unique index cannot trip over + // hex-only birthday collisions; application code supplies 62^8 nanoid + // values on insert. + refKey: text("ref_key") + .notNull() + .default( + sql`substr(translate(encode(decode(md5(gen_random_uuid()::text), 'hex'), 'base64'), '+/', 'Aa'), 1, 8)`, + ), color: text("color"), metadata: jsonb("metadata").$type>().default({}).notNull(), sortOrder: integer("sort_order").notNull(), @@ -281,6 +291,7 @@ export const workspaceItems = pgTable( unique("workspace_items_workspace_id_id_unique").on(table.workspaceId, table.id), index("workspace_items_tree_idx").on(table.workspaceId, table.parentId, table.sortOrder), index("workspace_items_type_idx").on(table.workspaceId, table.type), + uniqueIndex("workspace_items_ref_key_unique").on(table.workspaceId, table.refKey), uniqueIndex("workspace_items_root_name_unique") .on(table.workspaceId, table.nameKey) .where(sql`${table.parentId} is null`), diff --git a/src/features/workspaces/ai/ai-thread-runtime.ts b/src/features/workspaces/ai/ai-thread-runtime.ts index 21e2fdc38..aaf2dff61 100644 --- a/src/features/workspaces/ai/ai-thread-runtime.ts +++ b/src/features/workspaces/ai/ai-thread-runtime.ts @@ -15,7 +15,6 @@ import type { AIThreadContext, AIThreadPromptScope, } from "#/features/workspaces/ai/ai-thread-metadata"; -import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; import { requireAiToolDefinition, type AiToolModelPolicy, @@ -52,11 +51,11 @@ const AI_THREAD_VIEW_ONLY_WORKSPACE_LINE = "- Workspace access: view-only. Do not create, rename, edit, move, or delete workspace items."; const AI_THREAD_WORKSPACE_CITATION_PROMPT = [ "# Workspace Citations", - "- Workspace reads may include short refs such as `wr_7Kp2Qa9x`.", - '- After a quote or important claim supported by a ref, place an empty `` immediately after that text.', - "- Copy refs exactly. Never invent, alter, reuse a ref for different material, or cite web/unsupported claims with workspace tags. Omit the tag when no ref was provided.", + "- Every workspace read returns the item's durable ref (like `Xk7p2Qa9`), and units inside it — pages, blocks, cards, questions — have unit refs.", + '- After a quote or important claim supported by workspace content, place an empty citation immediately after that text: `` for the item, or `` for a unit (join the item ref and the unit with a slash, dropping any `.r_` suffix).', + "- Copy refs exactly. Never invent or alter one, and never cite web/unsupported claims with workspace tags. Omit the tag when you did not read the content.", "- Cite selectively: important claims, conclusions, summaries, and direct quotes, not every sentence or common knowledge.", - "- When you mention a newly created workspace item and creation returned a ref, cite the item name with that ref.", + "- When you mention a newly created workspace item, cite its name with the ref creation returned.", "- Workspace names and paths are not URLs. Never build Markdown links to workspace items from a name, path, or app origin.", ].join("\n"); const WORKSPACE_FS_METHOD_NAMES = [ @@ -83,8 +82,6 @@ export function createAIThreadTools(input: { threadId: string; workspace: WorkspaceLike; getThreadContext: () => Promise; - onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; - resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; timeZone?: string; }): ToolSet { return createAIThreadToolCatalog(input).tools; @@ -98,8 +95,6 @@ export function createAIThreadTurnToolConfig(input: { getThreadContext: () => Promise; canMutate: boolean; onOrchestrationRuntime?: Parameters[0]["onRuntime"]; - onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; - resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; timeZone?: string; }) { const toolCatalog = createAIThreadToolCatalog(input); @@ -135,8 +130,6 @@ function createAIThreadToolCatalog(input: { threadId: string; workspace: WorkspaceLike; getThreadContext: () => Promise; - onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; - resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; timeZone?: string; }) { const sandboxTools = createSandboxTools(input.workspace); @@ -152,8 +145,6 @@ function createAIThreadToolCatalog(input: { const workspaceTools = createAIThreadWorkspaceTools({ env: input.env, getThreadContext: input.getThreadContext, - onWorkspaceReferences: input.onWorkspaceReferences, - resolveWorkspaceReferences: input.resolveWorkspaceReferences, }); const entries: AIThreadToolEntry[] = []; diff --git a/src/features/workspaces/ai/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts index 1cc070ed5..41b66c231 100644 --- a/src/features/workspaces/ai/ai-thread.ts +++ b/src/features/workspaces/ai/ai-thread.ts @@ -24,10 +24,6 @@ import { AI_THREAD_COMPACTION_TOKEN_THRESHOLD, createAIThreadCompactFunction, } from "#/features/workspaces/ai/ai-compaction"; -import { - collectWorkspaceReferenceRecords, - reconcileWorkspaceMessageReferences, -} from "#/features/workspaces/ai/workspace-references"; import { resolveChatAttachmentModelMessages } from "#/features/workspaces/ai/chat-attachment-model"; import { classifyAIThreadChatError } from "#/features/workspaces/ai/chat-error-classification"; import type { AIThreadContext } from "#/features/workspaces/ai/ai-thread-metadata"; @@ -48,7 +44,6 @@ import { type WorkspaceAiChatModelId, } from "#/features/workspaces/ai/models"; import type { UserAIStore } from "#/features/workspaces/ai/user-ai-agents"; -import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; import { checkWorkspaceAiMessageAccess, trackWorkspaceAiMessageUsage, @@ -103,7 +98,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { private shouldRefreshSessionPrompt = false; private activeRunStartedAt: number | undefined; private activeUsageContext: AIThreadUsageContext | undefined; - private activeWorkspaceReferences: WorkspaceReferenceRecord[] = []; private readonly telemetry = new AIThreadTelemetryRecorder({ env: this.env, schedule: (task) => { @@ -164,10 +158,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { threadId: this.name, workspace: this.workspace, getThreadContext: () => this._getThreadContext(), - onWorkspaceReferences: (records) => { - this._recordWorkspaceReferences(records); - }, - resolveWorkspaceReferences: (refs) => this._resolveWorkspaceReferences(refs), }); } @@ -186,7 +176,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { } if (!ctx.continuation) { - this.activeWorkspaceReferences = []; this.activeRunStartedAt = await directory.recordThreadRunStarted(this.name, { isUserMessage: true, }); @@ -295,9 +284,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { override async onChatResponse(result: ChatResponseResult) { this.telemetry.recordTurnFinished(result); this._trackCompletedMessageUsage(result); - if (result.status === "completed") { - await this._reconcileWorkspaceReferences(result.message); - } if (!this._shouldSettleRunAfterResponse(result)) { await this._refreshSessionPromptIfNeeded(); return; @@ -352,10 +338,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { onOrchestrationRuntime: (runtime) => { this.codemode = runtime; }, - onWorkspaceReferences: (records) => { - this._recordWorkspaceReferences(records); - }, - resolveWorkspaceReferences: (refs) => this._resolveWorkspaceReferences(refs), timeZone, }); } @@ -420,57 +402,10 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { } finally { this.activeRunStartedAt = undefined; this.activeUsageContext = undefined; - this.activeWorkspaceReferences = []; await this._refreshSessionPromptIfNeeded(); } } - private _recordWorkspaceReferences(records: readonly WorkspaceReferenceRecord[]) { - this.activeWorkspaceReferences.push(...records); - } - - /** - * Looks up the refs a read handed the assistant, so a tool can turn one - * into the location it stands for. Reads from this turn are not in the - * transcript yet, which is exactly when a document usually cites them. - */ - private async _resolveWorkspaceReferences(refs: readonly string[]) { - const wanted = new Set(refs); - const records = [ - ...collectWorkspaceReferenceRecords(await this.getMessages()), - ...this.activeWorkspaceReferences, - ]; - - return records.filter((record) => wanted.has(record.ref)); - } - - private async _reconcileWorkspaceReferences(message: ChatResponseResult["message"]) { - try { - const transcriptReferences = collectWorkspaceReferenceRecords(await this.getMessages()); - const reconciled = reconcileWorkspaceMessageReferences( - message, - [...transcriptReferences, ...this.activeWorkspaceReferences], - this.activeWorkspaceReferences, - ); - - if (reconciled !== message) { - await this.addMessages([reconciled], { mode: "upsert" }); - } - } catch (error) { - const thread = this.activeUsageContext?.thread; - recordOperationalFailure({ - distinctId: thread?.userId, - error, - event: "ai_citation_finalization", - fields: { - thread_id: this.name, - user_id: thread?.userId, - workspace_id: thread?.workspaceId, - }, - }); - } - } - private _trackCompletedMessageUsage(result: ChatResponseResult) { if (result.continuation || result.status !== "completed") { return; diff --git a/src/features/workspaces/ai/workspace-read-file-fallback.ts b/src/features/workspaces/ai/workspace-read-file-fallback.ts index 01d974584..e1ed20586 100644 --- a/src/features/workspaces/ai/workspace-read-file-fallback.ts +++ b/src/features/workspaces/ai/workspace-read-file-fallback.ts @@ -4,7 +4,7 @@ import { workspaceReadItemsInputSchema, workspaceReadItemsOutputSchema, } from "#/features/workspaces/content/workspace-content-contract"; -import { createWorkspaceReadItemsModelOutput } from "#/features/workspaces/content/workspace-read-references"; +import { createWorkspaceReadItemsModelOutput } from "#/features/workspaces/content/workspace-read-model-output"; import { getWorkspaceFileSourceObject } from "#/features/workspaces/extraction/workspace-file-source"; const maxPendingPdfBytes = 3.5 * 1024 * 1024; diff --git a/src/features/workspaces/ai/workspace-references.test.ts b/src/features/workspaces/ai/workspace-references.test.ts deleted file mode 100644 index a834bc936..000000000 --- a/src/features/workspaces/ai/workspace-references.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { UIMessage } from "ai"; -import { describe, expect, it } from "vitest"; - -import { - collectWorkspaceReferenceRecords, - reconcileWorkspaceMessageReferences, - stripWorkspaceCitationTags, -} from "#/features/workspaces/ai/workspace-references"; -import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; - -const first = reference("wr_AAAAAAAA", "item-1"); -const second = reference("wr_BBBBBBBB", "item-2"); - -describe("workspace references", () => { - it("persists only known refs used by assistant text in first-use order", () => { - const message = assistantMessage( - `Alpha beta ` + - ` gamma ` + - ` again `, - ); - const reconciled = reconcileWorkspaceMessageReferences(message, [first, second]); - - expect(collectWorkspaceReferenceRecords([reconciled])).toEqual([second, first]); - }); - - it("rejects a ref that maps to different durable locations", () => { - const collision = reference("wr_AAAAAAAA", "item-2"); - const message = assistantMessage(`Alpha `); - - expect( - collectWorkspaceReferenceRecords([ - reconcileWorkspaceMessageReferences(message, [first, collision]), - ]), - ).toEqual([]); - }); - - it("is idempotent and removes stale normalized records", () => { - const message = assistantMessage(`Alpha `); - const reconciled = reconcileWorkspaceMessageReferences(message, [first]); - - expect(reconcileWorkspaceMessageReferences(reconciled, [first])).toBe(reconciled); - expect( - collectWorkspaceReferenceRecords([ - reconcileWorkspaceMessageReferences( - { ...reconciled, parts: [{ type: "text", text: "No citation" }, reconciled.parts[1]] }, - [first], - ), - ]), - ).toEqual([]); - }); - - it("collapses duplicate citation data parts to one canonical part", () => { - const message = assistantMessage(`Alpha `); - const reconciled = reconcileWorkspaceMessageReferences(message, [first]); - const duplicated = { - ...reconciled, - parts: [...reconciled.parts, reconciled.parts[1]], - }; - const repaired = reconcileWorkspaceMessageReferences(duplicated, [first]); - - expect(repaired.parts.filter((part) => part.type === "data-workspace-references")).toHaveLength( - 1, - ); - }); - - it("retains Code Mode refs that are not present in a direct tool result", () => { - const reconciled = reconcileWorkspaceMessageReferences(assistantMessage("Done"), [], [first]); - - expect(collectWorkspaceReferenceRecords([reconciled])).toEqual([first]); - }); - - it("does not duplicate refs already persisted by a direct tool result", () => { - const readOutput = { references: [first], results: [] }; - const message: UIMessage = { - id: "assistant-1", - role: "assistant", - parts: [ - { - type: "tool-workspace_read_items", - toolCallId: "call-1", - state: "output-available", - input: { requests: [] }, - output: readOutput, - }, - ], - }; - const reconciled = reconcileWorkspaceMessageReferences(message, [first], [first]); - - expect(reconciled.parts).toHaveLength(1); - expect(collectWorkspaceReferenceRecords([reconciled])).toEqual([first]); - }); - - it("collects references from direct workspace reads", () => { - const readOutput = { - references: [first], - results: [ - { - content: '

Notes

', - format: "html", - itemId: "item-1", - location: { - endBlock: 1, - kind: "blocks", - startBlock: 1, - totalBlocks: 1, - }, - path: "/Notes", - status: "ready", - type: "document", - }, - ], - }; - const messages: UIMessage[] = [ - { - id: "assistant-1", - role: "assistant", - parts: [ - { - type: "tool-workspace_read_items", - toolCallId: "call-1", - state: "output-available", - input: { requests: [] }, - output: readOutput, - }, - ], - }, - ]; - - expect(collectWorkspaceReferenceRecords(messages)).toEqual([first]); - }); - - it("does not trust references inside model-authored Code Mode results", () => { - const messages: UIMessage[] = [ - { - id: "assistant-1", - role: "assistant", - parts: [ - { - type: "dynamic-tool", - toolName: "orchestrate", - toolCallId: "call-1", - state: "output-available", - input: {}, - output: { - status: "completed", - result: { - references: [first], - results: [], - }, - }, - }, - ], - }, - ]; - - expect(collectWorkspaceReferenceRecords(messages)).toEqual([]); - }); - - it("ignores citation records supplied by a user message", () => { - const messages: UIMessage[] = [ - { - id: "user-1", - role: "user", - parts: [ - { - type: "data-workspace-references", - id: "workspace-references", - data: { references: [first], version: 1 }, - }, - ], - }, - ]; - - expect(collectWorkspaceReferenceRecords(messages)).toEqual([]); - }); - - it("strips valid citation protocol tags from copied Markdown", () => { - expect( - stripWorkspaceCitationTags( - `Alpha beta label`, - ), - ).toBe("Alpha beta label"); - }); -}); - -function assistantMessage(text: string): UIMessage { - return { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text }], - }; -} - -function reference(ref: string, itemId: string): WorkspaceReferenceRecord { - return { - location: { itemId, kind: "item", version: 1 }, - ref: ref as WorkspaceReferenceRecord["ref"], - }; -} diff --git a/src/features/workspaces/ai/workspace-references.ts b/src/features/workspaces/ai/workspace-references.ts index 7981016cb..db1ce82c3 100644 --- a/src/features/workspaces/ai/workspace-references.ts +++ b/src/features/workspaces/ai/workspace-references.ts @@ -1,140 +1,14 @@ -import { isToolUIPart, type UIMessage } from "ai"; -import { z } from "zod"; - -import { getWorkspaceToolResultAdapter } from "#/features/workspaces/ai/workspace-tool-result-adapters"; -import { - getWorkspaceLocationKey, - indexWorkspaceReferenceRecords, - parseWorkspaceReference, - type WorkspaceReference, - type WorkspaceReferenceRecord, - type WorkspaceLocation, - workspaceReferenceRecordSchema, -} from "#/features/workspaces/locations/workspace-location"; - -export const WORKSPACE_REFERENCES_DATA_PART_TYPE = "data-workspace-references"; -const MAX_WORKSPACE_CITATIONS_PER_MESSAGE = 50; -const workspaceCitationTagPattern = - /|>\s*<\/citation\s*>)/g; -const anyCompleteWorkspaceCitationTagPattern = /<\/?citation\b[^>]*>/gi; - -const workspaceReferencesDataSchema = z.strictObject({ - references: z.array(workspaceReferenceRecordSchema), - version: z.literal(1), -}); - /** - * Reconciles one hidden reference data part onto an assistant message. - * - * It retains valid cited refs plus refs produced inside Code Mode that do not - * already live in a direct tool result. Unknown or colliding citations stay inert. - * - * @param message - Finalized assistant message. - * @param candidates - App-issued refs available to this response. - * @returns The original message when already normalized, otherwise an updated copy. + * Chat citations are self-describing workspace addresses: the assistant copies + * `refKey` or `refKey/unit` from a read into ``, and the + * client resolves them against the live workspace when a message is drawn. + * Nothing is minted or persisted per turn. */ -export function reconcileWorkspaceMessageReferences( - message: UIMessage, - candidates: readonly WorkspaceReferenceRecord[], - retain: readonly WorkspaceReferenceRecord[] = [], -): UIMessage { - const directRecords = collectWorkspaceToolReferenceRecords(message); - const directKeys = new Set(directRecords.map(getWorkspaceReferenceRecordKey)); - const references = dedupeWorkspaceReferenceRecords([ - ...resolveUsedWorkspaceCitations(message, candidates), - ...retain.filter((record) => !directKeys.has(getWorkspaceReferenceRecordKey(record))), - ]); - if (hasCanonicalWorkspaceReferencePart(message, references)) { - return message; - } - - const parts = message.parts.filter((part) => part.type !== WORKSPACE_REFERENCES_DATA_PART_TYPE); - if (references.length > 0) { - parts.push({ - type: WORKSPACE_REFERENCES_DATA_PART_TYPE, - data: { - references, - version: 1, - }, - }); - } - - return { ...message, parts }; -} - -/** - * Returns validated durable reference records persisted on a UI message. - * - * @param message - Any persisted or streaming UI message. - * @returns The first valid normalized reference list, or an empty list. - */ -function getWorkspaceMessageReferenceRecords( - message: UIMessage, -): readonly WorkspaceReferenceRecord[] { - for (const part of message.parts) { - if (part.type !== WORKSPACE_REFERENCES_DATA_PART_TYPE || !("data" in part)) { - continue; - } - - const parsed = workspaceReferencesDataSchema.safeParse(part.data); - if (parsed.success) { - return parsed.data.references; - } - } - - return []; -} - -/** - * Collects app-issued reference records from direct workspace tools and - * normalized hidden reference data in a persisted transcript. - * - * Code Mode's final result is model-authored, so genuine nested reads are - * captured during execution instead of trusted from the orchestration output. - * - * @param messages - UI messages on the active thread path. - * @returns Candidate records in transcript order. - */ -export function collectWorkspaceReferenceRecords( - messages: readonly UIMessage[], -): WorkspaceReferenceRecord[] { - const records: WorkspaceReferenceRecord[] = []; - - for (const message of messages) { - if (message.role !== "assistant") { - continue; - } - - records.push(...getWorkspaceMessageReferenceRecords(message)); - records.push(...collectWorkspaceToolReferenceRecords(message)); - } - - return records; -} - -/** - * Builds the unambiguous ref-to-location map available while rendering a message. - * - * Persisted references and completed direct workspace-tool outputs both - * contribute, so direct tool citations can appear before post-response - * reconciliation arrives. - * - * @param message - Streaming or persisted UI message. - * @returns Unambiguous locations keyed by their exact model-facing refs. - */ -export function getWorkspaceCitationLocations( - message: UIMessage, -): ReadonlyMap { - const index = indexWorkspaceReferenceRecords(collectWorkspaceReferenceRecords([message])); - const locations = new Map(); - - for (const [ref, record] of index) { - if (record) locations.set(ref, record.location); - } +/** Hidden data part written by earlier versions; recognized only to keep it hidden. */ +export const WORKSPACE_REFERENCES_DATA_PART_TYPE = "data-workspace-references"; - return locations; -} +const anyCompleteWorkspaceCitationTagPattern = /<\/?citation\b[^>]*>/gi; /** * Removes model-only citation markers from text copied to the clipboard. @@ -145,106 +19,3 @@ export function getWorkspaceCitationLocations( export function stripWorkspaceCitationTags(text: string) { return text.replace(anyCompleteWorkspaceCitationTagPattern, ""); } - -function resolveUsedWorkspaceCitations( - message: UIMessage, - candidates: readonly WorkspaceReferenceRecord[], -) { - const candidateIndex = indexWorkspaceReferenceRecords(candidates); - const citations: WorkspaceReferenceRecord[] = []; - const usedRefs = new Set(); - - for (const part of message.parts) { - if (part.type !== "text") { - continue; - } - - for (const match of part.text.matchAll(workspaceCitationTagPattern)) { - const ref = parseWorkspaceReference(match[2]); - if (!ref || usedRefs.has(ref)) { - continue; - } - - const candidate = candidateIndex.get(ref); - if (!candidate) { - continue; - } - - usedRefs.add(ref); - citations.push(candidate); - if (citations.length === MAX_WORKSPACE_CITATIONS_PER_MESSAGE) { - return citations; - } - } - } - - return citations; -} - -function haveSameWorkspaceReferences( - left: readonly WorkspaceReferenceRecord[], - right: readonly WorkspaceReferenceRecord[], -) { - return ( - left.length === right.length && - left.every( - (record, index) => - record.ref === right[index]?.ref && - getWorkspaceLocationKey(record.location) === - getWorkspaceLocationKey(right[index].location) && - record.revision === right[index].revision, - ) - ); -} - -function hasCanonicalWorkspaceReferencePart( - message: UIMessage, - references: readonly WorkspaceReferenceRecord[], -) { - const referenceParts = message.parts.filter( - (part) => part.type === WORKSPACE_REFERENCES_DATA_PART_TYPE, - ); - - if (references.length === 0) { - return referenceParts.length === 0; - } - - if (referenceParts.length !== 1) { - return false; - } - - const [part] = referenceParts; - if (!part || !("data" in part)) { - return false; - } - - const parsed = workspaceReferencesDataSchema.safeParse(part.data); - return parsed.success && haveSameWorkspaceReferences(parsed.data.references, references); -} - -function collectWorkspaceToolReferenceRecords(message: UIMessage) { - const records: WorkspaceReferenceRecord[] = []; - for (const part of message.parts) { - if (!isToolUIPart(part) || part.state !== "output-available") continue; - const toolName = - part.type === "dynamic-tool" ? part.toolName : part.type.split("-").slice(1).join("-"); - records.push( - ...(getWorkspaceToolResultAdapter(toolName)?.collectReferences(part.output) ?? []), - ); - } - return records; -} - -function dedupeWorkspaceReferenceRecords(records: readonly WorkspaceReferenceRecord[]) { - const seen = new Set(); - return records.filter((record) => { - const key = getWorkspaceReferenceRecordKey(record); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function getWorkspaceReferenceRecordKey(record: WorkspaceReferenceRecord) { - return `${record.ref}:${getWorkspaceLocationKey(record.location)}:${record.revision ?? ""}`; -} diff --git a/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts b/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts index 254ccb821..411a045c6 100644 --- a/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts +++ b/src/features/workspaces/ai/workspace-tool-result-adapters.test.ts @@ -6,13 +6,12 @@ describe("workspace tool result adapters", () => { it("projects current results onto the model-facing shape", () => { const output = { failed: [], - items: [{ itemId: "itm_1", path: "/Notes/A", type: "document" }], - references: [{ location: { itemId: "itm_1", kind: "item", version: 1 }, ref: "wr_7Kp2Qa9x" }], + items: [{ itemId: "itm_1", path: "/Notes/A", ref: "Xk7p2Qa9", type: "document" }], }; expect(getWorkspaceToolResultAdapter("workspace_create_items")?.projectOutput(output)).toEqual({ failed: [], - items: [{ path: "/Notes/A", ref: "wr_7Kp2Qa9x", type: "document" }], + items: [{ path: "/Notes/A", ref: "Xk7p2Qa9", type: "document" }], }); }); @@ -27,7 +26,6 @@ describe("workspace tool result adapters", () => { }, ], items: [], - references: [], }; expect(getWorkspaceToolResultAdapter("workspace_create_items")?.projectOutput(output)).toEqual({ @@ -39,18 +37,12 @@ describe("workspace tool result adapters", () => { it("projects create failures without exposing internal references", () => { const output = { failed: [{ code: "path_already_exists", index: 0, path: "/Old" }], - items: [{ itemId: "itm_internal", path: "/Old", type: "document" }], - references: [ - { - location: { itemId: "itm_internal", kind: "item", version: 1 }, - ref: "wr_7Kp2Qa9x", - }, - ], + items: [{ itemId: "itm_internal", path: "/Old", ref: "Xk7p2Qa9", type: "document" }], }; expect(getWorkspaceToolResultAdapter("workspace_create_items")?.projectOutput(output)).toEqual({ failed: output.failed, - items: [{ path: "/Old", ref: "wr_7Kp2Qa9x", type: "document" }], + items: [{ path: "/Old", ref: "Xk7p2Qa9", type: "document" }], }); }); diff --git a/src/features/workspaces/ai/workspace-tool-result-adapters.ts b/src/features/workspaces/ai/workspace-tool-result-adapters.ts index d7ac9a4c5..043426a88 100644 --- a/src/features/workspaces/ai/workspace-tool-result-adapters.ts +++ b/src/features/workspaces/ai/workspace-tool-result-adapters.ts @@ -1,68 +1,38 @@ import type { JSONValue } from "ai"; -import { z } from "zod"; import { workspaceReadItemsOutputSchema } from "#/features/workspaces/content/workspace-content-contract"; -import { createWorkspaceReadItemsModelOutput } from "#/features/workspaces/content/workspace-read-references"; -import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; +import { createWorkspaceReadItemsModelOutput } from "#/features/workspaces/content/workspace-read-model-output"; import { workspaceCreateItemsOutputSchema, workspaceEditItemOutputSchema, } from "#/features/workspaces/operations/workspace-tool-schemas"; -function defineWorkspaceToolResultAdapter(input: { - collectReferences?: (output: z.output) => readonly WorkspaceReferenceRecord[]; - outputSchema: TSchema; - projectOutput: (output: z.output) => unknown; -}) { - return { - collectReferences: (output: unknown) => { - const parsed = input.outputSchema.safeParse(output); - return parsed.success ? (input.collectReferences?.(parsed.data) ?? []) : []; +/** + * Model-facing projections for tool outputs whose rich form carries more than + * the model needs. Addresses are already self-describing, so projection is + * only ever subtraction: internal item ids and app-side bookkeeping. + */ +const workspaceToolResultAdapters = { + workspace_create_items: { + projectOutput: (output: unknown): JSONValue => { + const { failed, items } = workspaceCreateItemsOutputSchema.parse(output); + return { failed, items: items.map(({ itemId: _itemId, ...item }) => item) } as JSONValue; }, - projectOutput: (output: unknown) => { - return input.projectOutput(input.outputSchema.parse(output)) as JSONValue; + }, + // The operation output also carries the durable item id used by the app's + // review controls. Keep the model-facing receipt limited to actionable counts. + workspace_edit_item: { + projectOutput: (output: unknown): JSONValue => { + const { applied, failed, path } = workspaceEditItemOutputSchema.parse(output); + return { applied, failed, path } as JSONValue; }, - }; -} - -const workspaceReadItemsResultAdapter = defineWorkspaceToolResultAdapter({ - collectReferences: (output) => output.references, - outputSchema: workspaceReadItemsOutputSchema, - projectOutput: createWorkspaceReadItemsModelOutput, -}); - -const workspaceCreateItemsResultAdapter = defineWorkspaceToolResultAdapter({ - collectReferences: (output) => output.references, - outputSchema: workspaceCreateItemsOutputSchema, - projectOutput: (output) => { - const refsByItemId = new Map( - output.references.flatMap((record) => - record.location.kind === "item" ? [[record.location.itemId, record.ref] as const] : [], - ), - ); - - return { - failed: output.failed, - items: output.items.map(({ itemId, path, type }) => { - const ref = refsByItemId.get(itemId); - - return { path, ...(ref ? { ref } : {}), type }; - }), - }; }, -}); - -// The operation output also carries the durable item id used by the app's -// review controls. Keep the model-facing receipt limited to actionable counts. -const workspaceEditItemResultAdapter = defineWorkspaceToolResultAdapter({ - outputSchema: workspaceEditItemOutputSchema, - projectOutput: ({ applied, failed, path }) => ({ applied, failed, path }), -}); - -const workspaceToolResultAdapters = { - workspace_create_items: workspaceCreateItemsResultAdapter, - workspace_edit_item: workspaceEditItemResultAdapter, - workspace_read_items: workspaceReadItemsResultAdapter, + workspace_read_items: { + projectOutput: (output: unknown): JSONValue => + createWorkspaceReadItemsModelOutput( + workspaceReadItemsOutputSchema.parse(output), + ) as JSONValue, + }, } as const; export function getWorkspaceToolResultAdapter(name: string) { diff --git a/src/features/workspaces/ai/workspace-tools.ts b/src/features/workspaces/ai/workspace-tools.ts index 0ad155bf9..050e958c4 100644 --- a/src/features/workspaces/ai/workspace-tools.ts +++ b/src/features/workspaces/ai/workspace-tools.ts @@ -7,23 +7,16 @@ import { isPendingPdfFallbackInput, } from "#/features/workspaces/ai/workspace-read-file-fallback"; import { getWorkspaceToolResultAdapter } from "#/features/workspaces/ai/workspace-tool-result-adapters"; -import type { WorkspaceReferenceRecord } from "#/features/workspaces/locations/workspace-location"; import { workspaceToolDefinitions, getWorkspaceToolScopes, } from "#/features/workspaces/operations/workspace-tool-definitions"; -import { - createWorkspaceAccessContext, - type WorkspaceAccessContext, - type WorkspaceAccessScope, -} from "#/features/workspaces/operations/workspace-access-context"; +import { createWorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; type WorkspaceThreadToolConfig = { definition: (typeof workspaceToolDefinitions)[number]; env: Cloudflare.Env; getThreadContext: () => Promise; - onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; - resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; }; function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { @@ -65,18 +58,14 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { const output = await definition.executeUnknown( args, - createThreadWorkspaceAccessContext( - thread, - getWorkspaceToolScopes(definition.access), - context.invocationId, - input.resolveWorkspaceReferences, - ), + createWorkspaceAccessContext({ + operationId: context.invocationId, + scopes: getWorkspaceToolScopes(definition.access), + userId: thread.userId, + workspaceId: thread.workspaceId, + }), ); - const references = resultAdapter?.collectReferences(output) ?? []; - if (references.length > 0 && input.onWorkspaceReferences) { - input.onWorkspaceReferences(references); - } if (freshWorkspaceIds && isPendingPdfFallbackInput(args)) { freshWorkspaceIds.set(context.invocationId, thread.workspaceId); } @@ -89,8 +78,6 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { export function createAIThreadWorkspaceTools(input: { env: Cloudflare.Env; getThreadContext: () => Promise; - onWorkspaceReferences?: (records: readonly WorkspaceReferenceRecord[]) => void; - resolveWorkspaceReferences?: (refs: readonly string[]) => Promise; }): ToolSet { return Object.fromEntries( workspaceToolDefinitions.map((definition) => [ @@ -99,8 +86,6 @@ export function createAIThreadWorkspaceTools(input: { definition, env: input.env, getThreadContext: input.getThreadContext, - onWorkspaceReferences: input.onWorkspaceReferences, - resolveWorkspaceReferences: input.resolveWorkspaceReferences, }), ]), ) as ToolSet; @@ -115,18 +100,3 @@ async function requireThreadContext(getThreadContext: () => Promise Promise, -): WorkspaceAccessContext { - return createWorkspaceAccessContext({ - operationId, - ...(resolveWorkspaceReferences ? { resolveWorkspaceReferences } : {}), - scopes, - userId: thread.userId, - workspaceId: thread.workspaceId, - }); -} diff --git a/src/features/workspaces/cache-page.test.ts b/src/features/workspaces/cache-page.test.ts index df650432a..933613b61 100644 --- a/src/features/workspaces/cache-page.test.ts +++ b/src/features/workspaces/cache-page.test.ts @@ -75,6 +75,7 @@ function createItem(input: Partial = {}): WorkspaceItem { return { color: input.color ?? null, createdAt: "2026-01-01T00:00:00.000Z", + refKey: "ref-item-1", id: "folder-1", metadataJson: {}, name: input.name ?? "Folder", diff --git a/src/features/workspaces/components/WorkspaceCitation.tsx b/src/features/workspaces/components/WorkspaceCitation.tsx index aa252dfa1..33aa2755a 100644 --- a/src/features/workspaces/components/WorkspaceCitation.tsx +++ b/src/features/workspaces/components/WorkspaceCitation.tsx @@ -5,16 +5,19 @@ import type { WorkspaceItem } from "#/features/workspaces/contracts"; import { flashcardViewerQueryOptions } from "#/features/workspaces/flashcards/flashcard-queries"; import type { WorkspaceLocation } from "#/features/workspaces/locations/workspace-location"; import { useWorkspaceLocationActions } from "#/features/workspaces/locations/workspace-location-context"; +import { quizViewerQueryOptions } from "#/features/workspaces/quizzes/quiz-queries"; import { cn } from "#/lib/utils"; export function WorkspaceCitation({ location }: { readonly location: WorkspaceLocation }) { const item = useWorkspaceLocationActions().getItem(location.itemId); - return location.kind === "flashcard" && item?.type === "flashcard" ? ( - - ) : ( - - ); + if (location.kind === "flashcard" && item?.type === "flashcard") { + return ; + } + if (location.kind === "quiz-question" && item?.type === "quiz") { + return ; + } + return ; } function FlashcardCitation({ @@ -41,6 +44,31 @@ function FlashcardCitation({ ); } +function QuizCitation({ + item, + location, +}: { + item: WorkspaceItem; + location: Extract; +}) { + const { data: questionNumber } = useQuery({ + ...quizViewerQueryOptions({ + itemId: item.id, + updatedAt: item.updatedAt, + workspaceId: item.workspaceId, + }), + select: ({ questions }) => + questions.findIndex((question) => question.id === location.questionId) + 1, + }); + + return ( + + ); +} + function CitationButton({ location, locatorLabel, diff --git a/src/features/workspaces/components/WorkspaceContent.tsx b/src/features/workspaces/components/WorkspaceContent.tsx index 7fbfc8f79..7baecb4a1 100644 --- a/src/features/workspaces/components/WorkspaceContent.tsx +++ b/src/features/workspaces/components/WorkspaceContent.tsx @@ -11,6 +11,7 @@ import { } from "#/components/ui/empty"; import { DocumentEditorSurface } from "#/features/workspaces/components/document-editor/DocumentEditorSurface"; import { FlashcardViewer } from "#/features/workspaces/components/flashcards/FlashcardViewer"; +import { QuizViewer } from "#/features/workspaces/components/quizzes/QuizViewer"; import { WorkspaceClipboardIntakeDialog } from "#/features/workspaces/components/WorkspaceClipboardIntakeDialog"; import WorkspaceClickableEmptyState from "#/features/workspaces/components/WorkspaceClickableEmptyState"; import { useWorkspaceClipboardIntake } from "#/features/workspaces/components/useWorkspaceClipboardIntake"; @@ -525,6 +526,10 @@ function WorkspaceItemView({ return ; } + if (item.type === "quiz") { + return ; + } + const { Icon: ItemIcon, iconClassName, surfaceClassName } = getWorkspaceItemDisplay(item); const itemViewContent = (
diff --git a/src/features/workspaces/components/WorkspaceLayout.tsx b/src/features/workspaces/components/WorkspaceLayout.tsx index dfc6f5fe3..bd7238d31 100644 --- a/src/features/workspaces/components/WorkspaceLayout.tsx +++ b/src/features/workspaces/components/WorkspaceLayout.tsx @@ -4,7 +4,10 @@ import { workspacePageQueryKey } from "#/features/workspaces/cache-keys"; import { applyWorkspacePageDeltaToCache } from "#/features/workspaces/cache-page"; import AiChatPanel from "#/features/workspaces/components/AiChatPanel"; import WorkspaceChatLayout from "#/features/workspaces/components/WorkspaceChatLayout"; -import { CreateFlashcardsDialog } from "#/features/workspaces/components/flashcards/CreateFlashcardsDialog"; +import { + CreateStudyItemDialog, + type StudyItemDialogType, +} from "#/features/workspaces/components/study/CreateStudyItemDialog"; import WorkspaceContextBar from "#/features/workspaces/components/WorkspaceContextBar"; import { hasActiveWorkspaceCapture } from "#/features/workspaces/components/WorkspaceCaptureChrome"; import WorkspaceDragProvider from "#/features/workspaces/components/WorkspaceDragProvider"; @@ -70,7 +73,10 @@ export function WorkspaceShell({ const queryClient = useQueryClient(); const createWorkspaceItemMutation = useCreateWorkspaceItemMutation(); const moveWorkspaceItemsMutation = useMoveWorkspaceItemsMutation(); - const [flashcardParentId, setFlashcardParentId] = useState(); + const [studyDraft, setStudyDraft] = useState<{ + type: StudyItemDialogType; + parentId: string | null; + }>(); const persistedStoresHydrated = useWorkspacePersistedStoresHydrated(); const ensureWorkspaceUiSession = useWorkspaceUiStore((state) => state.ensureWorkspaceSession); const itemViewStatesByViewInstanceId = useWorkspaceItemViewStatesByViewInstance(workspace.id); @@ -135,8 +141,8 @@ export function WorkspaceShell({ return; } - if (input.type === "flashcard") { - setFlashcardParentId(input.parentId); + if (input.type === "flashcard" || input.type === "quiz") { + setStudyDraft({ type: input.type, parentId: input.parentId }); return; } createWorkspaceItemMutation.mutate({ @@ -297,11 +303,10 @@ export function WorkspaceShell({ ); - const flashcardParent = flashcardParentId ? itemsById.get(flashcardParentId) : undefined; - const flashcardDialogOpen = flashcardParentId === null || flashcardParent !== undefined; - const flashcardParentPath = flashcardParent - ? getWorkspaceItemPath(flashcardParent, itemsById) - : "/"; + const studyParent = studyDraft?.parentId ? itemsById.get(studyDraft.parentId) : undefined; + const studyDialogOpen = + studyDraft !== undefined && (studyDraft.parentId === null || studyParent !== undefined); + const studyParentPath = studyParent ? getWorkspaceItemPath(studyParent, itemsById) : "/"; return ( @@ -310,12 +315,13 @@ export function WorkspaceShell({ {workspaceInteractionContent} - { - if (!open) setFlashcardParentId(undefined); + if (!open) setStudyDraft(undefined); }} /> diff --git a/src/features/workspaces/components/ai-chat/AiChatMessagePartView.tsx b/src/features/workspaces/components/ai-chat/AiChatMessagePartView.tsx index 06b6522b9..21a90a213 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessagePartView.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessagePartView.tsx @@ -16,30 +16,23 @@ import { import { AiChatMessageResponse } from "#/features/workspaces/components/ai-chat/AiChatMessageResponse"; import { AiChatToolActivityRow } from "#/features/workspaces/components/ai-chat/AiChatToolActivityRow"; import type { AiChatMessagePart } from "#/features/workspaces/components/ai-chat/types"; -import type { - WorkspaceLocation, - WorkspaceReference, -} from "#/features/workspaces/locations/workspace-location"; export function AiChatMessagePartView({ interruptUnfinishedTools = false, isStreaming = false, part, preserveWhitespace = false, - workspaceCitationLocations, }: { interruptUnfinishedTools?: boolean; isStreaming?: boolean; part: AiChatMessagePart | AiChatToolGroupPart; preserveWhitespace?: boolean; - workspaceCitationLocations?: ReadonlyMap; }) { if (part.type === "text") { return ( {part.text} diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageResponse.test.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageResponse.test.tsx index 7e593de6d..d896a15b1 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageResponse.test.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageResponse.test.tsx @@ -6,10 +6,6 @@ import { AiChatMessageResponse } from "#/features/workspaces/components/ai-chat/ import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; import { flashcardViewerQueryOptions } from "#/features/workspaces/flashcards/flashcard-queries"; import { createEmptyFlashcardStudyState } from "#/features/workspaces/flashcards/flashcard-study-state"; -import type { - WorkspaceLocation, - WorkspaceReference, -} from "#/features/workspaces/locations/workspace-location"; import { WorkspaceLocationProvider } from "#/features/workspaces/locations/workspace-location-context"; import type { WorkspaceItem } from "#/features/workspaces/contracts"; @@ -18,9 +14,15 @@ vi.mock("#/features/workspaces/flashcards/flashcard-functions", () => ({ recordFlashcardStudyRatingFn: vi.fn(), resetFlashcardStudyProgressFn: vi.fn(), })); +vi.mock("#/features/workspaces/quizzes/quiz-functions", () => ({ + getQuizViewerFn: vi.fn(), + recordQuizAnswerFn: vi.fn(), + resetQuizStudyProgressFn: vi.fn(), +})); const documentItem: WorkspaceItem = { color: null, + refKey: "refdoc01", createdAt: "2026-01-01T00:00:00.000Z", id: "document-1", metadataJson: {}, @@ -35,6 +37,7 @@ const documentItem: WorkspaceItem = { const flashcardItem: WorkspaceItem = { ...documentItem, id: "flashcard-1", + refKey: "refcard1", name: "Biology", type: "flashcard", }; @@ -44,7 +47,6 @@ function renderMessage( options: { isStreaming?: boolean; items?: WorkspaceItem[]; - locations?: ReadonlyMap; queryClient?: QueryClient; } = {}, ) { @@ -54,38 +56,42 @@ function renderMessage( itemsById={new Map(options.items?.map((item) => [item.id, item]))} navigate={() => "tab-1"} > - - {children} - + {children} , ); } describe("AI chat message response citations", () => { - it("renders a validated citation as an app-owned source button", () => { - const ref = "wr_AAAAAAAA" as WorkspaceReference; - const html = renderMessage(`Claim `, { - locations: new Map([ - [ref, { itemId: "missing-pdf", kind: "pdf-page", pageNumber: 12, version: 1 }], - ]), + it("renders a resolvable page citation as an app-owned source button", () => { + const fileItem: WorkspaceItem = { + ...documentItem, + id: "file-1", + refKey: "reffile1", + name: "Book.pdf", + type: "file", + }; + const html = renderMessage(`Claim `, { + items: [fileItem], }); expect(html).toContain("Source unavailable"); expect(html).toContain(">· p. 12"); expect(html).not.toContain(" { + const html = renderMessage(`Claim `, { + items: [documentItem], + }); + + expect(html).not.toContain(" { - const ref = "wr_BBBBBBBB" as WorkspaceReference; - const html = renderMessage(`Claim `, { + const html = renderMessage(`Claim `, { items: [documentItem], - locations: new Map([[ref, { itemId: documentItem.id, kind: "item", version: 1 }]]), }); expect(html).toContain(" { }); it("shows a flashcard's authored position", () => { - const ref = "wr_CCCCCCCC" as WorkspaceReference; const cardId = "f67080f9-0158-4565-86a9-4c90ed6809d2"; const queryClient = new QueryClient(); const query = flashcardViewerQueryOptions({ @@ -110,11 +115,8 @@ describe("AI chat message response citations", () => { ], studyState: createEmptyFlashcardStudyState(), }); - const html = renderMessage(`Claim `, { + const html = renderMessage(`Claim `, { items: [flashcardItem], - locations: new Map([ - [ref, { cardId, itemId: flashcardItem.id, kind: "flashcard", version: 1 }], - ]), queryClient, }); @@ -123,17 +125,18 @@ describe("AI chat message response citations", () => { }); it("does not expose an incomplete streamed citation tag", () => { - const html = renderMessage('Claim { - const ref = "wr_AAAAAAAA" as WorkspaceReference; - const html = renderMessage(`Claim not a citation`, { - locations: new Map([[ref, { itemId: "document-1", kind: "item", version: 1 }]]), + const html = renderMessage(`Claim not a citation`, { + items: [documentItem], }); expect(html).toContain("not a citation"); diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx index a463da156..0b1ca8283 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageResponse.tsx @@ -1,15 +1,11 @@ import { cjk } from "@streamdown/cjk"; import { createMathPlugin } from "@streamdown/math"; -import { createContext, type ComponentProps, use, useEffect } from "react"; +import { type ComponentProps, useEffect } from "react"; import { Streamdown, type StreamdownProps } from "streamdown"; import "katex/dist/katex.min.css"; // Extends the shared KaTeX instance with \ce{} chemistry and \pu{} units. import "katex/contrib/mhchem"; -import { - parseWorkspaceReference, - type WorkspaceLocation, - type WorkspaceReference, -} from "#/features/workspaces/locations/workspace-location"; +import { useWorkspaceLocationActions } from "#/features/workspaces/locations/workspace-location-context"; import { MarkdownCodeBlock } from "#/features/workspaces/components/ai-chat/ai-chat-code-block"; import { normalizeLlmMarkdown } from "#/features/workspaces/components/ai-chat/normalize-llm-markdown"; import { WorkspaceCitation } from "#/features/workspaces/components/WorkspaceCitation"; @@ -20,7 +16,6 @@ type AiChatMessageResponseProps = Omit< "allowedTags" | "literalTagContent" > & { isStreaming?: boolean; - workspaceCitationLocations?: ReadonlyMap; }; const math = createMathPlugin({ @@ -31,10 +26,6 @@ const streamdownPlugins = { cjk, math }; const streamdownComponents = { code: MarkdownCodeBlock }; const streamdownAllowedTags = { citation: ["ref"] }; const streamdownLiteralTagContent = ["citation"]; -const emptyWorkspaceCitationLocations = new Map(); -const WorkspaceCitationLocationsContext = createContext< - ReadonlyMap ->(emptyWorkspaceCitationLocations); const streamdownAnimation = { animation: "fadeIn", duration: 160, @@ -50,6 +41,7 @@ type StreamdownCitationProps = Record & { }; function StreamdownWorkspaceCitation(citationProps: StreamdownCitationProps) { + const { resolveAddress } = useWorkspaceLocationActions(); const children = citationProps.children; if (typeof children === "string" && children.trim().length > 0) { return children; @@ -58,13 +50,7 @@ function StreamdownWorkspaceCitation(citationProps: StreamdownCitationProps) { return null; } - const ref = parseWorkspaceReference(citationProps.node?.properties?.ref); - if (!ref) { - return null; - } - - const locations = use(WorkspaceCitationLocationsContext); - const location = locations.get(ref); + const location = resolveAddress(citationProps.node?.properties?.ref); return location ? : null; } @@ -73,7 +59,6 @@ export function AiChatMessageResponse({ className, components, isStreaming = false, - workspaceCitationLocations = emptyWorkspaceCitationLocations, ...props }: AiChatMessageResponseProps) { useEffect(() => { @@ -90,21 +75,19 @@ export function AiChatMessageResponse({ typeof children === "string" ? normalizeLlmMarkdown(children) : children; return ( - - ol]:pl-2 [&>ul]:pl-2", className)} - components={mergedComponents} - isAnimating={isStreaming} - linkSafety={{ enabled: false }} - mode="streaming" - plugins={streamdownPlugins} - {...props} - allowedTags={streamdownAllowedTags} - literalTagContent={streamdownLiteralTagContent} - > - {normalizedChildren} - - + ol]:pl-2 [&>ul]:pl-2", className)} + components={mergedComponents} + isAnimating={isStreaming} + linkSafety={{ enabled: false }} + mode="streaming" + plugins={streamdownPlugins} + {...props} + allowedTags={streamdownAllowedTags} + literalTagContent={streamdownLiteralTagContent} + > + {normalizedChildren} + ); } diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx index 11974dfae..3d6e052a4 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageRow.tsx @@ -11,10 +11,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "#/comp import { AiChatMessagePartView } from "#/features/workspaces/components/ai-chat/AiChatMessagePartView"; import { AiChatDocumentEditActions } from "#/features/workspaces/components/ai-chat/AiChatDocumentEditActions"; import { getAiChatDocumentEditGroups } from "#/features/workspaces/components/ai-chat/ai-chat-document-edit-actions"; -import { - getWorkspaceCitationLocations, - stripWorkspaceCitationTags, -} from "#/features/workspaces/ai/workspace-references"; +import { stripWorkspaceCitationTags } from "#/features/workspaces/ai/workspace-references"; import { type AiChatRenderablePart, type AssistantRowDisplay, @@ -49,8 +46,6 @@ export default function AiChatMessageRow({ message: AiChatMessage; onRegenerate?: () => void; }) { - const workspaceCitationLocations = getWorkspaceCitationLocations(message); - if (message.role === "assistant" && display?.kind === "hidden") { return null; } @@ -86,7 +81,6 @@ export default function AiChatMessageRow({ isStreaming={isStreaming} message={message} onRegenerate={onRegenerate} - workspaceCitationLocations={workspaceCitationLocations} /> ) : ( @@ -209,13 +203,11 @@ function AssistantMessageBody({ isStreaming, message, onRegenerate, - workspaceCitationLocations, }: { display: AssistantRowDisplay; isStreaming: boolean; message: AiChatMessage; onRegenerate?: () => void; - workspaceCitationLocations: ReturnType; }) { if (display.kind === "content") { return ( @@ -226,7 +218,6 @@ function AssistantMessageBody({ interruptUnfinishedTools={display.interruptUnfinishedTools} isStreaming={isStreaming} part={part} - workspaceCitationLocations={workspaceCitationLocations} /> ))}
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx b/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx index fad3fcd15..4a8d30e81 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx +++ b/src/features/workspaces/components/ai-chat/ai-chat-code-block.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { isValidElement, useEffect, useState } from "react"; import type { ThemedToken } from "shiki/core"; import { ClientOnly } from "@tanstack/react-router"; +import { useIsCodeFenceIncomplete } from "streamdown"; import { CodeBlockActions, CodeBlockCopyButton, @@ -13,10 +14,11 @@ import { import { getCodeLanguageLabel, highlightCodeTokens, + isMermaidCodeLanguage, normalizeCodeLanguage, type SupportedCodeLanguage, } from "#/features/workspaces/documents/code-block-shiki/highlighter"; -import { AiChatMermaidDiagram } from "#/features/workspaces/components/ai-chat/AiChatMermaidDiagram"; +import { MermaidDiagram } from "#/components/code-block/mermaid-diagram"; import { cn } from "#/lib/utils.ts"; // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline @@ -358,6 +360,9 @@ export const MarkdownCodeBlock = ({ "data-block": dataBlock, ...props }: MarkdownCodeBlockProps) => { + // Only chat can see a half-written fence, so the streaming state is read + // here and handed to the shared diagram rather than looked up inside it. + const isFenceIncomplete = useIsCodeFenceIncomplete(); const isBlock = dataBlock !== undefined; if (!isBlock) { @@ -375,10 +380,10 @@ export const MarkdownCodeBlock = ({ getLanguageFromClassName(className) ?? getLanguageFromClassName(node?.properties?.className); const code = getTextContent(children).replace(/\n$/, ""); - if (rawLanguage?.toLowerCase() === "mermaid") { + if (isMermaidCodeLanguage(rawLanguage)) { return ( - + ); } diff --git a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts index 6cd3e183d..285a9eb49 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts @@ -166,7 +166,13 @@ export function getFinishedToolReceipt(input: { successVerb: "Created", typeFromItem: (item) => { const type = getString(asRecord(item).type); - return type === "folder" ? "folder" : type === "flashcard" ? "flashcard set" : "document"; + return type === "folder" + ? "folder" + : type === "flashcard" + ? "flashcard set" + : type === "quiz" + ? "quiz" + : "document"; }, }); case "workspace_delete_items": diff --git a/src/features/workspaces/components/flashcards/FlashcardViewer.tsx b/src/features/workspaces/components/flashcards/FlashcardViewer.tsx index 81edcf8ce..4782c1e8c 100644 --- a/src/features/workspaces/components/flashcards/FlashcardViewer.tsx +++ b/src/features/workspaces/components/flashcards/FlashcardViewer.tsx @@ -1,16 +1,23 @@ import { useQuery } from "@tanstack/react-query"; -import type { JSONContent } from "@tiptap/core"; -import { EditorContent, useEditor } from "@tiptap/react"; -import { Check, ChevronLeft, ChevronRight, Lightbulb, X } from "lucide-react"; +import { Check, X } from "lucide-react"; import { domAnimation, LazyMotion, m, MotionConfig } from "motion/react"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "#/components/ui/button"; import { Skeleton } from "#/components/ui/skeleton"; import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; -import { FlashcardToolbar } from "#/features/workspaces/components/flashcards/FlashcardToolbar"; +import { + StudyProgressStrip, + type StudyProgressTone, +} from "#/features/workspaces/components/study/StudyProgressStrip"; +import { StudyRichText } from "#/features/workspaces/components/study/StudyRichText"; +import { + StudyAiActionButton, + StudyNavButton, + useStudySessionFocus, +} from "#/features/workspaces/components/study/StudySessionControls"; +import { StudyToolbar } from "#/features/workspaces/components/study/StudyToolbar"; import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; -import { getTiptapDocumentBaseExtensions } from "#/features/workspaces/documents/tiptap-extensions"; import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; import { flashcardViewerQueryOptions, @@ -178,9 +185,17 @@ function FlashcardStudySession({ }, [resetProgress, startSession]); const toolbar = useMemo( () => ( - 0} isResetting={isResetting} + labels={{ + allLabel: "All cards", + mobileLabel: "Flashcard study options", + resetAriaLabel: "Reset flashcard progress", + resetDescription: + "This clears every saved response for this set. The cards themselves will not change.", + resetTitle: "Reset flashcard progress?", + }} missedCount={studyProgress.missedCount} mode={mode} shuffled={shuffled} @@ -407,6 +422,7 @@ function FlashcardStudySurface({ studyCards: Flashcard[]; studyState: FlashcardStudyState; }) { + const sectionRef = useStudySessionFocus(currentCard.id); const settling = ratingFeedback !== null; const nextCard = ratingFeedback ? studyCards[currentIndex + 1] : undefined; const isLastCardRating = ratingFeedback !== null && !nextCard; @@ -441,6 +457,7 @@ function FlashcardStudySurface({
{ @@ -565,7 +582,8 @@ function FlashcardStudySurface({ label="Front" content={currentCard.front} action={ - sendComposerPrompt( @@ -582,7 +600,8 @@ function FlashcardStudySurface({ label="Back" content={currentCard.back} action={ - sendComposerPrompt( @@ -599,30 +618,28 @@ function FlashcardStudySurface({
- { + const { label, tone } = describeFlashcardRating( + studyState.cards[card.id]?.lastRating, + ); + return { id: card.id, label: `Card ${index + 1}: ${label}`, tone }; + })} />
{gotItCount + missedCount} reviewed
- + /> - + />
{currentIndex + 1} of {studyCards.length} @@ -679,66 +691,6 @@ function FlashcardStudySurface({ ); } -function FlashcardStatusStrip({ - cards, - currentIndex, - gotItCount, - missedCount, - onSelect, - reviewsByCardId, -}: { - cards: Flashcard[]; - currentIndex: number; - gotItCount: number; - missedCount: number; - onSelect: (index: number) => void; - reviewsByCardId: FlashcardStudyState["cards"]; -}) { - const unseenCount = cards.length - gotItCount - missedCount; - const gap = cards.length <= 50 ? 3 : cards.length <= 120 ? 2 : cards.length <= 300 ? 1 : 0; - - return ( -
- {cards.map((card, index) => { - const rating = reviewsByCardId[card.id]?.lastRating; - const status = getFlashcardRatingLabel(rating); - return ( - - ); - })} -
- ); -} - function getStudyCards(cardIds: string[], cardsById: ReadonlyMap): Flashcard[] { const cards: Flashcard[] = []; for (const cardId of cardIds) { @@ -748,18 +700,21 @@ function getStudyCards(cardIds: string[], cardsById: ReadonlyMap 500 ? "is-very-dense" : textLength > 220 ? "is-dense" : undefined; - const editor = useEditor( - { - content: content as unknown as JSONContent, - editable: false, - immediatelyRender: false, - extensions: getTiptapDocumentBaseExtensions(), - editorProps: { - attributes: { - class: cn( - "workspace-document-prose workspace-flashcard-prose outline-none", - densityClass, - ), - }, - }, - }, - [content], - ); return (
{action} - +
); } -function FlashcardAiAction({ label, onSend }: { label: string; onSend: () => void }) { - return ( - - ); -} - function countFlashcardText(value: unknown): number { if (Array.isArray(value)) { return value.reduce((length, entry) => length + countFlashcardText(entry), 0); diff --git a/src/features/workspaces/components/flashcards/flashcard-viewer.css b/src/features/workspaces/components/flashcards/flashcard-viewer.css index 3174b99bf..1faed71e3 100644 --- a/src/features/workspaces/components/flashcards/flashcard-viewer.css +++ b/src/features/workspaces/components/flashcards/flashcard-viewer.css @@ -47,9 +47,11 @@ } .workspace-flashcard-prose { + width: 100%; max-width: 42rem; + margin-inline: auto; font-size: clamp(1.4rem, 3vw, 2rem); - text-align: center; + text-align: left; } .workspace-flashcard-prose.is-dense { @@ -60,12 +62,6 @@ font-size: clamp(0.95rem, 1.7vw, 1.2rem); } - .workspace-flashcard-prose ul, - .workspace-flashcard-prose ol { - display: inline-block; - text-align: left; - } - @media (prefers-reduced-motion: reduce) { .workspace-flashcard-face { transition-duration: 1ms; diff --git a/src/features/workspaces/components/quizzes/QuizViewer.tsx b/src/features/workspaces/components/quizzes/QuizViewer.tsx new file mode 100644 index 000000000..3ac924e8b --- /dev/null +++ b/src/features/workspaces/components/quizzes/QuizViewer.tsx @@ -0,0 +1,560 @@ +import { useQuery } from "@tanstack/react-query"; +import { Check, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Button } from "#/components/ui/button"; +import { Skeleton } from "#/components/ui/skeleton"; +import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; +import { StudyProgressStrip } from "#/features/workspaces/components/study/StudyProgressStrip"; +import { StudyRichText } from "#/features/workspaces/components/study/StudyRichText"; +import { + StudyAiActionButton, + StudyNavButton, + useStudySessionFocus, +} from "#/features/workspaces/components/study/StudySessionControls"; +import { StudyToolbar } from "#/features/workspaces/components/study/StudyToolbar"; +import { useWorkspaceItemToolbar } from "#/features/workspaces/components/WorkspaceItemToolbarSlot"; +import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import type { WorkspaceItem } from "#/features/workspaces/contracts"; +import { useWorkspaceRevealRequest } from "#/features/workspaces/locations/workspace-location-context"; +import type { QuizQuestion } from "#/features/workspaces/quizzes/quiz-content"; +import { + quizViewerQueryOptions, + useRecordQuizAnswer, + useResetQuizStudyProgress, +} from "#/features/workspaces/quizzes/quiz-queries"; +import { + createQuizStudyQueue, + getQuizStudyViewState, + type QuizStudyMode, +} from "#/features/workspaces/quizzes/quiz-study-session"; +import { + getQuizAnswer, + summarizeQuizStudyProgress, + type QuizStudyState, +} from "#/features/workspaces/quizzes/quiz-study-state"; +import { useWorkspaceUiStore } from "#/features/workspaces/state/workspace-ui-store"; +import { cn } from "#/lib/utils"; + +interface QuizSessionState { + questionIds: string[]; + currentIndex: number; + mode: QuizStudyMode; + /** The option picked but not yet submitted for the current question. */ + pendingOptionId: string | null; + shuffled: boolean; +} + +export function QuizViewer({ + itemPath, + item, + viewInstanceId, +}: { + itemPath: string; + item: WorkspaceItem; + viewInstanceId: string; +}) { + const { data, error, isPending } = useQuery( + quizViewerQueryOptions({ + itemId: item.id, + updatedAt: item.updatedAt, + workspaceId: item.workspaceId, + }), + ); + + if (isPending) return ; + if (error || !data) { + return ( +
+ Could not load this quiz. +
+ ); + } + return ( + + ); +} + +function QuizStudySession({ + questions, + itemPath, + studyState, + item, + viewInstanceId, +}: { + questions: QuizQuestion[]; + itemPath: string; + studyState: QuizStudyState; + item: WorkspaceItem; + viewInstanceId: string; +}) { + const [session, setSession] = useState(() => ({ + questionIds: createQuizStudyQueue({ questions, mode: "all", shuffled: false, studyState }), + currentIndex: 0, + mode: "all", + pendingOptionId: null, + shuffled: false, + })); + const { questionIds, currentIndex, mode, pendingOptionId, shuffled } = session; + const questionsById = useMemo( + () => new Map(questions.map((question) => [question.id, question])), + [questions], + ); + const studyQuestions = useMemo( + () => + questionIds.flatMap((questionId) => { + const question = questionsById.get(questionId); + return question ? [question] : []; + }), + [questionIds, questionsById], + ); + const currentQuestion = studyQuestions[currentIndex]; + const sourceQuestionNumber = currentQuestion + ? questions.findIndex((question) => question.id === currentQuestion.id) + 1 + : 0; + const answer = currentQuestion ? getQuizAnswer(currentQuestion, studyState) : undefined; + const sectionRef = useStudySessionFocus(currentQuestion?.id); + const quizProgress = useMemo( + () => summarizeQuizStudyProgress(questions, studyState), + [questions, studyState], + ); + const clearItemViewState = useWorkspaceUiStore((state) => state.clearItemViewState); + const setItemViewState = useWorkspaceUiStore((state) => state.setItemViewState); + const { complete: completeRevealRequest, request: revealRequest } = useWorkspaceRevealRequest( + viewInstanceId, + "quiz-question", + ); + const queryInput = { + itemId: item.id, + updatedAt: item.updatedAt, + workspaceId: item.workspaceId, + }; + const recordAnswer = useRecordQuizAnswer(queryInput); + const { isPending: isResetting, mutate: resetProgress } = useResetQuizStudyProgress(queryInput); + const startSession = useCallback( + (nextMode: QuizStudyMode, nextShuffled: boolean) => { + setSession({ + questionIds: createQuizStudyQueue({ + questions, + mode: nextMode, + shuffled: nextShuffled, + studyState, + }), + currentIndex: 0, + mode: nextMode, + pendingOptionId: null, + shuffled: nextShuffled, + }); + }, + [questions, studyState], + ); + const goTo = useCallback((index: number) => { + setSession((current) => ({ + ...current, + currentIndex: Math.min(Math.max(index, 0), current.questionIds.length - 1), + pendingOptionId: null, + })); + }, []); + const resetStudyProgress = useCallback(() => { + startSession("all", false); + resetProgress(); + }, [resetProgress, startSession]); + const toolbar = useMemo( + () => ( + 0} + isResetting={isResetting} + labels={{ + allLabel: "All questions", + mobileLabel: "Quiz options", + resetAriaLabel: "Reset quiz progress", + resetDescription: + "This clears every saved answer for this quiz. The questions themselves will not change.", + resetTitle: "Reset quiz progress?", + }} + missedCount={quizProgress.incorrectCount} + mode={mode} + shuffled={shuffled} + onModeChange={(nextMode) => startSession(nextMode, shuffled)} + onReset={resetStudyProgress} + onShuffleToggle={() => startSession(mode, !shuffled)} + /> + ), + [isResetting, mode, quizProgress, resetStudyProgress, shuffled, startSession], + ); + useWorkspaceItemToolbar(viewInstanceId, toolbar); + useEffect(() => { + if (!currentQuestion) return; + setItemViewState(item.workspaceId, viewInstanceId, { + itemId: item.id, + ...getQuizStudyViewState({ + answered: answer !== undefined, + ...(answer ? { correct: answer.selectedOptionId === currentQuestion.correctOptionId } : {}), + mode, + progress: quizProgress, + sessionPosition: currentIndex + 1, + sessionTotal: studyQuestions.length, + shuffled, + sourceQuestionNumber, + }), + }); + }, [ + answer, + currentIndex, + currentQuestion, + item.id, + item.workspaceId, + mode, + quizProgress, + setItemViewState, + shuffled, + sourceQuestionNumber, + studyQuestions.length, + viewInstanceId, + ]); + useEffect( + () => () => clearItemViewState(item.workspaceId, viewInstanceId), + [clearItemViewState, item.workspaceId, viewInstanceId], + ); + useEffect(() => { + if (!revealRequest) return; + let nextQuestionIds = questionIds; + let questionIndex = nextQuestionIds.indexOf(revealRequest.location.questionId); + if (questionIndex < 0 && questionsById.has(revealRequest.location.questionId)) { + nextQuestionIds = createQuizStudyQueue({ questions, mode: "all", shuffled, studyState }); + questionIndex = nextQuestionIds.indexOf(revealRequest.location.questionId); + } + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + if (questionIndex >= 0) { + setSession((current) => ({ + ...current, + questionIds: nextQuestionIds, + currentIndex: questionIndex, + mode: nextQuestionIds === questionIds ? current.mode : "all", + pendingOptionId: null, + })); + } + completeRevealRequest(revealRequest, questionIndex >= 0); + }); + return () => { + cancelled = true; + }; + }, [ + completeRevealRequest, + questionIds, + questions, + questionsById, + revealRequest, + shuffled, + studyState, + ]); + + if (questions.length === 0) { + return ( +
+
+

No questions yet

+

Ask AI to add questions to this quiz.

+
+
+ ); + } + if (studyQuestions.length === 0) { + return ( +
+
+
+

No missed questions

+

Nothing needs another try right now.

+
+ +
+
+ ); + } + if (!currentQuestion) return null; + + const graded = answer !== undefined; + const isCorrect = answer?.selectedOptionId === currentQuestion.correctOptionId; + const selectedOptionId = graded ? answer.selectedOptionId : pendingOptionId; + const isLastQuestion = currentIndex >= studyQuestions.length - 1; + // Khan-style: the pending pick, and the correct option once graded, get an + // outline. Wrong picks keep only the filled X — no row chrome — so they + // stay in the hairline list. + const outlinedOptionIds = new Set( + [!graded ? selectedOptionId : null, graded ? currentQuestion.correctOptionId : null].filter( + Boolean, + ), + ); + const lastOption = currentQuestion.options.at(-1); + + return ( +
{ + if ((event.target as HTMLElement).closest("input, textarea, select")) return; + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + goTo(currentIndex + (event.key === "ArrowLeft" ? -1 : 1)); + } + }} + > +
+ {/* Keyed by question so a scrolled-down long question does not leave the + next one scrolled past its stem. pt-1 leaves the Hint button's focus + ring room inside the scroller, which clips both axes once + overflow-y is set. */} +
+
+ + {/* The slot keeps its height once grading removes the Hint, so the + options below do not jump up. */} +
+ {!graded ? ( + + sendComposerPrompt( + item.workspaceId, + `Give me a helpful hint for question ${sourceQuestionNumber} in “${itemPath}” without revealing the answer.`, + ) + } + /> + ) : null} +
+
+ +
+ {currentQuestion.options.map((option, optionIndex) => { + const isSelected = option.id === selectedOptionId; + const isCorrectOption = option.id === currentQuestion.correctOptionId; + const previousOption = currentQuestion.options[optionIndex - 1]; + return ( +
+ + +
+ ); + })} + +
+ + {graded ? ( +
+
+ + {isCorrect ? "Correct" : "Incorrect"} + + + sendComposerPrompt( + item.workspaceId, + `Explain the answer to question ${sourceQuestionNumber} in “${itemPath}” in more depth.`, + ) + } + /> +
+ +
+ ) : null} +
+ +
+ { + const questionAnswer = getQuizAnswer(question, studyState); + const questionCorrect = questionAnswer?.selectedOptionId === question.correctOptionId; + return { + id: question.id, + label: `Question ${index + 1}: ${ + !questionAnswer ? "Unanswered" : questionCorrect ? "Correct" : "Missed" + }`, + tone: !questionAnswer ? "unseen" : questionCorrect ? "correct" : "missed", + }; + })} + /> +
+ + {quizProgress.answeredCount} answered + +
+ goTo(currentIndex - 1)} + /> + + {currentIndex + 1} of {studyQuestions.length} + + goTo(currentIndex + 1)} + /> +
+ {graded ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +function QuizRichText({ content, compact }: { content: TiptapDocumentJson; compact?: boolean }) { + return ( + + ); +} + +/** + * Hairline between answer rows. Always in the flow and hidden by colour rather + * than by unmounting, so rows never shift as the outline moves between them. + */ +function QuizOptionRule({ visible }: { visible: boolean }) { + return
; +} + +function QuizViewerSkeleton() { + return ( +
+
+
+
+ + +
+ {/* Widths vary so the rows read as answers rather than a block. */} + {["w-3/5", "w-2/5", "w-1/2", "w-4/6"].map((width) => ( +
+
+ + +
+
+ ))} +
+
+
+
+ +
+
+ +
+ + + +
+ +
+
+
+
+ ); +} diff --git a/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx b/src/features/workspaces/components/study/CreateStudyItemDialog.tsx similarity index 63% rename from src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx rename to src/features/workspaces/components/study/CreateStudyItemDialog.tsx index 37d7db68e..549d21a92 100644 --- a/src/features/workspaces/components/flashcards/CreateFlashcardsDialog.tsx +++ b/src/features/workspaces/components/study/CreateStudyItemDialog.tsx @@ -1,4 +1,4 @@ -import { Layers3 } from "lucide-react"; +import { Layers3, ListChecks } from "lucide-react"; import { useId } from "react"; import { Button } from "#/components/ui/button"; @@ -15,12 +15,36 @@ import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select" import { Textarea } from "#/components/ui/textarea"; import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; -export function CreateFlashcardsDialog({ +export type StudyItemDialogType = "flashcard" | "quiz"; + +const studyItemDialogConfigs = { + flashcard: { + Icon: Layers3, + iconClassName: "text-violet-500", + title: "Create flashcards", + countLabel: "Number of cards", + prompt: (count: number, locationPhrase: string, topic: string) => + `Create a flashcard set with exactly ${count} cards ${locationPhrase}. Cover: ${topic}`, + }, + quiz: { + Icon: ListChecks, + iconClassName: "text-emerald-500", + title: "Create a quiz", + countLabel: "Number of questions", + prompt: (count: number, locationPhrase: string, topic: string) => + `Create a quiz with exactly ${count} multiple-choice questions ${locationPhrase}. Cover: ${topic}`, + }, +} as const satisfies Record; + +/** Collects a topic and size, then hands the AI composer the create request. */ +export function CreateStudyItemDialog({ + type, open, parentPath, workspaceId, onOpenChange, }: { + type: StudyItemDialogType; open: boolean; parentPath: string; workspaceId: string; @@ -28,6 +52,7 @@ export function CreateFlashcardsDialog({ }) { const topicId = useId(); const countId = useId(); + const config = studyItemDialogConfigs[type]; return ( @@ -43,7 +68,7 @@ export function CreateFlashcardsDialog({ if ( !sendComposerPrompt( workspaceId, - `Create a flashcard set with exactly ${count} cards ${describeFlashcardLocation(parentPath)}. Cover: ${topic}`, + config.prompt(count, describeStudyItemLocation(parentPath), topic), ) ) return; @@ -52,14 +77,14 @@ export function CreateFlashcardsDialog({ > - - AI will create the set in your current chat. + AI will create it in your current chat. - What should the cards cover? + What should it cover?