From 12e4e60005fba64acfab5f7136e41ea178bef273 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Tue, 21 Apr 2026 23:28:14 -0700 Subject: [PATCH] feat(data-api): add group and pin resources with polymorphic pin table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sibling resources land together because their migration SQL is generated as one unit and their API-schema / handler wiring has to go in the same commit to keep the repo compiling. group: upgrade the existing group table from `sort_order INT` to `order_key TEXT` (`scopedOrderKeyIndex('group', 'entityType')`) and expose it as a first-class resource under /groups. Each entityType owns an independent orderKey sequence. Reorder delegates to the new applyScopedMoves helper. pin: brand-new polymorphic table `(id, entityType, entityId, orderKey, timestamps)` with UNIQUE(entityType, entityId) enforcing idempotency at the DB layer. One table serves arbitrarily many consumers — same precedent as entity_tag. No FK to consumer tables; every consumer service's delete path must call `pinService.purgeForEntity(tx, entityType, entityId)` to keep the two tables in sync (signature is tx-first, the mainstream ORM convention; tagService.removeEntityTags is the project's historical tx-last outlier and is left as-is). PinService.pin is idempotent and concurrent-safe: a fast-path SELECT returns existing rows, and a UNIQUE collision under concurrent INSERT is caught, classified, and re-SELECTed so the caller never sees a constraint error. Handler layer is a thin Zod-parse shell — all scope inference, row lookup, and orderKey computation live in the services. --- migrations/sqlite-drizzle/0014_hot_ultron.sql | 15 + .../sqlite-drizzle/meta/0014_snapshot.json | 3032 +++++++++++++++++ migrations/sqlite-drizzle/meta/_journal.json | 7 + packages/shared/data/api/schemas/groups.ts | 97 + packages/shared/data/api/schemas/index.ts | 6 +- packages/shared/data/api/schemas/pins.ts | 86 + packages/shared/data/types/group.ts | 37 + packages/shared/data/types/pin.ts | 36 + .../api/handlers/__tests__/groups.test.ts | 213 ++ .../data/api/handlers/__tests__/pins.test.ts | 200 ++ src/main/data/api/handlers/groups.ts | 78 + src/main/data/api/handlers/index.ts | 6 +- src/main/data/api/handlers/pins.ts | 66 + src/main/data/db/schemas/group.ts | 17 +- src/main/data/db/schemas/pin.ts | 42 + src/main/data/services/GroupService.ts | 164 + src/main/data/services/PinService.ts | 193 ++ .../services/__tests__/GroupService.test.ts | 208 ++ .../services/__tests__/PinService.test.ts | 241 ++ 19 files changed, 4734 insertions(+), 10 deletions(-) create mode 100644 migrations/sqlite-drizzle/0014_hot_ultron.sql create mode 100644 migrations/sqlite-drizzle/meta/0014_snapshot.json create mode 100644 packages/shared/data/api/schemas/groups.ts create mode 100644 packages/shared/data/api/schemas/pins.ts create mode 100644 packages/shared/data/types/group.ts create mode 100644 packages/shared/data/types/pin.ts create mode 100644 src/main/data/api/handlers/__tests__/groups.test.ts create mode 100644 src/main/data/api/handlers/__tests__/pins.test.ts create mode 100644 src/main/data/api/handlers/groups.ts create mode 100644 src/main/data/api/handlers/pins.ts create mode 100644 src/main/data/db/schemas/pin.ts create mode 100644 src/main/data/services/GroupService.ts create mode 100644 src/main/data/services/PinService.ts create mode 100644 src/main/data/services/__tests__/GroupService.test.ts create mode 100644 src/main/data/services/__tests__/PinService.test.ts diff --git a/migrations/sqlite-drizzle/0014_hot_ultron.sql b/migrations/sqlite-drizzle/0014_hot_ultron.sql new file mode 100644 index 0000000000..e04875f0a0 --- /dev/null +++ b/migrations/sqlite-drizzle/0014_hot_ultron.sql @@ -0,0 +1,15 @@ +CREATE TABLE `pin` ( + `id` text PRIMARY KEY NOT NULL, + `entity_type` text NOT NULL, + `entity_id` text NOT NULL, + `order_key` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `pin_entity_type_entity_id_unique_idx` ON `pin` (`entity_type`,`entity_id`);--> statement-breakpoint +CREATE INDEX `pin_entity_type_order_key_idx` ON `pin` (`entity_type`,`order_key`);--> statement-breakpoint +DROP INDEX `group_entity_sort_idx`;--> statement-breakpoint +ALTER TABLE `group` ADD `order_key` text NOT NULL;--> statement-breakpoint +CREATE INDEX `group_entity_type_order_key_idx` ON `group` (`entity_type`,`order_key`);--> statement-breakpoint +ALTER TABLE `group` DROP COLUMN `sort_order`; \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0014_snapshot.json b/migrations/sqlite-drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000000..4bff4f5137 --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0014_snapshot.json @@ -0,0 +1,3032 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5ed1efb9-bd34-437e-8762-2adfdc9b6816", + "prevId": "50459d64-3954-481e-8a6e-8d71994960b7", + "tables": { + "agent": { + "name": "agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agent_name_idx": { + "name": "agent_name_idx", + "columns": ["name"], + "isUnique": false + }, + "agent_type_idx": { + "name": "agent_type_idx", + "columns": ["type"], + "isUnique": false + }, + "agent_sort_order_idx": { + "name": "agent_sort_order_idx", + "columns": ["sort_order"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_channel": { + "name": "agent_channel", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "active_chat_ids": { + "name": "active_chat_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_channel_agent_id_idx": { + "name": "agent_channel_agent_id_idx", + "columns": ["agent_id"], + "isUnique": false + }, + "agent_channel_type_idx": { + "name": "agent_channel_type_idx", + "columns": ["type"], + "isUnique": false + }, + "agent_channel_session_id_idx": { + "name": "agent_channel_session_id_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_channel_agent_id_agent_id_fk": { + "name": "agent_channel_agent_id_agent_id_fk", + "tableFrom": "agent_channel", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agent_channel_session_id_agent_session_id_fk": { + "name": "agent_channel_session_id_agent_session_id_fk", + "tableFrom": "agent_channel", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "agent_channel_type_check": { + "name": "agent_channel_type_check", + "value": "\"agent_channel\".\"type\" IN ('telegram', 'feishu', 'qq', 'wechat', 'discord', 'slack')" + }, + "agent_channel_permission_mode_check": { + "name": "agent_channel_permission_mode_check", + "value": "\"agent_channel\".\"permission_mode\" IS NULL OR \"agent_channel\".\"permission_mode\" IN ('default', 'acceptEdits', 'bypassPermissions', 'plan')" + } + } + }, + "agent_channel_task": { + "name": "agent_channel_task", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_channel_task_channel_id_idx": { + "name": "agent_channel_task_channel_id_idx", + "columns": ["channel_id"], + "isUnique": false + }, + "agent_channel_task_task_id_idx": { + "name": "agent_channel_task_task_id_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_channel_task_channel_id_agent_channel_id_fk": { + "name": "agent_channel_task_channel_id_agent_channel_id_fk", + "tableFrom": "agent_channel_task", + "tableTo": "agent_channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_channel_task_task_id_agent_task_id_fk": { + "name": "agent_channel_task_task_id_agent_task_id_fk", + "tableFrom": "agent_channel_task", + "tableTo": "agent_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_channel_task_channel_id_task_id_pk": { + "columns": ["channel_id", "task_id"], + "name": "agent_channel_task_channel_id_task_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_global_skill": { + "name": "agent_global_skill", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_name": { + "name": "folder_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_global_skill_folder_name_unique": { + "name": "agent_global_skill_folder_name_unique", + "columns": ["folder_name"], + "isUnique": true + }, + "agent_global_skill_source_idx": { + "name": "agent_global_skill_source_idx", + "columns": ["source"], + "isUnique": false + }, + "agent_global_skill_is_enabled_idx": { + "name": "agent_global_skill_is_enabled_idx", + "columns": ["is_enabled"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_session": { + "name": "agent_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slash_commands": { + "name": "slash_commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_session_agent_id_idx": { + "name": "agent_session_agent_id_idx", + "columns": ["agent_id"], + "isUnique": false + }, + "agent_session_model_idx": { + "name": "agent_session_model_idx", + "columns": ["model"], + "isUnique": false + }, + "agent_session_sort_order_idx": { + "name": "agent_session_sort_order_idx", + "columns": ["sort_order"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_session_agent_id_agent_id_fk": { + "name": "agent_session_agent_id_agent_id_fk", + "tableFrom": "agent_session", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_session_message": { + "name": "agent_session_message", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_session_message_session_id_idx": { + "name": "agent_session_message_session_id_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_session_message_session_id_agent_session_id_fk": { + "name": "agent_session_message_session_id_agent_session_id_fk", + "tableFrom": "agent_session_message", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_skill": { + "name": "agent_skill", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_skill_agent_id_idx": { + "name": "agent_skill_agent_id_idx", + "columns": ["agent_id"], + "isUnique": false + }, + "agent_skill_skill_id_idx": { + "name": "agent_skill_skill_id_idx", + "columns": ["skill_id"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_skill_agent_id_agent_id_fk": { + "name": "agent_skill_agent_id_agent_id_fk", + "tableFrom": "agent_skill", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_skill_skill_id_agent_global_skill_id_fk": { + "name": "agent_skill_skill_id_agent_global_skill_id_fk", + "tableFrom": "agent_skill", + "tableTo": "agent_global_skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_skill_agent_id_skill_id_pk": { + "columns": ["agent_id", "skill_id"], + "name": "agent_skill_agent_id_skill_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_task_run_log": { + "name": "agent_task_run_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_task_run_log_task_id_idx": { + "name": "agent_task_run_log_task_id_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_task_run_log_task_id_agent_task_id_fk": { + "name": "agent_task_run_log_task_id_agent_task_id_fk", + "tableFrom": "agent_task_run_log", + "tableTo": "agent_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "agent_task_run_log_status_check": { + "name": "agent_task_run_log_status_check", + "value": "\"agent_task_run_log\".\"status\" IN ('running', 'success', 'error')" + } + } + }, + "agent_task": { + "name": "agent_task", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_value": { + "name": "schedule_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timeout_minutes": { + "name": "timeout_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "next_run": { + "name": "next_run", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run": { + "name": "last_run", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_task_agent_id_idx": { + "name": "agent_task_agent_id_idx", + "columns": ["agent_id"], + "isUnique": false + }, + "agent_task_next_run_idx": { + "name": "agent_task_next_run_idx", + "columns": ["next_run"], + "isUnique": false + }, + "agent_task_status_idx": { + "name": "agent_task_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": { + "agent_task_agent_id_agent_id_fk": { + "name": "agent_task_agent_id_agent_id_fk", + "tableFrom": "agent_task", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "agent_task_schedule_type_check": { + "name": "agent_task_schedule_type_check", + "value": "\"agent_task\".\"schedule_type\" IN ('cron', 'interval', 'once')" + }, + "agent_task_status_check": { + "name": "agent_task_status_check", + "value": "\"agent_task\".\"status\" IN ('active', 'paused', 'completed')" + } + } + }, + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant": { + "name": "assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "assistant_created_at_idx": { + "name": "assistant_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "assistant_model_id_user_model_id_fk": { + "name": "assistant_model_id_user_model_id_fk", + "tableFrom": "assistant", + "tableTo": "user_model", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant_knowledge_base": { + "name": "assistant_knowledge_base", + "columns": { + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "assistant_knowledge_base_assistant_id_assistant_id_fk": { + "name": "assistant_knowledge_base_assistant_id_assistant_id_fk", + "tableFrom": "assistant_knowledge_base", + "tableTo": "assistant", + "columnsFrom": ["assistant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assistant_knowledge_base_knowledge_base_id_knowledge_base_id_fk": { + "name": "assistant_knowledge_base_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "assistant_knowledge_base", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assistant_knowledge_base_assistant_id_knowledge_base_id_pk": { + "columns": ["assistant_id", "knowledge_base_id"], + "name": "assistant_knowledge_base_assistant_id_knowledge_base_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant_mcp_server": { + "name": "assistant_mcp_server", + "columns": { + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "assistant_mcp_server_assistant_id_assistant_id_fk": { + "name": "assistant_mcp_server_assistant_id_assistant_id_fk", + "tableFrom": "assistant_mcp_server", + "tableTo": "assistant", + "columnsFrom": ["assistant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assistant_mcp_server_mcp_server_id_mcp_server_id_fk": { + "name": "assistant_mcp_server_mcp_server_id_mcp_server_id_fk", + "tableFrom": "assistant_mcp_server", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assistant_mcp_server_assistant_id_mcp_server_id_pk": { + "columns": ["assistant_id", "mcp_server_id"], + "name": "assistant_mcp_server_assistant_id_mcp_server_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_entity_type_order_key_idx": { + "name": "group_entity_type_order_key_idx", + "columns": ["entity_type", "order_key"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "knowledge_base": { + "name": "knowledge_base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dimensions": { + "name": "dimensions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embedding_model_id": { + "name": "embedding_model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rerank_model_id": { + "name": "rerank_model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_processor_id": { + "name": "file_processor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chunk_size": { + "name": "chunk_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chunk_overlap": { + "name": "chunk_overlap", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "threshold": { + "name": "threshold", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_count": { + "name": "document_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_mode": { + "name": "search_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hybrid_alpha": { + "name": "hybrid_alpha", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "knowledge_base_embedding_model_id_user_model_id_fk": { + "name": "knowledge_base_embedding_model_id_user_model_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user_model", + "columnsFrom": ["embedding_model_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "knowledge_base_rerank_model_id_user_model_id_fk": { + "name": "knowledge_base_rerank_model_id_user_model_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user_model", + "columnsFrom": ["rerank_model_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "knowledge_base_search_mode_check": { + "name": "knowledge_base_search_mode_check", + "value": "\"knowledge_base\".\"search_mode\" IN ('default', 'bm25', 'hybrid') OR \"knowledge_base\".\"search_mode\" IS NULL" + } + } + }, + "knowledge_item": { + "name": "knowledge_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "base_id": { + "name": "base_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_item_base_type_created_idx": { + "name": "knowledge_item_base_type_created_idx", + "columns": ["base_id", "type", "created_at"], + "isUnique": false + }, + "knowledge_item_base_group_created_idx": { + "name": "knowledge_item_base_group_created_idx", + "columns": ["base_id", "group_id", "created_at"], + "isUnique": false + }, + "knowledge_item_baseId_id_unique": { + "name": "knowledge_item_baseId_id_unique", + "columns": ["base_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "knowledge_item_base_id_knowledge_base_id_fk": { + "name": "knowledge_item_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_item", + "tableTo": "knowledge_base", + "columnsFrom": ["base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_item_base_id_group_id_knowledge_item_base_id_id_fk": { + "name": "knowledge_item_base_id_group_id_knowledge_item_base_id_id_fk", + "tableFrom": "knowledge_item", + "tableTo": "knowledge_item", + "columnsFrom": ["base_id", "group_id"], + "columnsTo": ["base_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "knowledge_item_type_check": { + "name": "knowledge_item_type_check", + "value": "\"knowledge_item\".\"type\" IN ('file', 'url', 'note', 'sitemap', 'directory')" + }, + "knowledge_item_status_check": { + "name": "knowledge_item_status_check", + "value": "\"knowledge_item\".\"status\" IN ('idle', 'pending', 'file_processing', 'read', 'embed', 'completed', 'failed')" + } + } + }, + "mcp_server": { + "name": "mcp_server", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registry_url": { + "name": "registry_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_url": { + "name": "provider_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "long_running": { + "name": "long_running", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dxt_version": { + "name": "dxt_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dxt_path": { + "name": "dxt_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_key": { + "name": "search_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config_sample": { + "name": "config_sample", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled_auto_approve_tools": { + "name": "disabled_auto_approve_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "should_config": { + "name": "should_config", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "install_source": { + "name": "install_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_trusted": { + "name": "is_trusted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trusted_at": { + "name": "trusted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_server_name_idx": { + "name": "mcp_server_name_idx", + "columns": ["name"], + "isUnique": false + }, + "mcp_server_is_active_idx": { + "name": "mcp_server_is_active_idx", + "columns": ["is_active"], + "isUnique": false + }, + "mcp_server_sort_order_idx": { + "name": "mcp_server_sort_order_idx", + "columns": ["sort_order"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "mcp_server_type_check": { + "name": "mcp_server_type_check", + "value": "\"mcp_server\".\"type\" IS NULL OR \"mcp_server\".\"type\" IN ('stdio', 'sse', 'streamableHttp', 'inMemory')" + }, + "mcp_server_install_source_check": { + "name": "mcp_server_install_source_check", + "value": "\"mcp_server\".\"install_source\" IS NULL OR \"mcp_server\".\"install_source\" IN ('builtin', 'manual', 'protocol', 'unknown')" + } + } + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "siblings_group_id": { + "name": "siblings_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_snapshot": { + "name": "model_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": ["topic_id", "created_at"], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": ["trace_id"], + "isUnique": false + } + }, + "foreignKeys": { + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_model_id_user_model_id_fk": { + "name": "message_model_id_user_model_id_fk", + "tableFrom": "message", + "tableTo": "user_model", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')" + } + } + }, + "miniapp": { + "name": "miniapp", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'enabled'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bordered": { + "name": "bordered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supported_regions": { + "name": "supported_regions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_key": { + "name": "name_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "miniapp_status_sort_idx": { + "name": "miniapp_status_sort_idx", + "columns": ["status", "sort_order"], + "isUnique": false + }, + "miniapp_type_idx": { + "name": "miniapp_type_idx", + "columns": ["type"], + "isUnique": false + }, + "miniapp_status_type_idx": { + "name": "miniapp_status_type_idx", + "columns": ["status", "type"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "miniapp_status_check": { + "name": "miniapp_status_check", + "value": "\"miniapp\".\"status\" IN ('enabled', 'disabled', 'pinned')" + }, + "miniapp_type_check": { + "name": "miniapp_type_check", + "value": "\"miniapp\".\"type\" IN ('default', 'custom')" + } + } + }, + "pin": { + "name": "pin", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pin_entity_type_entity_id_unique_idx": { + "name": "pin_entity_type_entity_id_unique_idx", + "columns": ["entity_type", "entity_id"], + "isUnique": true + }, + "pin_entity_type_order_key_idx": { + "name": "pin_entity_type_order_key_idx", + "columns": ["entity_type", "order_key"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preference": { + "name": "preference", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": ["scope", "key"], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": ["tag_id"], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": ["entity_type", "entity_id", "tag_id"], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_node_id": { + "name": "active_node_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": ["group_id", "updated_at"], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": ["group_id", "sort_order"], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": ["is_pinned", "pinned_order"], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": ["assistant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "topic_assistant_id_assistant_id_fk": { + "name": "topic_assistant_id_assistant_id_fk", + "tableFrom": "topic", + "tableTo": "assistant", + "columnsFrom": ["assistant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translate_history": { + "name": "translate_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_text": { + "name": "source_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_text": { + "name": "target_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_language": { + "name": "source_language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_language": { + "name": "target_language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "star": { + "name": "star", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translate_history_created_at_idx": { + "name": "translate_history_created_at_idx", + "columns": ["created_at"], + "isUnique": false + }, + "translate_history_star_created_at_idx": { + "name": "translate_history_star_created_at_idx", + "columns": ["star", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "translate_history_source_language_translate_language_lang_code_fk": { + "name": "translate_history_source_language_translate_language_lang_code_fk", + "tableFrom": "translate_history", + "tableTo": "translate_language", + "columnsFrom": ["source_language"], + "columnsTo": ["lang_code"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "translate_history_target_language_translate_language_lang_code_fk": { + "name": "translate_history_target_language_translate_language_lang_code_fk", + "tableFrom": "translate_history", + "tableTo": "translate_language", + "columnsFrom": ["target_language"], + "columnsTo": ["lang_code"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translate_language": { + "name": "translate_language", + "columns": { + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_model": { + "name": "user_model", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "preset_model_id": { + "name": "preset_model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_modalities": { + "name": "output_modalities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_types": { + "name": "endpoint_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_endpoint_url": { + "name": "custom_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supports_streaming": { + "name": "supports_streaming", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pricing": { + "name": "pricing", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_hidden": { + "name": "is_hidden", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_deprecated": { + "name": "is_deprecated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_overrides": { + "name": "user_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_model_preset_idx": { + "name": "user_model_preset_idx", + "columns": ["preset_model_id"], + "isUnique": false + }, + "user_model_provider_enabled_idx": { + "name": "user_model_provider_enabled_idx", + "columns": ["provider_id", "is_enabled"], + "isUnique": false + }, + "user_model_provider_sort_idx": { + "name": "user_model_provider_sort_idx", + "columns": ["provider_id", "sort_order"], + "isUnique": false + }, + "user_model_provider_model_unique": { + "name": "user_model_provider_model_unique", + "columns": ["provider_id", "model_id"], + "isUnique": true + } + }, + "foreignKeys": { + "user_model_provider_id_user_provider_provider_id_fk": { + "name": "user_model_provider_id_user_provider_provider_id_fk", + "tableFrom": "user_model", + "tableTo": "user_provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["provider_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_provider": { + "name": "user_provider", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_provider_id": { + "name": "preset_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint_configs": { + "name": "endpoint_configs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_chat_endpoint": { + "name": "default_chat_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_keys": { + "name": "api_keys", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "auth_config": { + "name": "auth_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_features": { + "name": "api_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_settings": { + "name": "provider_settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_provider_preset_idx": { + "name": "user_provider_preset_idx", + "columns": ["preset_provider_id"], + "isUnique": false + }, + "user_provider_enabled_sort_idx": { + "name": "user_provider_enabled_sort_idx", + "columns": ["is_enabled", "sort_order"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index bc1b6c4190..bc980b03a3 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -98,6 +98,13 @@ "when": 1776744800060, "tag": "0013_fantastic_joystick", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1776838196467, + "tag": "0014_hot_ultron", + "breakpoints": true } ], "version": "7" diff --git a/packages/shared/data/api/schemas/groups.ts b/packages/shared/data/api/schemas/groups.ts new file mode 100644 index 0000000000..d3bb3ec650 --- /dev/null +++ b/packages/shared/data/api/schemas/groups.ts @@ -0,0 +1,97 @@ +/** + * Group API Schema definitions + * + * Contains endpoints for Group CRUD and scoped reorder operations. + * Entity schemas and types live in `@shared/data/types/group`. + */ + +import * as z from 'zod' + +import { EntityTypeSchema } from '../../types/entityType' +import { type Group, GroupIdSchema as SharedGroupIdSchema, GroupNameSchema } from '../../types/group' +import type { OrderEndpoints } from './_endpointHelpers' + +export const GroupIdSchema = SharedGroupIdSchema + +// ============================================================================ +// DTOs +// ============================================================================ + +/** + * DTO for creating a new group. + * `entityType` is locked at creation time (read-only afterwards). + */ +export const CreateGroupDtoSchema = z.object({ + entityType: EntityTypeSchema, + name: GroupNameSchema +}) +export type CreateGroupDto = z.infer + +/** + * DTO for updating an existing group. Only `name` is mutable. + */ +export const UpdateGroupDtoSchema = z.object({ + name: GroupNameSchema.optional() +}) +export type UpdateGroupDto = z.infer + +/** + * Query params for `GET /groups`. `entityType` is required — listing across + * entity types has no business use case. + */ +export const ListGroupsQuerySchema = z.object({ + entityType: EntityTypeSchema +}) +export type ListGroupsQuery = z.infer + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * Group API Schema definitions + */ +export type GroupSchemas = { + /** + * Groups collection endpoint + * @example GET /groups?entityType=topic + * @example POST /groups { "entityType": "topic", "name": "Research" } + */ + '/groups': { + /** List groups within a given entityType, ordered by orderKey */ + GET: { + query: ListGroupsQuery + response: Group[] + } + /** Create a new group; appended to the end of its entityType bucket */ + POST: { + body: CreateGroupDto + response: Group + } + } + + /** + * Individual group endpoint + * @example GET /groups/abc123 + * @example PATCH /groups/abc123 { "name": "Renamed" } + * @example DELETE /groups/abc123 + */ + '/groups/:id': { + /** Get a group by ID */ + GET: { + params: { id: string } + response: Group + } + /** Update a group's mutable fields */ + PATCH: { + params: { id: string } + body: UpdateGroupDto + response: Group + } + /** Delete a group */ + DELETE: { + params: { id: string } + response: void + } + } +} & OrderEndpoints<'/groups'> diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts index a138f91b49..04de1fb0b6 100644 --- a/packages/shared/data/api/schemas/index.ts +++ b/packages/shared/data/api/schemas/index.ts @@ -22,11 +22,13 @@ import type { AssertValidSchemas } from '../apiTypes' import type { AssistantSchemas } from './assistants' import type { FileProcessingSchemas } from './fileProcessing' +import type { GroupSchemas } from './groups' import type { KnowledgeSchemas } from './knowledges' import type { MCPServerSchemas } from './mcpServers' import type { MessageSchemas } from './messages' import type { MiniappSchemas } from './miniapps' import type { ModelSchemas } from './models' +import type { PinSchemas } from './pins' import type { ProviderSchemas } from './providers' import type { TagSchemas } from './tags' import type { TemporaryChatSchemas } from './temporaryChats' @@ -58,5 +60,7 @@ export type ApiSchemas = AssertValidSchemas< KnowledgeSchemas & MiniappSchemas & AssistantSchemas & - TagSchemas + TagSchemas & + GroupSchemas & + PinSchemas > diff --git a/packages/shared/data/api/schemas/pins.ts b/packages/shared/data/api/schemas/pins.ts new file mode 100644 index 0000000000..1606727015 --- /dev/null +++ b/packages/shared/data/api/schemas/pins.ts @@ -0,0 +1,86 @@ +/** + * Pin API Schema definitions + * + * Contains endpoints for Pin CRUD and scoped reorder operations. + * Entity schemas and types live in `@shared/data/types/pin`. + * + * Note: there is no PATCH on `/pins/:id` — pins have no mutable business + * fields. `entityType` / `entityId` are immutable after creation, timestamps + * are auto, and `id` is auto. + */ + +import * as z from 'zod' + +import { EntityTypeSchema } from '../../types/entityType' +import { type Pin, PinIdSchema as SharedPinIdSchema } from '../../types/pin' +import type { OrderEndpoints } from './_endpointHelpers' + +export const PinIdSchema = SharedPinIdSchema + +// ============================================================================ +// DTOs +// ============================================================================ + +/** + * DTO for creating (or re-using) a pin. Idempotent: when a pin already exists + * for the same (entityType, entityId) the service returns the existing row. + */ +export const CreatePinDtoSchema = z.object({ + entityType: EntityTypeSchema, + entityId: z.uuidv4() +}) +export type CreatePinDto = z.infer + +/** + * Query params for `GET /pins`. `entityType` is required — listing across + * entity types has no business use case. + */ +export const ListPinsQuerySchema = z.object({ + entityType: EntityTypeSchema +}) +export type ListPinsQuery = z.infer + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * Pin API Schema definitions + */ +export type PinSchemas = { + /** + * Pins collection endpoint + * @example GET /pins?entityType=topic + * @example POST /pins { "entityType": "topic", "entityId": "..." } + */ + '/pins': { + /** List pins within a given entityType, ordered by orderKey */ + GET: { + query: ListPinsQuery + response: Pin[] + } + /** Idempotent pin: returns the existing row when already pinned */ + POST: { + body: CreatePinDto + response: Pin + } + } + + /** + * Individual pin endpoint + * @example GET /pins/abc123 + * @example DELETE /pins/abc123 + */ + '/pins/:id': { + /** Get a pin by ID */ + GET: { + params: { id: string } + response: Pin + } + /** Unpin (hard delete by pin id) */ + DELETE: { + params: { id: string } + response: void + } + } +} & OrderEndpoints<'/pins'> diff --git a/packages/shared/data/types/group.ts b/packages/shared/data/types/group.ts new file mode 100644 index 0000000000..c9729b9001 --- /dev/null +++ b/packages/shared/data/types/group.ts @@ -0,0 +1,37 @@ +/** + * Group entity types + * + * Groups are user-managed flat containers that organize entities (assistants, + * topics, sessions) under a chosen entityType. Ordering within an entityType is + * preserved via a fractional-indexing `orderKey`. + */ + +import * as z from 'zod' + +import { EntityTypeSchema } from './entityType' + +// ============================================================================ +// Group Entity +// ============================================================================ + +export const GroupIdSchema = z.uuidv4() +export const GroupNameSchema = z.string().trim().min(1).max(64) + +/** + * Complete Group entity as returned by the API. + */ +export const GroupSchema = z.object({ + /** Group ID (UUID v4, auto-generated by database) */ + id: GroupIdSchema, + /** Entity type this group belongs to (tag / group / pin share the enum) */ + entityType: EntityTypeSchema, + /** Display name of the group */ + name: GroupNameSchema, + /** Fractional-indexing order key within this entityType */ + orderKey: z.string().min(1), + /** Creation timestamp (ISO string) */ + createdAt: z.iso.datetime(), + /** Last update timestamp (ISO string) */ + updatedAt: z.iso.datetime() +}) +export type Group = z.infer diff --git a/packages/shared/data/types/pin.ts b/packages/shared/data/types/pin.ts new file mode 100644 index 0000000000..f6f71a3fbc --- /dev/null +++ b/packages/shared/data/types/pin.ts @@ -0,0 +1,36 @@ +/** + * Pin entity types + * + * Pins are a polymorphic non-destructive "promote to top" marker. Any entity + * type participating in the shared `EntityType` enum can be pinned by its id. + * Ordering is preserved per entityType via a fractional-indexing `orderKey`. + */ + +import * as z from 'zod' + +import { EntityTypeSchema } from './entityType' + +// ============================================================================ +// Pin Entity +// ============================================================================ + +export const PinIdSchema = z.uuidv4() + +/** + * Complete Pin entity as returned by the API. + */ +export const PinSchema = z.object({ + /** Pin ID (UUID v4, auto-generated by database) */ + id: PinIdSchema, + /** Entity type this pin targets (tag / group / pin share the enum) */ + entityType: EntityTypeSchema, + /** Target entity id (UUID v4) */ + entityId: z.uuidv4(), + /** Fractional-indexing order key within this entityType */ + orderKey: z.string().min(1), + /** Creation timestamp (ISO string) */ + createdAt: z.iso.datetime(), + /** Last update timestamp (ISO string) */ + updatedAt: z.iso.datetime() +}) +export type Pin = z.infer diff --git a/src/main/data/api/handlers/__tests__/groups.test.ts b/src/main/data/api/handlers/__tests__/groups.test.ts new file mode 100644 index 0000000000..5d44f3ec75 --- /dev/null +++ b/src/main/data/api/handlers/__tests__/groups.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { listByEntityTypeMock, createMock, getByIdMock, updateMock, deleteMock, reorderMock, reorderBatchMock } = + vi.hoisted(() => ({ + listByEntityTypeMock: vi.fn(), + createMock: vi.fn(), + getByIdMock: vi.fn(), + updateMock: vi.fn(), + deleteMock: vi.fn(), + reorderMock: vi.fn(), + reorderBatchMock: vi.fn() + })) + +vi.mock('@data/services/GroupService', () => ({ + groupService: { + listByEntityType: listByEntityTypeMock, + create: createMock, + getById: getByIdMock, + update: updateMock, + delete: deleteMock, + reorder: reorderMock, + reorderBatch: reorderBatchMock + } +})) + +import { groupHandlers } from '../groups' + +const GROUP_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_GROUP_ID = '22222222-2222-4222-8222-222222222222' + +describe('groupHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('/groups GET', () => { + it('should delegate GET to groupService.listByEntityType', async () => { + listByEntityTypeMock.mockResolvedValueOnce([{ id: 'g1', entityType: 'topic', name: 'A' }]) + + const result = await groupHandlers['/groups'].GET({ + query: { entityType: 'topic' } + } as never) + + expect(listByEntityTypeMock).toHaveBeenCalledWith('topic') + expect(result).toEqual([{ id: 'g1', entityType: 'topic', name: 'A' }]) + }) + + it('should reject a missing entityType query param with ZodError', async () => { + await expect(groupHandlers['/groups'].GET({ query: {} } as never)).rejects.toHaveProperty('name', 'ZodError') + + expect(listByEntityTypeMock).not.toHaveBeenCalled() + }) + + it('should reject an unknown entityType query value with ZodError', async () => { + await expect(groupHandlers['/groups'].GET({ query: { entityType: 'invalid' } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + + expect(listByEntityTypeMock).not.toHaveBeenCalled() + }) + }) + + describe('/groups POST', () => { + it('should parse POST body and call create', async () => { + createMock.mockResolvedValueOnce({ id: 'g1', entityType: 'topic', name: 'Research' }) + + await expect( + groupHandlers['/groups'].POST({ + body: { entityType: 'topic', name: 'Research' } + } as never) + ).resolves.toMatchObject({ id: 'g1' }) + + expect(createMock).toHaveBeenCalledWith({ entityType: 'topic', name: 'Research' }) + }) + + it('should reject unknown entityType in POST body with ZodError', async () => { + await expect( + groupHandlers['/groups'].POST({ + body: { entityType: 'bogus', name: 'Research' } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + + expect(createMock).not.toHaveBeenCalled() + }) + + it('should reject empty names in POST body with ZodError', async () => { + await expect( + groupHandlers['/groups'].POST({ + body: { entityType: 'topic', name: '' } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + + expect(createMock).not.toHaveBeenCalled() + }) + }) + + describe('/groups/:id', () => { + it('should parse path id and body for GET / PATCH / DELETE', async () => { + getByIdMock.mockResolvedValueOnce({ id: 'g1', entityType: 'topic', name: 'A' }) + updateMock.mockResolvedValueOnce({ id: 'g1', entityType: 'topic', name: 'Renamed' }) + deleteMock.mockResolvedValueOnce(undefined) + + await expect(groupHandlers['/groups/:id'].GET({ params: { id: GROUP_ID } } as never)).resolves.toMatchObject({ + id: 'g1' + }) + + await expect( + groupHandlers['/groups/:id'].PATCH({ + params: { id: GROUP_ID }, + body: { name: 'Renamed' } + } as never) + ).resolves.toMatchObject({ name: 'Renamed' }) + + await expect(groupHandlers['/groups/:id'].DELETE({ params: { id: GROUP_ID } } as never)).resolves.toBeUndefined() + + expect(getByIdMock).toHaveBeenCalledWith(GROUP_ID) + expect(updateMock).toHaveBeenCalledWith(GROUP_ID, { name: 'Renamed' }) + expect(deleteMock).toHaveBeenCalledWith(GROUP_ID) + }) + + it('should reject an invalid group id in path params with ZodError', async () => { + await expect(groupHandlers['/groups/:id'].GET({ params: { id: 'not-a-uuid' } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + + expect(getByIdMock).not.toHaveBeenCalled() + }) + + it('should reject an invalid PATCH body (name type wrong) with ZodError', async () => { + await expect( + groupHandlers['/groups/:id'].PATCH({ + params: { id: GROUP_ID }, + body: { name: 123 } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + + expect(updateMock).not.toHaveBeenCalled() + }) + }) + + describe('/groups/:id/order', () => { + it('should delegate a valid anchor body to groupService.reorder', async () => { + reorderMock.mockResolvedValueOnce(undefined) + + await expect( + groupHandlers['/groups/:id/order'].PATCH({ + params: { id: GROUP_ID }, + body: { position: 'first' } + } as never) + ).resolves.toBeUndefined() + + expect(reorderMock).toHaveBeenCalledWith(GROUP_ID, { position: 'first' }) + }) + + it('should reject an invalid anchor body with ZodError', async () => { + await expect( + groupHandlers['/groups/:id/order'].PATCH({ + params: { id: GROUP_ID }, + body: { position: 'middle' } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + + expect(reorderMock).not.toHaveBeenCalled() + }) + + it('should reject when id is not a valid uuid', async () => { + await expect( + groupHandlers['/groups/:id/order'].PATCH({ + params: { id: 'not-a-uuid' }, + body: { position: 'first' } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + + expect(reorderMock).not.toHaveBeenCalled() + }) + }) + + describe('/groups/order:batch', () => { + it('should delegate a valid batch body to groupService.reorderBatch', async () => { + reorderBatchMock.mockResolvedValueOnce(undefined) + + const moves = [ + { id: GROUP_ID, anchor: { position: 'first' } }, + { id: OTHER_GROUP_ID, anchor: { after: GROUP_ID } } + ] + + await expect(groupHandlers['/groups/order:batch'].PATCH({ body: { moves } } as never)).resolves.toBeUndefined() + + expect(reorderBatchMock).toHaveBeenCalledWith(moves) + }) + + it('should reject an empty moves array with ZodError', async () => { + await expect(groupHandlers['/groups/order:batch'].PATCH({ body: { moves: [] } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + + expect(reorderBatchMock).not.toHaveBeenCalled() + }) + + it('should reject a malformed move entry with ZodError', async () => { + await expect( + groupHandlers['/groups/order:batch'].PATCH({ + body: { moves: [{ id: '', anchor: { position: 'first' } }] } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + + expect(reorderBatchMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/data/api/handlers/__tests__/pins.test.ts b/src/main/data/api/handlers/__tests__/pins.test.ts new file mode 100644 index 0000000000..58a4ee8cb5 --- /dev/null +++ b/src/main/data/api/handlers/__tests__/pins.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { listByEntityTypeMock, getByIdMock, pinMock, unpinMock, reorderMock, reorderBatchMock } = vi.hoisted(() => ({ + listByEntityTypeMock: vi.fn(), + getByIdMock: vi.fn(), + pinMock: vi.fn(), + unpinMock: vi.fn(), + reorderMock: vi.fn(), + reorderBatchMock: vi.fn() +})) + +vi.mock('@data/services/PinService', () => ({ + pinService: { + listByEntityType: listByEntityTypeMock, + getById: getByIdMock, + pin: pinMock, + unpin: unpinMock, + reorder: reorderMock, + reorderBatch: reorderBatchMock + } +})) + +import { pinHandlers } from '../pins' + +const PIN_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_PIN_ID = '22222222-2222-4222-8222-222222222222' +const ENTITY_ID = '33333333-3333-4333-8333-333333333333' + +describe('pinHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('/pins', () => { + it('should delegate GET to pinService.listByEntityType with the parsed entityType', async () => { + listByEntityTypeMock.mockResolvedValueOnce([{ id: PIN_ID, entityType: 'topic', entityId: ENTITY_ID }]) + + const result = await pinHandlers['/pins'].GET({ + query: { entityType: 'topic' } + } as never) + + expect(listByEntityTypeMock).toHaveBeenCalledWith('topic') + expect(result).toEqual([{ id: PIN_ID, entityType: 'topic', entityId: ENTITY_ID }]) + }) + + it('should reject GET when query.entityType is missing', async () => { + await expect(pinHandlers['/pins'].GET({ query: {} } as never)).rejects.toHaveProperty('name', 'ZodError') + expect(listByEntityTypeMock).not.toHaveBeenCalled() + }) + + it('should reject GET when query.entityType is not a known enum value', async () => { + await expect(pinHandlers['/pins'].GET({ query: { entityType: 'unknown' } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + expect(listByEntityTypeMock).not.toHaveBeenCalled() + }) + + it('should delegate POST with parsed body (idempotency returns the same row on repeat calls)', async () => { + const row = { + id: PIN_ID, + entityType: 'topic', + entityId: ENTITY_ID, + orderKey: 'a0' + } + pinMock.mockResolvedValue(row) + + const firstCall = { body: { entityType: 'topic', entityId: ENTITY_ID } } + await expect(pinHandlers['/pins'].POST(firstCall as never)).resolves.toMatchObject({ id: PIN_ID }) + await expect(pinHandlers['/pins'].POST(firstCall as never)).resolves.toMatchObject({ id: PIN_ID }) + + expect(pinMock).toHaveBeenNthCalledWith(1, { entityType: 'topic', entityId: ENTITY_ID }) + expect(pinMock).toHaveBeenNthCalledWith(2, { entityType: 'topic', entityId: ENTITY_ID }) + }) + + it('should reject POST with an invalid entityType before calling the service', async () => { + await expect( + pinHandlers['/pins'].POST({ + body: { entityType: 'bogus', entityId: ENTITY_ID } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + expect(pinMock).not.toHaveBeenCalled() + }) + + it('should reject POST with an invalid entityId before calling the service', async () => { + await expect( + pinHandlers['/pins'].POST({ + body: { entityType: 'topic', entityId: 'not-a-uuid' } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + expect(pinMock).not.toHaveBeenCalled() + }) + }) + + describe('/pins/:id', () => { + it('should delegate GET with the parsed id', async () => { + getByIdMock.mockResolvedValueOnce({ id: PIN_ID }) + + await expect(pinHandlers['/pins/:id'].GET({ params: { id: PIN_ID } } as never)).resolves.toEqual({ + id: PIN_ID + }) + expect(getByIdMock).toHaveBeenCalledWith(PIN_ID) + }) + + it('should delegate DELETE with the parsed id', async () => { + unpinMock.mockResolvedValueOnce(undefined) + + await expect(pinHandlers['/pins/:id'].DELETE({ params: { id: PIN_ID } } as never)).resolves.toBeUndefined() + expect(unpinMock).toHaveBeenCalledWith(PIN_ID) + }) + + it('should reject invalid pin ids in path params before calling the service', async () => { + await expect(pinHandlers['/pins/:id'].GET({ params: { id: 'not-a-uuid' } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + expect(getByIdMock).not.toHaveBeenCalled() + + await expect(pinHandlers['/pins/:id'].DELETE({ params: { id: 'not-a-uuid' } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + expect(unpinMock).not.toHaveBeenCalled() + }) + }) + + describe('/pins/:id/order', () => { + it('should delegate PATCH with the parsed id and anchor', async () => { + reorderMock.mockResolvedValueOnce(undefined) + + await expect( + pinHandlers['/pins/:id/order'].PATCH({ + params: { id: PIN_ID }, + body: { before: OTHER_PIN_ID } + } as never) + ).resolves.toBeUndefined() + + expect(reorderMock).toHaveBeenCalledWith(PIN_ID, { before: OTHER_PIN_ID }) + }) + + it('should reject a malformed anchor before calling the service', async () => { + await expect( + pinHandlers['/pins/:id/order'].PATCH({ + params: { id: PIN_ID }, + body: { before: OTHER_PIN_ID, after: OTHER_PIN_ID } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + expect(reorderMock).not.toHaveBeenCalled() + }) + + it('should reject an invalid pin id before calling the service', async () => { + await expect( + pinHandlers['/pins/:id/order'].PATCH({ + params: { id: 'not-a-uuid' }, + body: { position: 'first' } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + expect(reorderMock).not.toHaveBeenCalled() + }) + }) + + describe('/pins/order:batch', () => { + it('should delegate PATCH with the parsed moves array', async () => { + reorderBatchMock.mockResolvedValueOnce(undefined) + + await expect( + pinHandlers['/pins/order:batch'].PATCH({ + body: { + moves: [ + { id: PIN_ID, anchor: { position: 'first' } }, + { id: OTHER_PIN_ID, anchor: { after: PIN_ID } } + ] + } + } as never) + ).resolves.toBeUndefined() + + expect(reorderBatchMock).toHaveBeenCalledWith([ + { id: PIN_ID, anchor: { position: 'first' } }, + { id: OTHER_PIN_ID, anchor: { after: PIN_ID } } + ]) + }) + + it('should reject an empty moves array before calling the service', async () => { + await expect(pinHandlers['/pins/order:batch'].PATCH({ body: { moves: [] } } as never)).rejects.toHaveProperty( + 'name', + 'ZodError' + ) + expect(reorderBatchMock).not.toHaveBeenCalled() + }) + + it('should reject a move missing an anchor before calling the service', async () => { + await expect( + pinHandlers['/pins/order:batch'].PATCH({ + body: { moves: [{ id: PIN_ID }] } + } as never) + ).rejects.toHaveProperty('name', 'ZodError') + expect(reorderBatchMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/data/api/handlers/groups.ts b/src/main/data/api/handlers/groups.ts new file mode 100644 index 0000000000..7c8119eca1 --- /dev/null +++ b/src/main/data/api/handlers/groups.ts @@ -0,0 +1,78 @@ +/** + * Group API Handlers + * + * Implements all group-related API endpoints: + * - Group CRUD operations + * - Scoped reorder endpoints (single + batch) + * + * All input validation happens here at the system boundary — handlers do not + * perform row lookups or scope inference, those responsibilities belong to + * `GroupService`. + */ + +import { groupService } from '@data/services/GroupService' +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import { OrderBatchRequestSchema, OrderRequestSchema } from '@shared/data/api/schemas/_endpointHelpers' +import type { GroupSchemas } from '@shared/data/api/schemas/groups' +import { + CreateGroupDtoSchema, + GroupIdSchema, + ListGroupsQuerySchema, + UpdateGroupDtoSchema +} from '@shared/data/api/schemas/groups' + +type GroupHandler> = ApiHandler + +export const groupHandlers: { + [Path in keyof GroupSchemas]: { + [Method in keyof GroupSchemas[Path]]: GroupHandler> + } +} = { + '/groups': { + GET: async ({ query }) => { + const parsed = ListGroupsQuerySchema.parse(query) + return await groupService.listByEntityType(parsed.entityType) + }, + + POST: async ({ body }) => { + const parsed = CreateGroupDtoSchema.parse(body) + return await groupService.create(parsed) + } + }, + + '/groups/:id': { + GET: async ({ params }) => { + const id = GroupIdSchema.parse(params.id) + return await groupService.getById(id) + }, + + PATCH: async ({ params, body }) => { + const id = GroupIdSchema.parse(params.id) + const parsed = UpdateGroupDtoSchema.parse(body) + return await groupService.update(id, parsed) + }, + + DELETE: async ({ params }) => { + const id = GroupIdSchema.parse(params.id) + await groupService.delete(id) + return undefined + } + }, + + '/groups/:id/order': { + PATCH: async ({ params, body }) => { + const id = GroupIdSchema.parse(params.id) + const anchor = OrderRequestSchema.parse(body) + await groupService.reorder(id, anchor) + return undefined + } + }, + + '/groups/order:batch': { + PATCH: async ({ body }) => { + const parsed = OrderBatchRequestSchema.parse(body) + await groupService.reorderBatch(parsed.moves) + return undefined + } + } +} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index e72a0e3ef5..9b75d6d8c5 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -16,11 +16,13 @@ import type { ApiImplementation } from '@shared/data/api/apiTypes' import { assistantHandlers } from './assistants' import { fileProcessingHandlers } from './fileProcessing' +import { groupHandlers } from './groups' import { knowledgeHandlers } from './knowledges' import { mcpServerHandlers } from './mcpServers' import { messageHandlers } from './messages' import { miniappHandlers } from './miniapps' import { modelHandlers } from './models' +import { pinHandlers } from './pins' import { providerHandlers } from './providers' import { tagHandlers } from './tags' import { temporaryChatHandlers } from './temporaryChats' @@ -46,5 +48,7 @@ export const apiHandlers: ApiImplementation = { ...translateHandlers, ...mcpServerHandlers, ...miniappHandlers, - ...tagHandlers + ...tagHandlers, + ...groupHandlers, + ...pinHandlers } diff --git a/src/main/data/api/handlers/pins.ts b/src/main/data/api/handlers/pins.ts new file mode 100644 index 0000000000..a7c055f334 --- /dev/null +++ b/src/main/data/api/handlers/pins.ts @@ -0,0 +1,66 @@ +/** + * Pin API Handlers + * + * Implements all pin-related API endpoints: + * - Pin CRUD (list by entityType, idempotent pin, get, unpin) + * - Scoped reorder (single + batch) + * + * All input validation happens here at the system boundary. Business logic — + * scope inference, orderKey computation, concurrency handling — lives in + * PinService. + */ + +import { pinService } from '@data/services/PinService' +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import { OrderBatchRequestSchema, OrderRequestSchema } from '@shared/data/api/schemas/_endpointHelpers' +import { CreatePinDtoSchema, ListPinsQuerySchema, PinIdSchema, type PinSchemas } from '@shared/data/api/schemas/pins' + +type PinHandler> = ApiHandler + +export const pinHandlers: { + [Path in keyof PinSchemas]: { + [Method in keyof PinSchemas[Path]]: PinHandler> + } +} = { + '/pins': { + GET: async ({ query }) => { + const parsed = ListPinsQuerySchema.parse(query) + return await pinService.listByEntityType(parsed.entityType) + }, + + POST: async ({ body }) => { + const parsed = CreatePinDtoSchema.parse(body) + return await pinService.pin(parsed) + } + }, + + '/pins/:id': { + GET: async ({ params }) => { + const id = PinIdSchema.parse(params.id) + return await pinService.getById(id) + }, + + DELETE: async ({ params }) => { + const id = PinIdSchema.parse(params.id) + await pinService.unpin(id) + return undefined + } + }, + + '/pins/:id/order': { + PATCH: async ({ params, body }) => { + const id = PinIdSchema.parse(params.id) + const anchor = OrderRequestSchema.parse(body) + await pinService.reorder(id, anchor) + return undefined + } + }, + + '/pins/order:batch': { + PATCH: async ({ body }) => { + const parsed = OrderBatchRequestSchema.parse(body) + await pinService.reorderBatch(parsed.moves) + return undefined + } + } +} diff --git a/src/main/data/db/schemas/group.ts b/src/main/data/db/schemas/group.ts index 1acaf0d6bf..add019dda7 100644 --- a/src/main/data/db/schemas/group.ts +++ b/src/main/data/db/schemas/group.ts @@ -1,24 +1,25 @@ -import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' -import { createUpdateTimestamps, uuidPrimaryKey } from './_columnHelpers' +import { createUpdateTimestamps, orderKeyColumns, scopedOrderKeyIndex, uuidPrimaryKey } from './_columnHelpers' /** * Group table - general-purpose grouping for entities * * Supports grouping of topics, sessions, and assistants. - * Each group belongs to a specific entity type. + * Each group belongs to a specific entity type; ordering is scoped per entityType + * via a fractional-indexing `orderKey` (see services/utils/orderKey.ts). */ export const groupTable = sqliteTable( 'group', { id: uuidPrimaryKey(), - // Entity type this group belongs to: topic, session, assistant entityType: text().notNull(), - // Display name of the group name: text().notNull(), - // Sort order for display - sortOrder: integer().default(0), + ...orderKeyColumns, ...createUpdateTimestamps }, - (t) => [index('group_entity_sort_idx').on(t.entityType, t.sortOrder)] + (t) => [scopedOrderKeyIndex('group', 'entityType')(t)] ) + +export type GroupInsert = typeof groupTable.$inferInsert +export type GroupSelect = typeof groupTable.$inferSelect diff --git a/src/main/data/db/schemas/pin.ts b/src/main/data/db/schemas/pin.ts new file mode 100644 index 0000000000..b2656575a3 --- /dev/null +++ b/src/main/data/db/schemas/pin.ts @@ -0,0 +1,42 @@ +import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps, orderKeyColumns, scopedOrderKeyIndex, uuidPrimaryKey } from './_columnHelpers' + +/** + * Pin table - polymorphic pinning across entity types + * + * Any entity type (topic, session, assistant, ...) can be pinned by inserting + * a row here with (entityType, entityId). Pinning is non-destructive: the + * referenced entity's group / order / state is unaffected. Pin order is + * scoped per entityType via `orderKey`. + * + * Design notes: + * - Polymorphic (no FK): mirrors the `entity_tag` table. Consumers MUST call + * `PinService.purgeForEntity(tx, entityType, entityId)` in their delete paths + * (cf. `tagService.removeEntityTags`) — the infra layer has zero knowledge + * of consumer schemas by design. + * - Hard delete on unpin: pinning is a non-destructive marker with no business + * audit value. Keeping a `deletedAt` column would let dead rows accumulate + * for a feature that only tracks "is this currently pinned?". + * - UNIQUE(entityType, entityId): enforces idempotency at the DB layer. The + * service-layer `pin()` method converts the resulting UNIQUE violation back + * into "return the existing row" so the DataApi boundary stays idempotent + * under concurrency. + */ +export const pinTable = sqliteTable( + 'pin', + { + id: uuidPrimaryKey(), + entityType: text().notNull(), + entityId: text().notNull(), + ...orderKeyColumns, + ...createUpdateTimestamps + }, + (t) => [ + uniqueIndex('pin_entity_type_entity_id_unique_idx').on(t.entityType, t.entityId), + scopedOrderKeyIndex('pin', 'entityType')(t) + ] +) + +export type PinInsert = typeof pinTable.$inferInsert +export type PinSelect = typeof pinTable.$inferSelect diff --git a/src/main/data/services/GroupService.ts b/src/main/data/services/GroupService.ts new file mode 100644 index 0000000000..5010e3d409 --- /dev/null +++ b/src/main/data/services/GroupService.ts @@ -0,0 +1,164 @@ +/** + * Group Service - handles group CRUD and scoped reorder operations + * + * Groups are user-managed flat containers keyed by `entityType`. Ordering within + * an entityType bucket is preserved via a fractional-indexing `orderKey`. + * + * USAGE GUIDANCE: + * - `listByEntityType` is the canonical read path; `entityType` is always required. + * - `create` auto-assigns `orderKey` via `insertWithOrderKey` (scope=entityType) + * so consumers never touch the column directly. + * - `reorder` / `reorderBatch` delegate to `applyScopedMoves`, which performs + * scope inference and enforces "batch stays within one entityType". + */ + +import { application } from '@application' +import { groupTable } from '@data/db/schemas/group' +import { defaultHandlersFor, withSqliteErrors } from '@data/db/sqliteErrors' +import { loggerService } from '@logger' +import { DataApiErrorFactory } from '@shared/data/api' +import type { OrderRequest } from '@shared/data/api/schemas/_endpointHelpers' +import type { CreateGroupDto, UpdateGroupDto } from '@shared/data/api/schemas/groups' +import type { EntityType } from '@shared/data/types/entityType' +import type { Group } from '@shared/data/types/group' +import { asc, eq } from 'drizzle-orm' + +import { applyScopedMoves, insertWithOrderKey } from './utils/orderKey' +import { timestampToISO } from './utils/rowMappers' + +const logger = loggerService.withContext('DataApi:GroupService') + +type GroupRow = typeof groupTable.$inferSelect + +function rowToGroup(row: GroupRow): Group { + return { + id: row.id, + entityType: row.entityType as EntityType, + name: row.name, + orderKey: row.orderKey, + createdAt: timestampToISO(row.createdAt), + updatedAt: timestampToISO(row.updatedAt) + } +} + +export class GroupService { + private get db() { + return application.get('DbService').getDb() + } + + /** + * List groups for a given entityType, ordered by orderKey ASC. + */ + async listByEntityType(entityType: EntityType): Promise { + const rows = await this.db + .select() + .from(groupTable) + .where(eq(groupTable.entityType, entityType)) + .orderBy(asc(groupTable.orderKey)) + return rows.map(rowToGroup) + } + + /** + * Get a group by ID. + */ + async getById(id: string): Promise { + const [row] = await this.db.select().from(groupTable).where(eq(groupTable.id, id)).limit(1) + + if (!row) { + throw DataApiErrorFactory.notFound('Group', id) + } + + return rowToGroup(row) + } + + /** + * Create a new group. The new row is appended to the end of its entityType + * bucket with a fresh fractional-indexing orderKey. + */ + async create(dto: CreateGroupDto): Promise { + const row = await withSqliteErrors( + () => + this.db.transaction(async (tx) => + insertWithOrderKey( + tx, + groupTable, + { entityType: dto.entityType, name: dto.name }, + { + pkColumn: groupTable.id, + scope: eq(groupTable.entityType, dto.entityType) + } + ) + ), + defaultHandlersFor('Group', dto.name) + ) + + const mapped = rowToGroup(row as GroupRow) + logger.info('Created group', { id: mapped.id, entityType: mapped.entityType }) + return mapped + } + + /** + * Update an existing group. `entityType` is immutable — only `name` can change. + */ + async update(id: string, dto: UpdateGroupDto): Promise { + const updates: Partial = {} + if (dto.name !== undefined) updates.name = dto.name + + if (Object.keys(updates).length === 0) { + return this.getById(id) + } + + const [row] = await withSqliteErrors( + () => this.db.update(groupTable).set(updates).where(eq(groupTable.id, id)).returning(), + defaultHandlersFor('Group', dto.name ?? id) + ) + + if (!row) { + throw DataApiErrorFactory.notFound('Group', id) + } + + logger.info('Updated group', { id, changes: Object.keys(dto) }) + return rowToGroup(row) + } + + /** + * Delete a group. + */ + async delete(id: string): Promise { + const [row] = await this.db.delete(groupTable).where(eq(groupTable.id, id)).returning({ id: groupTable.id }) + + if (!row) { + throw DataApiErrorFactory.notFound('Group', id) + } + + logger.info('Deleted group', { id }) + } + + /** + * Move a single group relative to an anchor. Scope (entityType) is inferred + * from the target row — callers do not pass scope. + */ + async reorder(id: string, anchor: OrderRequest): Promise { + await this.db.transaction(async (tx) => + applyScopedMoves(tx, groupTable, [{ id, anchor }], { + pkColumn: groupTable.id, + scopeColumn: groupTable.entityType + }) + ) + } + + /** + * Apply a batch of moves atomically. `applyScopedMoves` rejects batches that + * span multiple entityTypes with a VALIDATION_ERROR. + */ + async reorderBatch(moves: Array<{ id: string; anchor: OrderRequest }>): Promise { + await this.db.transaction(async (tx) => + applyScopedMoves(tx, groupTable, moves, { + pkColumn: groupTable.id, + scopeColumn: groupTable.entityType + }) + ) + } +} + +export const groupService = new GroupService() diff --git a/src/main/data/services/PinService.ts b/src/main/data/services/PinService.ts new file mode 100644 index 0000000000..3da5168221 --- /dev/null +++ b/src/main/data/services/PinService.ts @@ -0,0 +1,193 @@ +/** + * Pin Service - handles polymorphic pin CRUD and scoped reorder operations + * + * Pins are a non-destructive "promote to top" marker for any entity type + * listed in the shared `EntityType` enum. Ordering within an entityType bucket + * is preserved via a fractional-indexing `orderKey`. + * + * USAGE GUIDANCE: + * - `listByEntityType` is the canonical read path; `entityType` is always required. + * - `pin` is idempotent AND concurrent-safe: repeat calls for the same + * (entityType, entityId) resolve to the same row, even under parallel writes. + * - `unpin` is a hard delete. There is no soft-delete / audit column. + * - `reorder` / `reorderBatch` delegate to `applyScopedMoves`, which performs + * scope inference and enforces "batch stays within one entityType". + * - `purgeForEntity` MUST be called from consumer services' delete paths + * (mirrors `tagService.removeEntityTags`). The `pin` table has no FK to + * consumer tables by design; application-level purge is the contract. + */ + +import { application } from '@application' +import { type PinSelect, pinTable } from '@data/db/schemas/pin' +import { classifySqliteError } from '@data/db/sqliteErrors' +import type { DbType } from '@data/db/types' +import { loggerService } from '@logger' +import { DataApiErrorFactory } from '@shared/data/api' +import type { OrderRequest } from '@shared/data/api/schemas/_endpointHelpers' +import type { CreatePinDto } from '@shared/data/api/schemas/pins' +import type { EntityType } from '@shared/data/types/entityType' +import type { Pin } from '@shared/data/types/pin' +import { and, asc, eq } from 'drizzle-orm' + +import { applyScopedMoves, insertWithOrderKey } from './utils/orderKey' +import { timestampToISO } from './utils/rowMappers' + +const logger = loggerService.withContext('DataApi:PinService') + +function rowToPin(row: PinSelect): Pin { + return { + id: row.id, + entityType: row.entityType as EntityType, + entityId: row.entityId, + orderKey: row.orderKey, + createdAt: timestampToISO(row.createdAt), + updatedAt: timestampToISO(row.updatedAt) + } +} + +export class PinService { + private get db() { + return application.get('DbService').getDb() + } + + /** + * List pins for a given entityType, ordered by orderKey ASC. + */ + async listByEntityType(entityType: EntityType): Promise { + const rows = await this.db + .select() + .from(pinTable) + .where(eq(pinTable.entityType, entityType)) + .orderBy(asc(pinTable.orderKey)) + return rows.map(rowToPin) + } + + /** + * Get a pin by ID. + */ + async getById(id: string): Promise { + const [row] = await this.db.select().from(pinTable).where(eq(pinTable.id, id)).limit(1) + + if (!row) { + throw DataApiErrorFactory.notFound('Pin', id) + } + + return rowToPin(row) + } + + /** + * Idempotent, concurrent-safe pin. Two sequential calls with the same + * (entityType, entityId) return the same row; two concurrent calls also + * converge to one row without leaking a UNIQUE violation to the caller. + * + * Strategy: fast-path SELECT first; if nothing is there, INSERT with scoped + * orderKey. Under concurrency the INSERT may race a peer's INSERT and hit + * the UNIQUE(entityType, entityId) index — in that case classify the error + * as `unique` and re-SELECT to return the winner's row. Any non-UNIQUE + * error is re-thrown unchanged. + * + * See sqliteErrors.ts "Discipline: do not replace pre-validation" — the + * fast-path SELECT IS the pre-validation here; the UNIQUE catch is purely + * the TOCTOU concurrency fallback. + */ + async pin(dto: CreatePinDto): Promise { + return this.db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(pinTable) + .where(and(eq(pinTable.entityType, dto.entityType), eq(pinTable.entityId, dto.entityId))) + .limit(1) + if (existing) return rowToPin(existing) + + try { + const inserted = await insertWithOrderKey( + tx, + pinTable, + { entityType: dto.entityType, entityId: dto.entityId }, + { + pkColumn: pinTable.id, + scope: eq(pinTable.entityType, dto.entityType) + } + ) + const mapped = rowToPin(inserted as PinSelect) + logger.info('Created pin', { + id: mapped.id, + entityType: mapped.entityType, + entityId: mapped.entityId + }) + return mapped + } catch (e) { + if (classifySqliteError(e)?.kind !== 'unique') throw e + + const [winner] = await tx + .select() + .from(pinTable) + .where(and(eq(pinTable.entityType, dto.entityType), eq(pinTable.entityId, dto.entityId))) + .limit(1) + if (!winner) throw e + return rowToPin(winner) + } + }) + } + + /** + * Unpin by pin id. Hard delete. + */ + async unpin(id: string): Promise { + const [row] = await this.db.delete(pinTable).where(eq(pinTable.id, id)).returning({ id: pinTable.id }) + + if (!row) { + throw DataApiErrorFactory.notFound('Pin', id) + } + + logger.info('Deleted pin', { id }) + } + + /** + * Move a single pin relative to an anchor. Scope (entityType) is inferred + * from the target row — callers do not pass scope. + */ + async reorder(id: string, anchor: OrderRequest): Promise { + await this.db.transaction(async (tx) => + applyScopedMoves(tx, pinTable, [{ id, anchor }], { + pkColumn: pinTable.id, + scopeColumn: pinTable.entityType + }) + ) + } + + /** + * Apply a batch of moves atomically. `applyScopedMoves` rejects batches that + * span multiple entityTypes with a VALIDATION_ERROR. + */ + async reorderBatch(moves: Array<{ id: string; anchor: OrderRequest }>): Promise { + await this.db.transaction(async (tx) => + applyScopedMoves(tx, pinTable, moves, { + pkColumn: pinTable.id, + scopeColumn: pinTable.entityType + }) + ) + } + + /** + * Remove all pin rows targeting a given (entityType, entityId). + * Must be called by consumer services (TopicService, AssistantService, ...) + * when deleting the underlying entity, since `pin` has no FK to entity + * tables. + * + * Because pin is hard-deleted row-by-row (no bulk orderKey rewrite), the + * remaining rows' orderKeys are not mutated — neighbors retain their + * existing keys and relative ordering. + * + * Signature is tx-first (mainstream ORM convention) rather than mirroring + * `tagService.removeEntityTags` — tag's trailing-optional-tx is the project's + * historical outlier; new polymorphic purge helpers should lead with tx. + */ + async purgeForEntity(tx: Pick, entityType: EntityType, entityId: string): Promise { + await tx.delete(pinTable).where(and(eq(pinTable.entityType, entityType), eq(pinTable.entityId, entityId))) + + logger.info('Purged pins for entity', { entityType, entityId }) + } +} + +export const pinService = new PinService() diff --git a/src/main/data/services/__tests__/GroupService.test.ts b/src/main/data/services/__tests__/GroupService.test.ts new file mode 100644 index 0000000000..26a17b67a2 --- /dev/null +++ b/src/main/data/services/__tests__/GroupService.test.ts @@ -0,0 +1,208 @@ +import { groupTable } from '@data/db/schemas/group' +import { GroupService, groupService } from '@data/services/GroupService' +import { DataApiError, ErrorCode } from '@shared/data/api' +import { setupTestDatabase } from '@test-helpers/db' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' + +const GROUP_ID_MISSING = '11111111-1111-4111-8111-111111111111' + +describe('GroupService', () => { + const dbh = setupTestDatabase() + + it('should export a module-level singleton of GroupService', () => { + expect(groupService).toBeInstanceOf(GroupService) + }) + + describe('create', () => { + it('should create a group with an auto-assigned orderKey', async () => { + const result = await groupService.create({ entityType: 'topic', name: 'Research' }) + + expect(result).toMatchObject({ entityType: 'topic', name: 'Research' }) + expect(typeof result.orderKey).toBe('string') + expect(result.orderKey.length).toBeGreaterThan(0) + + const [row] = await dbh.db.select().from(groupTable).where(eq(groupTable.id, result.id)) + expect(row).toMatchObject({ name: 'Research', entityType: 'topic', orderKey: result.orderKey }) + }) + + it('should assign strictly increasing orderKeys within the same entityType', async () => { + const first = await groupService.create({ entityType: 'topic', name: 'alpha' }) + const second = await groupService.create({ entityType: 'topic', name: 'beta' }) + const third = await groupService.create({ entityType: 'topic', name: 'gamma' }) + + expect(second.orderKey > first.orderKey).toBe(true) + expect(third.orderKey > second.orderKey).toBe(true) + }) + + it('should keep orderKey sequences independent across entityTypes', async () => { + const topicFirst = await groupService.create({ entityType: 'topic', name: 'first-topic' }) + const sessionFirst = await groupService.create({ entityType: 'session', name: 'first-session' }) + + // Each entityType starts with the same fractional-indexing starter key + // because neither bucket has a predecessor. + expect(topicFirst.orderKey).toBe(sessionFirst.orderKey) + }) + }) + + describe('listByEntityType', () => { + it('should return groups ordered by orderKey, scoped to the requested entityType', async () => { + const topicA = await groupService.create({ entityType: 'topic', name: 'A' }) + const topicB = await groupService.create({ entityType: 'topic', name: 'B' }) + await groupService.create({ entityType: 'session', name: 'session-only' }) + + const topics = await groupService.listByEntityType('topic') + expect(topics.map((g) => g.id)).toEqual([topicA.id, topicB.id]) + }) + + it('should return an empty array when no groups exist for the entityType', async () => { + await expect(groupService.listByEntityType('assistant')).resolves.toEqual([]) + }) + }) + + describe('getById', () => { + it('should throw NOT_FOUND when the group does not exist', async () => { + await expect(groupService.getById(GROUP_ID_MISSING)).rejects.toThrow(DataApiError) + await expect(groupService.getById(GROUP_ID_MISSING)).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) + + describe('update', () => { + it('should update the name of an existing group', async () => { + const created = await groupService.create({ entityType: 'topic', name: 'Old' }) + + const updated = await groupService.update(created.id, { name: 'New' }) + + expect(updated).toMatchObject({ id: created.id, name: 'New', entityType: 'topic' }) + }) + + it('should return the current row for an empty update payload', async () => { + const created = await groupService.create({ entityType: 'topic', name: 'Unchanged' }) + + const result = await groupService.update(created.id, {}) + + expect(result).toMatchObject({ id: created.id, name: 'Unchanged' }) + }) + + it('should throw NOT_FOUND when the group does not exist', async () => { + await expect(groupService.update(GROUP_ID_MISSING, { name: 'x' })).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) + + describe('reorder', () => { + it("should move a group to the first position via { position: 'first' }", async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + const b = await groupService.create({ entityType: 'topic', name: 'B' }) + const c = await groupService.create({ entityType: 'topic', name: 'C' }) + + await groupService.reorder(c.id, { position: 'first' }) + + const ids = (await groupService.listByEntityType('topic')).map((g) => g.id) + expect(ids).toEqual([c.id, a.id, b.id]) + }) + + it('should move a group to before an anchor', async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + const b = await groupService.create({ entityType: 'topic', name: 'B' }) + const c = await groupService.create({ entityType: 'topic', name: 'C' }) + + await groupService.reorder(c.id, { before: b.id }) + + const ids = (await groupService.listByEntityType('topic')).map((g) => g.id) + expect(ids).toEqual([a.id, c.id, b.id]) + }) + + it('should move a group to after an anchor', async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + const b = await groupService.create({ entityType: 'topic', name: 'B' }) + const c = await groupService.create({ entityType: 'topic', name: 'C' }) + + await groupService.reorder(a.id, { after: b.id }) + + const ids = (await groupService.listByEntityType('topic')).map((g) => g.id) + expect(ids).toEqual([b.id, a.id, c.id]) + }) + + it("should move a group to the last position via { position: 'last' }", async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + const b = await groupService.create({ entityType: 'topic', name: 'B' }) + const c = await groupService.create({ entityType: 'topic', name: 'C' }) + + await groupService.reorder(a.id, { position: 'last' }) + + const ids = (await groupService.listByEntityType('topic')).map((g) => g.id) + expect(ids).toEqual([b.id, c.id, a.id]) + }) + + it('should throw NOT_FOUND when the target id does not exist', async () => { + await expect(groupService.reorder(GROUP_ID_MISSING, { position: 'first' })).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) + + describe('reorderBatch', () => { + it('should apply multi-move atomically within one entityType', async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + const b = await groupService.create({ entityType: 'topic', name: 'B' }) + const c = await groupService.create({ entityType: 'topic', name: 'C' }) + const d = await groupService.create({ entityType: 'topic', name: 'D' }) + + await groupService.reorderBatch([ + { id: d.id, anchor: { position: 'first' } }, + { id: a.id, anchor: { position: 'last' } } + ]) + + const ids = (await groupService.listByEntityType('topic')).map((g) => g.id) + expect(ids).toEqual([d.id, b.id, c.id, a.id]) + }) + + it('should reject a batch spanning multiple entityTypes with VALIDATION_ERROR', async () => { + const topic = await groupService.create({ entityType: 'topic', name: 'topic-group' }) + const session = await groupService.create({ entityType: 'session', name: 'session-group' }) + + await expect( + groupService.reorderBatch([ + { id: topic.id, anchor: { position: 'first' } }, + { id: session.id, anchor: { position: 'first' } } + ]) + ).rejects.toMatchObject({ code: ErrorCode.VALIDATION_ERROR }) + }) + + it('should throw NOT_FOUND when any move id is unknown', async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + + await expect( + groupService.reorderBatch([ + { id: a.id, anchor: { position: 'last' } }, + { id: GROUP_ID_MISSING, anchor: { position: 'first' } } + ]) + ).rejects.toMatchObject({ code: ErrorCode.NOT_FOUND }) + }) + }) + + describe('delete', () => { + it('should not change orderKeys of sibling groups after a deletion', async () => { + const a = await groupService.create({ entityType: 'topic', name: 'A' }) + const b = await groupService.create({ entityType: 'topic', name: 'B' }) + const c = await groupService.create({ entityType: 'topic', name: 'C' }) + + await groupService.delete(b.id) + + const remaining = await groupService.listByEntityType('topic') + expect(remaining.map((g) => g.id)).toEqual([a.id, c.id]) + expect(remaining[0].orderKey).toBe(a.orderKey) + expect(remaining[1].orderKey).toBe(c.orderKey) + }) + + it('should throw NOT_FOUND when the group does not exist', async () => { + await expect(groupService.delete(GROUP_ID_MISSING)).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) +}) diff --git a/src/main/data/services/__tests__/PinService.test.ts b/src/main/data/services/__tests__/PinService.test.ts new file mode 100644 index 0000000000..95f379ac23 --- /dev/null +++ b/src/main/data/services/__tests__/PinService.test.ts @@ -0,0 +1,241 @@ +import { pinTable } from '@data/db/schemas/pin' +import { PinService, pinService } from '@data/services/PinService' +import { DataApiError, ErrorCode } from '@shared/data/api' +import { setupTestDatabase } from '@test-helpers/db' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' + +const PIN_ID_MISSING = '11111111-1111-4111-8111-111111111111' +const ENTITY_ID_1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' +const ENTITY_ID_2 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2' +const ENTITY_ID_3 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3' +const PREEXISTING_PIN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +describe('PinService', () => { + const dbh = setupTestDatabase() + + it('should export a module-level singleton of PinService', () => { + expect(pinService).toBeInstanceOf(PinService) + }) + + describe('pin', () => { + it('should insert a new row for a fresh (entityType, entityId) pair', async () => { + const result = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + expect(result).toMatchObject({ entityType: 'topic', entityId: ENTITY_ID_1 }) + expect(typeof result.orderKey).toBe('string') + expect(result.orderKey.length).toBeGreaterThan(0) + + const [row] = await dbh.db.select().from(pinTable).where(eq(pinTable.id, result.id)) + expect(row).toMatchObject({ entityType: 'topic', entityId: ENTITY_ID_1, orderKey: result.orderKey }) + }) + + it('should return the same row on a repeat call (serial idempotency)', async () => { + const first = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const second = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + expect(second.id).toBe(first.id) + expect(second.orderKey).toBe(first.orderKey) + + const rows = await dbh.db.select().from(pinTable) + expect(rows).toHaveLength(1) + }) + + it('should return the pre-existing row when (entityType, entityId) already present', async () => { + // Seed a row directly to simulate "already pinned before this call runs". + // Exercises the fast-path SELECT branch of the idempotent pin(). + await dbh.db.insert(pinTable).values({ + id: PREEXISTING_PIN_ID, + entityType: 'topic', + entityId: ENTITY_ID_1, + orderKey: 'a0', + createdAt: 1_000, + updatedAt: 1_000 + }) + + const result = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + expect(result.id).toBe(PREEXISTING_PIN_ID) + expect(result.orderKey).toBe('a0') + + const rows = await dbh.db.select().from(pinTable) + expect(rows).toHaveLength(1) + }) + + it('should return distinct rows for different (entityType, entityId) pairs', async () => { + const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + const c = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 }) + + const ids = new Set([a.id, b.id, c.id]) + expect(ids.size).toBe(3) + }) + + it('should maintain independent orderKey sequences per entityType', async () => { + const topicFirst = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const sessionFirst = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 }) + + // Each scope starts from the same fractional-indexing starter key because + // neither bucket has a predecessor. + expect(topicFirst.orderKey).toBe(sessionFirst.orderKey) + + const topicSecond = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + expect(topicSecond.orderKey > topicFirst.orderKey).toBe(true) + }) + }) + + describe('unpin', () => { + it('should hard delete the pin row and return void', async () => { + const pin = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + await expect(pinService.unpin(pin.id)).resolves.toBeUndefined() + + const rows = await dbh.db.select().from(pinTable).where(eq(pinTable.id, pin.id)) + expect(rows).toHaveLength(0) + }) + + it('should throw NOT_FOUND when the pin id is unknown', async () => { + await expect(pinService.unpin(PIN_ID_MISSING)).rejects.toThrow(DataApiError) + await expect(pinService.unpin(PIN_ID_MISSING)).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) + + describe('getById', () => { + it('should return a fully mapped pin when found', async () => { + const pin = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + const result = await pinService.getById(pin.id) + expect(result).toMatchObject({ id: pin.id, entityType: 'topic', entityId: ENTITY_ID_1 }) + }) + + it('should throw NOT_FOUND when the pin does not exist', async () => { + await expect(pinService.getById(PIN_ID_MISSING)).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) + + describe('listByEntityType', () => { + it('should return pins ordered by orderKey, scoped to the requested entityType', async () => { + const topicA = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const topicB = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 }) + + const topics = await pinService.listByEntityType('topic') + expect(topics.map((p) => p.id)).toEqual([topicA.id, topicB.id]) + }) + + it('should return an empty array when no pins exist for the entityType', async () => { + await expect(pinService.listByEntityType('assistant')).resolves.toEqual([]) + }) + }) + + describe('reorder', () => { + it("should move a pin to the first position via { position: 'first' }", async () => { + const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 }) + + await pinService.reorder(c.id, { position: 'first' }) + + const ids = (await pinService.listByEntityType('topic')).map((p) => p.id) + expect(ids).toEqual([c.id, a.id, b.id]) + }) + + it('should move a pin to before an anchor', async () => { + const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 }) + + await pinService.reorder(c.id, { before: b.id }) + + const ids = (await pinService.listByEntityType('topic')).map((p) => p.id) + expect(ids).toEqual([a.id, c.id, b.id]) + }) + + it('should throw NOT_FOUND when the target id does not exist', async () => { + await expect(pinService.reorder(PIN_ID_MISSING, { position: 'first' })).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND + }) + }) + }) + + describe('reorderBatch', () => { + it('should apply multi-move atomically within one entityType', async () => { + const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 }) + const d = await pinService.pin({ entityType: 'topic', entityId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4' }) + + await pinService.reorderBatch([ + { id: d.id, anchor: { position: 'first' } }, + { id: a.id, anchor: { position: 'last' } } + ]) + + const ids = (await pinService.listByEntityType('topic')).map((p) => p.id) + expect(ids).toEqual([d.id, b.id, c.id, a.id]) + }) + + it('should reject a batch spanning multiple entityTypes with VALIDATION_ERROR', async () => { + const topic = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const session = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 }) + + await expect( + pinService.reorderBatch([ + { id: topic.id, anchor: { position: 'first' } }, + { id: session.id, anchor: { position: 'first' } } + ]) + ).rejects.toMatchObject({ code: ErrorCode.VALIDATION_ERROR }) + }) + + it('should throw NOT_FOUND when any move id is unknown', async () => { + const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + await expect( + pinService.reorderBatch([ + { id: a.id, anchor: { position: 'last' } }, + { id: PIN_ID_MISSING, anchor: { position: 'first' } } + ]) + ).rejects.toMatchObject({ code: ErrorCode.NOT_FOUND }) + }) + }) + + describe('purgeForEntity', () => { + it('should delete only pins targeting the specified (entityType, entityId)', async () => { + const target = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const siblingSameType = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + const sameIdOtherType = await pinService.pin({ entityType: 'session', entityId: ENTITY_ID_1 }) + + await pinService.purgeForEntity(dbh.db, 'topic', ENTITY_ID_1) + + const rows = await dbh.db.select().from(pinTable) + const remainingIds = rows.map((r) => r.id).sort() + expect(remainingIds).toEqual([siblingSameType.id, sameIdOtherType.id].sort()) + expect(rows.find((r) => r.id === target.id)).toBeUndefined() + }) + + it('should not mutate neighbor orderKeys within the same entityType', async () => { + const a = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + const b = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_2 }) + const c = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_3 }) + + await pinService.purgeForEntity(dbh.db, 'topic', b.entityId) + + const remaining = await pinService.listByEntityType('topic') + expect(remaining.map((p) => p.id)).toEqual([a.id, c.id]) + expect(remaining[0].orderKey).toBe(a.orderKey) + expect(remaining[1].orderKey).toBe(c.orderKey) + }) + + it('should be a no-op when no matching pin exists', async () => { + const existing = await pinService.pin({ entityType: 'topic', entityId: ENTITY_ID_1 }) + + await expect(pinService.purgeForEntity(dbh.db, 'assistant', ENTITY_ID_1)).resolves.toBeUndefined() + + const rows = await dbh.db.select().from(pinTable) + expect(rows.map((r) => r.id)).toEqual([existing.id]) + }) + }) +})