From 99fafdb8d637f17160127c3b07dd875512c9b823 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 25 Aug 2026 10:59:43 +0200 Subject: [PATCH] Add database indexes --- ...4_000001_add_application_query_indexes.php | 230 ++++++++++++++++++ extensions/manifest.json | 2 +- 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_24_000001_add_application_query_indexes.php diff --git a/database/migrations/2026_08_24_000001_add_application_query_indexes.php b/database/migrations/2026_08_24_000001_add_application_query_indexes.php new file mode 100644 index 00000000..096647e3 --- /dev/null +++ b/database/migrations/2026_08_24_000001_add_application_query_indexes.php @@ -0,0 +1,230 @@ +runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)'); + + // Tenant-scoped project pagination ordered by newest first; also covers the organization FK. + $this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)'); + // Speeds client relationship loads and the FK check when a client is deleted or its ID changes. + $this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)'); + + // Tenant-scoped task pagination ordered by newest first; also covers the organization FK. + $this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)'); + // Speeds project task lists and the FK check when a project is deleted or its ID changes. + $this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)'); + + // Tenant-scoped tag pagination ordered by newest first; also covers the organization FK. + $this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)'); + + // Tenant-scoped report pagination ordered by newest first; also covers the organization FK. + $this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)'); + + // Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup. + $this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)'); + // Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes. + $this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)'); + + // The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes. + $this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)'); + $this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)'); + + // Supports organization deletion/current-team cleanup and the FK check on users.current_team_id. + $this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)'); + + // Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table. + $this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)'); + + // Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK. + $this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)'); + // Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering. + $this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)'); + // Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK. + $this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)'); + // Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK. + $this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)'); + // These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted. + $this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)'); + $this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)'); + $this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)'); + // Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup. + $this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL'); + // whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index. + $this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)'); + + // Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update. + $this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)'); + $this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)'); + + // owner_id is already the leading column of oauth_clients_owner_id_owner_type_index. + $this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index'); + // Public report lookup starts with the unique share_secret index; no query filters only by this boolean. + $this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $indexes = [ + 'clients_organization_created_id_index', + 'projects_organization_created_id_index', + 'projects_client_id_index', + 'tasks_organization_created_id_index', + 'tasks_project_id_index', + 'tags_organization_created_id_index', + 'reports_organization_created_id_index', + 'members_organization_created_id_index', + 'members_user_id_index', + 'project_members_member_id_index', + 'project_members_user_id_index', + 'users_current_team_id_index', + 'audits_created_at_index', + 'time_entries_organization_start_id_index', + 'time_entries_created_at_index', + 'time_entries_user_organization_start_index', + 'time_entries_member_start_index', + 'time_entries_project_id_index', + 'time_entries_task_id_index', + 'time_entries_client_id_index', + 'time_entries_active_member_index', + 'time_entries_tags_gin_index', + 'oauth_access_tokens_client_id_index', + 'oauth_auth_codes_client_id_index', + ]; + + $concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : ''; + foreach ($indexes as $index) { + $this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index); + } + + $this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)'); + $this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)'); + } + + private function runIndexOperation(string $operation, string $index, string $statement): void + { + $indexState = $this->indexState($index); + + if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) { + $this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index)); + + return; + } + + if ($operation === 'drop' && $indexState === null) { + $this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index)); + + return; + } + + if ($operation === 'create' && $indexState !== null) { + $this->writeProgress(sprintf( + 'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding', + $index, + $indexState['valid'] ? 'true' : 'false', + $indexState['ready'] ? 'true' : 'false', + )); + $this->executeIndexStatement( + 'drop incomplete', + $index, + (DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index), + ); + } + + $this->executeIndexStatement($operation, $index, $statement); + } + + private function executeIndexStatement(string $operation, string $index, string $statement): void + { + $startedAt = microtime(true); + $this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index)); + + try { + DB::statement($statement); + } catch (Throwable $exception) { + $this->writeProgress(sprintf( + 'Failed to %s index [%s] after %.2f seconds: %s', + $operation, + $index, + microtime(true) - $startedAt, + $exception->getMessage(), + )); + + throw $exception; + } + + $this->writeProgress(sprintf( + 'Finished %s index [%s] in %.2f seconds', + $operation === 'create' ? 'creating' : 'dropping', + $index, + microtime(true) - $startedAt, + )); + } + + /** + * @return array{valid: bool, ready: bool}|null + */ + private function indexState(string $index): ?array + { + $state = DB::selectOne( + <<<'SQL' + SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready + FROM pg_index + JOIN pg_class ON pg_class.oid = pg_index.indexrelid + JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_namespace.nspname = current_schema() + AND pg_class.relname = ? + SQL, + [$index], + ); + + if ($state === null) { + return null; + } + + return [ + 'valid' => (bool) $state->valid, + 'ready' => (bool) $state->ready, + ]; + } + + private function quoteIdentifier(string $identifier): string + { + return DB::connection()->getQueryGrammar()->wrap($identifier); + } + + private function writeProgress(string $message): void + { + if (app()->runningUnitTests()) { + return; + } + + $message = sprintf('[%s] %s', date(DATE_ATOM), $message); + Log::info($message); + consoleInfo($message); + } +}; diff --git a/extensions/manifest.json b/extensions/manifest.json index 2c88180d..119c0ae7 100644 --- a/extensions/manifest.json +++ b/extensions/manifest.json @@ -1,7 +1,7 @@ { "Billing": { "repository": "solidtime-io/extension-billing", - "ref": "v0.0.3" + "ref": "v0.0.4" }, "Services": { "repository": "solidtime-io/extension-services",