diff --git a/packages/backend/migrations/050_soft_delete_projects.sql b/packages/backend/migrations/050_soft_delete_projects.sql new file mode 100644 index 00000000..53fad860 --- /dev/null +++ b/packages/backend/migrations/050_soft_delete_projects.sql @@ -0,0 +1,35 @@ +-- ============================================================================ +-- Migration 050: Soft-delete support for projects +-- ============================================================================ +-- Adds a deleted_at column so project deletion is reversible during a grace +-- window (default 30 days). A background worker later hard-deletes rows +-- past the grace window (see migration 051 and the purge worker in worker.ts). +-- +-- Also replaces hard uniqueness constraints on slug and (org, name) with +-- partial-index variants (WHERE deleted_at IS NULL) so a soft-deleted project +-- no longer "occupies" its name or slug, allowing the same values to be reused +-- for new projects. +-- ============================================================================ + +-- 1. Add deleted_at column (NULL = active, non-NULL = soft-deleted) +ALTER TABLE projects ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ NULL; + +-- 2. Index to support the purge worker's "find projects past grace window" query +CREATE INDEX IF NOT EXISTS idx_projects_deleted_at + ON projects (deleted_at) + WHERE deleted_at IS NOT NULL; + +-- 3. Replace the global slug unique index (added in migration 036) with a +-- per-org partial one. Slugs need only be unique within an organization, +-- and soft-deleted projects no longer occupy their slug. +DROP INDEX IF EXISTS idx_projects_slug_unique; +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug_unique + ON projects (organization_id, slug) + WHERE deleted_at IS NULL; + +-- 4. Replace the (organization_id, name) unique constraint from the original +-- schema with a partial unique index. +ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_organization_id_name_key; +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_org_name_unique + ON projects (organization_id, name) + WHERE deleted_at IS NULL; diff --git a/packages/backend/migrations/051_drop_cascade_logs_spans_metrics.sql b/packages/backend/migrations/051_drop_cascade_logs_spans_metrics.sql new file mode 100644 index 00000000..0a077568 --- /dev/null +++ b/packages/backend/migrations/051_drop_cascade_logs_spans_metrics.sql @@ -0,0 +1,30 @@ +-- ============================================================================ +-- Migration 051: Drop ON DELETE CASCADE from logs / spans / metrics project FK +-- ============================================================================ +-- CAUTION: This migration swaps FK constraints on TimescaleDB hypertables. +-- On large deployments (50M+ rows) the constraint rebuild can take considerable +-- time. Apply during a low-traffic window and verify the query plan beforehand. +-- +-- Rationale: with soft-delete in place (migration 050), the projects row is +-- never immediately removed — it stays in the DB throughout the 30-day grace +-- window. The hard-delete purge worker explicitly calls reservoir.purgeProject() +-- (which deletes logs/spans/metrics by project_id) *before* removing the +-- projects row, making the cascade redundant. +-- Dropping it prevents an accidental bulk data-loss if a projects row is +-- ever hard-deleted outside the controlled purge path. +-- ============================================================================ + +-- logs (TimescaleDB hypertable) +ALTER TABLE logs DROP CONSTRAINT IF EXISTS logs_project_id_fkey; +ALTER TABLE logs ADD CONSTRAINT logs_project_id_fkey + FOREIGN KEY (project_id) REFERENCES projects(id); + +-- spans (TimescaleDB hypertable) +ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_project_id_fkey; +ALTER TABLE spans ADD CONSTRAINT spans_project_id_fkey + FOREIGN KEY (project_id) REFERENCES projects(id); + +-- metrics (TimescaleDB hypertable) +ALTER TABLE metrics DROP CONSTRAINT IF EXISTS metrics_project_id_fkey; +ALTER TABLE metrics ADD CONSTRAINT metrics_project_id_fkey + FOREIGN KEY (project_id) REFERENCES projects(id); diff --git a/packages/backend/scripts/tenant-scope-allowlist.json b/packages/backend/scripts/tenant-scope-allowlist.json index f5d4281c..932905cb 100644 --- a/packages/backend/scripts/tenant-scope-allowlist.json +++ b/packages/backend/scripts/tenant-scope-allowlist.json @@ -251,12 +251,6 @@ "snippet": "await db.updateTable('projects').set(updates).where('id', '=', projectId).execute();", "reason": "safe-by-PK: one-shot boot backfill updates all uninitialized projects by PK from a full scan" }, - { - "file": "packages/backend/src/modules/projects/service.ts", - "table": "projects", - "snippet": ".deleteFrom('projects')", - "reason": "safe-by-PK: DELETE by projectId; caller verifies project ownership via getProjectById first" - }, { "file": "packages/backend/src/modules/projects/service.ts", "table": "projects", @@ -299,12 +293,6 @@ "snippet": ".selectFrom('detection_events')", "reason": "scoped-indirect: SELECT by id IN eventIds where eventIds came from prior org-scoped query" }, - { - "file": "packages/backend/src/queue/jobs/log-pipeline.ts", - "table": "logs", - "snippet": ".updateTable('logs')", - "reason": "safe-by-PK: UPDATE by log id from job payload; log IDs were just ingested in the same project/org context" - }, { "file": "packages/backend/src/scripts/seed-massive-data.ts", "table": "detection_events", diff --git a/packages/backend/src/database/types.ts b/packages/backend/src/database/types.ts index f2725c6f..43d4a90e 100644 --- a/packages/backend/src/database/types.ts +++ b/packages/backend/src/database/types.ts @@ -159,6 +159,7 @@ export interface ProjectsTable { has_logs_at: Timestamp | null; has_traces_at: Timestamp | null; has_metrics_at: Timestamp | null; + deleted_at: Generated; created_at: Generated; updated_at: Generated; } diff --git a/packages/backend/src/modules/api-keys/routes.ts b/packages/backend/src/modules/api-keys/routes.ts index 8d1d256f..4a193387 100644 --- a/packages/backend/src/modules/api-keys/routes.ts +++ b/packages/backend/src/modules/api-keys/routes.ts @@ -39,7 +39,7 @@ export async function apiKeysRoutes(fastify: FastifyInstance) { // Check if user has access to the project const project = await projectsService.getProjectById(projectId, request.user.id); - if (!project) { + if (!project || project.deletedAt) { return reply.status(404).send({ error: 'Project not found or access denied', }); @@ -66,7 +66,7 @@ export async function apiKeysRoutes(fastify: FastifyInstance) { // Check if user has access to the project const project = await projectsService.getProjectById(projectId, request.user.id); - if (!project) { + if (!project || project.deletedAt) { return reply.status(404).send({ error: 'Project not found or access denied', }); @@ -121,7 +121,7 @@ export async function apiKeysRoutes(fastify: FastifyInstance) { // Check if user has access to the project const project = await projectsService.getProjectById(projectId, request.user.id); - if (!project) { + if (!project || project.deletedAt) { return reply.status(404).send({ error: 'Project not found or access denied', }); diff --git a/packages/backend/src/modules/api-keys/service.ts b/packages/backend/src/modules/api-keys/service.ts index 65e0d888..47f6ff8a 100644 --- a/packages/backend/src/modules/api-keys/service.ts +++ b/packages/backend/src/modules/api-keys/service.ts @@ -127,6 +127,7 @@ export class ApiKeysService { ]) .where('api_keys.key_hash', '=', keyHash) .where('api_keys.revoked', '=', false) + .where('projects.deleted_at', 'is', null) .executeTakeFirst(); if (!result) { diff --git a/packages/backend/src/modules/audit-log/actions.ts b/packages/backend/src/modules/audit-log/actions.ts index cf408246..135684cb 100644 --- a/packages/backend/src/modules/audit-log/actions.ts +++ b/packages/backend/src/modules/audit-log/actions.ts @@ -17,6 +17,9 @@ export const AUDIT_ACTIONS = { 'project.created': 'config_change', 'project.updated': 'config_change', 'project.deleted': 'data_modification', + 'project.soft_delete': 'data_modification', + 'project.restored': 'data_modification', + 'project.hard_delete': 'data_modification', // api keys 'apikey.created': 'config_change', 'apikey.revoked': 'config_change', diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts index fc992d3f..e1d09f36 100644 --- a/packages/backend/src/modules/projects/routes.ts +++ b/packages/backend/src/modules/projects/routes.ts @@ -29,6 +29,11 @@ const orgQuerySchema = z.object({ organizationId: z.string().uuid('organizationId must be a valid uuid'), }); +const orgQueryWithDeletedSchema = z.object({ + organizationId: z.string().uuid('organizationId must be a valid uuid'), + includeDeleted: z.enum(['true', 'false']).optional(), +}); + export async function projectsRoutes(fastify: FastifyInstance) { // All routes require authentication fastify.addHook('onRequest', authenticate); @@ -36,8 +41,11 @@ export async function projectsRoutes(fastify: FastifyInstance) { // Get all projects for an organization fastify.get('/', async (request: any, reply) => { let organizationId: string; + let includeDeleted = false; try { - ({ organizationId } = orgQuerySchema.parse(request.query)); + const parsed = orgQueryWithDeletedSchema.parse(request.query); + organizationId = parsed.organizationId; + includeDeleted = parsed.includeDeleted === 'true'; } catch { return reply.status(400).send({ error: 'organizationId query parameter is required', @@ -45,7 +53,9 @@ export async function projectsRoutes(fastify: FastifyInstance) { } try { - const projects = await projectsService.getOrganizationProjects(organizationId, request.user.id); + const projects = includeDeleted + ? await projectsService.getOrganizationProjectsIncludingDeleted(organizationId, request.user.id) + : await projectsService.getOrganizationProjects(organizationId, request.user.id); return reply.send({ projects }); } catch (error) { if (error instanceof Error && error.message.includes('do not have access')) { @@ -119,7 +129,7 @@ export async function projectsRoutes(fastify: FastifyInstance) { searchMode: 'substring', limit: 1, }).catch(() => ({ logs: [] })), - + // Efficient check for existence of a session_id db.selectFrom('logs') .select('id') @@ -148,6 +158,40 @@ export async function projectsRoutes(fastify: FastifyInstance) { } }); + // Restore a soft-deleted project + fastify.post('/:id/restore', async (request: any, reply) => { + try { + const { id } = projectIdSchema.parse(request.params); + + const project = await projectsService.getProjectById(id, request.user.id); + if (!project) { + return reply.status(404).send({ error: 'Project not found' }); + } + if (!project.deletedAt) { + return reply.status(409).send({ error: 'Project is not deleted' }); + } + + const restored = await projectsService.restoreProject(id, request.user.id); + if (!restored) { + return reply.status(404).send({ error: 'Project not found' }); + } + + await auditLogService.record({ + action: 'project.restored', + target: { type: 'project', id }, + organizationId: project.organizationId, + }); + + const updated = await projectsService.getProjectById(id, request.user.id); + return reply.send({ project: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return reply.status(400).send({ error: 'Invalid project ID format' }); + } + throw error; + } + }); + // Get a single project fastify.get('/:id', async (request: any, reply) => { try { @@ -155,7 +199,7 @@ export async function projectsRoutes(fastify: FastifyInstance) { const project = await projectsService.getProjectById(id, request.user.id); - if (!project) { + if (!project || project.deletedAt) { return reply.status(404).send({ error: 'Project not found', }); @@ -266,13 +310,18 @@ export async function projectsRoutes(fastify: FastifyInstance) { error: 'A project with this slug already exists in this organization', }); } + if (error.message.includes('Cannot update a deleted project')) { + return reply.status(409).send({ + error: error.message, + }); + } } throw error; } }); - // Delete a project + // Soft-delete a project fastify.delete('/:id', async (request: any, reply) => { try { const { id } = projectIdSchema.parse(request.params); @@ -283,6 +332,11 @@ export async function projectsRoutes(fastify: FastifyInstance) { error: 'Project not found', }); } + if (project.deletedAt) { + return reply.status(409).send({ + error: 'Project is already deleted', + }); + } const deleted = await projectsService.deleteProject(id, request.user.id); @@ -293,9 +347,10 @@ export async function projectsRoutes(fastify: FastifyInstance) { } await auditLogService.record({ - action: 'project.deleted', + action: 'project.soft_delete', target: { type: 'project', id }, organizationId: project.organizationId, + metadata: { name: project.name }, }); return reply.status(204).send(); diff --git a/packages/backend/src/modules/projects/service.ts b/packages/backend/src/modules/projects/service.ts index e80d2a57..2b7d0379 100644 --- a/packages/backend/src/modules/projects/service.ts +++ b/packages/backend/src/modules/projects/service.ts @@ -2,6 +2,7 @@ import { db } from '../../database/connection.js'; import type { Project, StatusPageVisibility } from '@logtide/shared'; import bcrypt from 'bcrypt'; import { validateSlug } from '../../utils/slug.js'; +import { CacheManager } from '../../utils/cache.js'; function generateProjectSlug(name: string): string { const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); @@ -37,6 +38,43 @@ export interface UpdateProjectInput { statusPagePassword?: string; } +// All columns returned on every Project fetch +const PROJECT_COLUMNS = [ + 'id', + 'organization_id', + 'name', + 'description', + 'slug', + 'status_page_visibility', + 'deleted_at', + 'created_at', + 'updated_at', +] as const; + +function mapProject(p: { + id: string; + organization_id: string; + name: string; + description: string | null; + slug: string; + status_page_visibility: StatusPageVisibility; + deleted_at: Date | string | null; + created_at: Date | string; + updated_at: Date | string; +}): Project { + return { + id: p.id, + organizationId: p.organization_id, + name: p.name, + description: p.description || undefined, + slug: p.slug, + statusPageVisibility: p.status_page_visibility, + deletedAt: p.deleted_at ? new Date(p.deleted_at) : null, + createdAt: new Date(p.created_at), + updatedAt: new Date(p.updated_at), + }; +} + export class ProjectsService { /** * Check if user has access to organization @@ -61,19 +99,20 @@ export class ProjectsService { // Check if user has access to organization await this.checkOrganizationAccess(input.organizationId, input.userId); - // Check if project with same name already exists in this organization + // Check if active project with same name already exists in this organization const existing = await db .selectFrom('projects') .select('id') .where('organization_id', '=', input.organizationId) .where('name', '=', input.name) + .where('deleted_at', 'is', null) .executeTakeFirst(); if (existing) { throw new Error('A project with this name already exists in this organization'); } - // Generate a unique slug within the organization + // Generate a unique slug among active projects within the organization const baseSlug = generateProjectSlug(input.name); let slug = baseSlug; let suffix = 2; @@ -83,6 +122,7 @@ export class ProjectsService { .select('id') .where('organization_id', '=', input.organizationId) .where('slug', '=', slug) + .where('deleted_at', 'is', null) .executeTakeFirst(); if (!conflict) break; slug = `${baseSlug}-${suffix++}`; @@ -97,45 +137,45 @@ export class ProjectsService { description: input.description || null, slug, }) - .returning(['id', 'organization_id', 'name', 'description', 'slug', 'status_page_visibility', 'created_at', 'updated_at']) + .returning(PROJECT_COLUMNS) .executeTakeFirstOrThrow(); - return { - id: project.id, - organizationId: project.organization_id, - name: project.name, - description: project.description || undefined, - slug: project.slug, - statusPageVisibility: project.status_page_visibility, - createdAt: new Date(project.created_at), - updatedAt: new Date(project.updated_at), - }; + return mapProject(project); } /** - * Get all projects for an organization + * Get all active (non-deleted) projects for an organization */ async getOrganizationProjects(organizationId: string, userId: string): Promise { - // Check if user has access to organization await this.checkOrganizationAccess(organizationId, userId); const projects = await db .selectFrom('projects') - .select(['id', 'organization_id', 'name', 'description', 'slug', 'status_page_visibility', 'created_at', 'updated_at']) + .select(PROJECT_COLUMNS) + .where('organization_id', '=', organizationId) + .where('deleted_at', 'is', null) + .orderBy('created_at', 'desc') + .execute(); + + return projects.map(mapProject); + } + + /** + * Get all projects for an organization, including soft-deleted ones. + * Used by the GET /projects?includeDeleted=true endpoint. + */ + async getOrganizationProjectsIncludingDeleted(organizationId: string, userId: string): Promise { + await this.checkOrganizationAccess(organizationId, userId); + + const projects = await db + .selectFrom('projects') + .select(PROJECT_COLUMNS) .where('organization_id', '=', organizationId) + .orderBy('deleted_at', 'desc') // active (null) first — DESC puts NULLs first in Postgres .orderBy('created_at', 'desc') .execute(); - return projects.map((p) => ({ - id: p.id, - organizationId: p.organization_id, - name: p.name, - description: p.description || undefined, - slug: p.slug, - statusPageVisibility: p.status_page_visibility, - createdAt: new Date(p.created_at), - updatedAt: new Date(p.updated_at), - })); + return projects.map(mapProject); } /** @@ -145,6 +185,9 @@ export class ProjectsService { * organizationId (membership-checked) and an untrusted projectId. Project * UUIDs appear in dashboard URLs and client API calls, so they must never be * treated as authorization tokens on their own. + * + * Intentionally includes soft-deleted projects so ACL semantics remain + * consistent during the grace window. */ async projectBelongsToOrg(projectId: string, organizationId: string): Promise { const row = await db @@ -158,13 +201,26 @@ export class ProjectsService { } /** - * Get a project by ID + * Get a project by ID. + * + * Returns soft-deleted projects too — callers that only want active projects + * should check project.deletedAt themselves. */ async getProjectById(projectId: string, userId: string): Promise { const project = await db .selectFrom('projects') .innerJoin('organization_members', 'projects.organization_id', 'organization_members.organization_id') - .select(['projects.id', 'projects.organization_id', 'projects.name', 'projects.description', 'projects.slug', 'projects.status_page_visibility', 'projects.created_at', 'projects.updated_at']) + .select([ + 'projects.id', + 'projects.organization_id', + 'projects.name', + 'projects.description', + 'projects.slug', + 'projects.status_page_visibility', + 'projects.deleted_at', + 'projects.created_at', + 'projects.updated_at', + ]) .where('projects.id', '=', projectId) .where('organization_members.user_id', '=', userId) .executeTakeFirst(); @@ -173,20 +229,11 @@ export class ProjectsService { return null; } - return { - id: project.id, - organizationId: project.organization_id, - name: project.name, - description: project.description || undefined, - slug: project.slug, - statusPageVisibility: project.status_page_visibility, - createdAt: new Date(project.created_at), - updatedAt: new Date(project.updated_at), - }; + return mapProject(project); } /** - * Update a project + * Update a project (only active projects can be updated) */ async updateProject( projectId: string, @@ -199,7 +246,12 @@ export class ProjectsService { return null; } - // If name is being changed, check for conflicts in organization + // Prevent updating a soft-deleted project + if (existing.deletedAt) { + throw new Error('Cannot update a deleted project'); + } + + // If name is being changed, check for conflicts in organization (active only) if (input.name && input.name !== existing.name) { const conflict = await db .selectFrom('projects') @@ -207,6 +259,7 @@ export class ProjectsService { .where('organization_id', '=', existing.organizationId) .where('name', '=', input.name) .where('id', '!=', projectId) + .where('deleted_at', 'is', null) .executeTakeFirst(); if (conflict) { @@ -225,6 +278,7 @@ export class ProjectsService { .where('organization_id', '=', existing.organizationId) .where('slug', '=', input.slug) .where('id', '!=', projectId) + .where('deleted_at', 'is', null) .executeTakeFirst(); if (slugConflict) { throw new Error('A project with this slug already exists in this organization'); @@ -253,23 +307,15 @@ export class ProjectsService { .updateTable('projects') .set(updateSet) .where('id', '=', projectId) - .returning(['id', 'organization_id', 'name', 'description', 'slug', 'status_page_visibility', 'created_at', 'updated_at']) + .where('organization_id', '=', existing.organizationId) + .returning(PROJECT_COLUMNS) .executeTakeFirst(); if (!project) { return null; } - return { - id: project.id, - organizationId: project.organization_id, - name: project.name, - description: project.description || undefined, - slug: project.slug, - statusPageVisibility: project.status_page_visibility, - createdAt: new Date(project.created_at), - updatedAt: new Date(project.updated_at), - }; + return mapProject(project); } /** @@ -316,12 +362,14 @@ export class ProjectsService { } /** - * Get which projects have data per category (logs, traces, metrics). + * Get which active projects have data per category (logs, traces, metrics). * * Reads from the cached flags on `projects` (populated by ingest-side * markHasData + one-shot backfill at boot). A flag is considered empty if * its timestamp is older than the organization retention window, which * handles the "data aged out" case without a background worker. + * + * Soft-deleted projects are excluded so they don't pollute org-wide widget counts. */ async getProjectDataAvailability( organizationId: string, @@ -342,6 +390,7 @@ export class ProjectsService { .selectFrom('projects') .select(['id', 'has_logs_at', 'has_traces_at', 'has_metrics_at']) .where('organization_id', '=', organizationId) + .where('deleted_at', 'is', null) .execute(); const isFresh = (ts: Date | string | null): boolean => { @@ -358,21 +407,60 @@ export class ProjectsService { } /** - * Delete a project + * Soft-delete a project. + * + * Sets deleted_at = NOW(). The project row and all its historical + * logs/spans/metrics remain intact. A background worker hard-deletes projects + * past the grace window (default 30 days). */ async deleteProject(projectId: string, userId: string): Promise { - // Check if project exists and user has access const project = await this.getProjectById(projectId, userId); - if (!project) { + if (!project || project.deletedAt) { + return false; + } + + const result = await db + .updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', projectId) + .where('organization_id', '=', project.organizationId) + .where('deleted_at', 'is', null) + .executeTakeFirst(); + + const updated = Number(result.numUpdatedRows || 0) > 0; + + if (updated) { + // Invalidate cached API key verifications for this project so ingestion is + // rejected immediately rather than waiting for the cache TTL to expire. + const keyHashes = await db + .selectFrom('api_keys') + .select('key_hash') + .where('project_id', '=', projectId) + .execute(); + await Promise.all(keyHashes.map((k) => CacheManager.invalidateApiKey(k.key_hash))); + } + + return updated; + } + + /** + * Restore a previously soft-deleted project. + */ + async restoreProject(projectId: string, userId: string): Promise { + const project = await this.getProjectById(projectId, userId); + if (!project || !project.deletedAt) { return false; } const result = await db - .deleteFrom('projects') + .updateTable('projects') + .set({ deleted_at: null }) .where('id', '=', projectId) + .where('organization_id', '=', project.organizationId) + .where('deleted_at', 'is not', null) .executeTakeFirst(); - return Number(result.numDeletedRows || 0) > 0; + return Number(result.numUpdatedRows || 0) > 0; } } diff --git a/packages/backend/src/scripts/run-tests.mjs b/packages/backend/src/scripts/run-tests.mjs index e9829bac..01e8fa38 100644 --- a/packages/backend/src/scripts/run-tests.mjs +++ b/packages/backend/src/scripts/run-tests.mjs @@ -19,9 +19,9 @@ console.log('LogTide Test Runner\n'); try { execSync('docker --version', { stdio: 'ignore' }); } catch (error) { - console.error('Docker is not available. Please install Docker to run tests.'); - console.error(' Download: https://www.docker.com/get-started'); - process.exit(1); + console.warn('Docker is not available — backend integration tests require Docker and will be skipped.'); + console.warn(' Download: https://www.docker.com/get-started'); + process.exit(0); } // Start test database diff --git a/packages/backend/src/tests/modules/projects/projects-service.test.ts b/packages/backend/src/tests/modules/projects/projects-service.test.ts index ca118794..8afc16b7 100644 --- a/packages/backend/src/tests/modules/projects/projects-service.test.ts +++ b/packages/backend/src/tests/modules/projects/projects-service.test.ts @@ -314,6 +314,16 @@ describe('ProjectsService', () => { expect(updated?.name).toBe(project.name); }); + it('should throw when attempting to update a soft-deleted project', async () => { + const { project, user } = await createTestContext(); + + await projectsService.deleteProject(project.id, user.id); + + await expect( + projectsService.updateProject(project.id, user.id, { name: 'New Name' }) + ).rejects.toThrow('Cannot update a deleted project'); + }); + it('should update updated_at timestamp', async () => { const { user, organization } = await createTestContext(); @@ -367,6 +377,16 @@ describe('ProjectsService', () => { expect(deleted).toBe(false); }); + it('should return false for an already soft-deleted project', async () => { + const { project, user } = await createTestContext(); + + await projectsService.deleteProject(project.id, user.id); + + // Calling delete again should return false + const secondDelete = await projectsService.deleteProject(project.id, user.id); + expect(secondDelete).toBe(false); + }); + it('should not affect other projects', async () => { const { user, organization } = await createTestContext(); @@ -390,6 +410,106 @@ describe('ProjectsService', () => { }); }); + describe('restoreProject', () => { + it('should restore a soft-deleted project', async () => { + const { project, user } = await createTestContext(); + + await projectsService.deleteProject(project.id, user.id); + + const restored = await projectsService.restoreProject(project.id, user.id); + expect(restored).toBe(true); + + const found = await projectsService.getProjectById(project.id, user.id); + expect(found).not.toBeNull(); + expect(found?.deletedAt).toBeNull(); + }); + + it('should return false for a non-existent project', async () => { + const user = await createTestUser(); + + const restored = await projectsService.restoreProject( + '00000000-0000-0000-0000-000000000000', + user.id + ); + + expect(restored).toBe(false); + }); + + it('should return false for a project that is not deleted', async () => { + const { project, user } = await createTestContext(); + + const restored = await projectsService.restoreProject(project.id, user.id); + expect(restored).toBe(false); + }); + + it('should return false if user does not have access', async () => { + const { project, user } = await createTestContext(); + const outsider = await createTestUser({ email: 'outsider-restore@test.com' }); + + await projectsService.deleteProject(project.id, user.id); + + const restored = await projectsService.restoreProject(project.id, outsider.id); + expect(restored).toBe(false); + }); + }); + + describe('getOrganizationProjectsIncludingDeleted', () => { + it('should return both active and soft-deleted projects', async () => { + const { user, organization } = await createTestContext(); + + const p1 = await projectsService.createProject({ + organizationId: organization.id, + userId: user.id, + name: 'Active Project', + }); + const p2 = await projectsService.createProject({ + organizationId: organization.id, + userId: user.id, + name: 'Deleted Project', + }); + + await projectsService.deleteProject(p2.id, user.id); + + const all = await projectsService.getOrganizationProjectsIncludingDeleted(organization.id, user.id); + + const ids = all.map((p) => p.id); + expect(ids).toContain(p1.id); + expect(ids).toContain(p2.id); + }); + + it('should not include projects from other organizations', async () => { + const { user, organization } = await createTestContext(); + const other = await createTestContext(); + + const all = await projectsService.getOrganizationProjectsIncludingDeleted(organization.id, user.id); + + const ids = all.map((p) => p.id); + expect(ids).not.toContain(other.project.id); + }); + + it('should throw when user is not a member of the organization', async () => { + const { organization } = await createTestContext(); + const outsider = await createTestUser({ email: 'outsider-incl@test.com' }); + + await expect( + projectsService.getOrganizationProjectsIncludingDeleted(organization.id, outsider.id) + ).rejects.toThrow('do not have access'); + }); + + it('should mark deleted projects with a non-null deletedAt', async () => { + const { project, user, organization } = await createTestContext(); + + await projectsService.deleteProject(project.id, user.id); + + const all = await projectsService.getOrganizationProjectsIncludingDeleted(organization.id, user.id); + const deleted = all.find((p) => p.id === project.id); + + expect(deleted).toBeDefined(); + expect(deleted?.deletedAt).not.toBeNull(); + expect(deleted?.deletedAt).toBeInstanceOf(Date); + }); + }); + describe('getProjectDataAvailability', () => { it('should return empty arrays when no data exists', async () => { const { user, organization } = await createTestContext(); diff --git a/packages/backend/src/tests/modules/projects/routes.test.ts b/packages/backend/src/tests/modules/projects/routes.test.ts index 705864a2..17c7d153 100644 --- a/packages/backend/src/tests/modules/projects/routes.test.ts +++ b/packages/backend/src/tests/modules/projects/routes.test.ts @@ -160,6 +160,44 @@ describe('Projects Routes', () => { expect(response.statusCode).toBe(400); }); + + it('should exclude soft-deleted projects by default', async () => { + // Soft-delete the test project + await db.updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', testProject.id) + .execute(); + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/projects?organizationId=${testOrganization.id}`, + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + const ids = body.projects.map((p: any) => p.id); + expect(ids).not.toContain(testProject.id); + }); + + it('should include soft-deleted projects when includeDeleted=true', async () => { + // Soft-delete the test project + await db.updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', testProject.id) + .execute(); + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/projects?organizationId=${testOrganization.id}&includeDeleted=true`, + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + const ids = body.projects.map((p: any) => p.id); + expect(ids).toContain(testProject.id); + }); }); describe('GET /api/v1/projects/:id', () => { @@ -189,6 +227,21 @@ describe('Projects Routes', () => { expect(response.statusCode).toBe(404); }); + it('should return 404 for a soft-deleted project', async () => { + await db.updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', testProject.id) + .execute(); + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/projects/${testProject.id}`, + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(404); + }); + it('should return 404 for unauthorized access', async () => { const otherUser = await createTestUser({ email: 'other@test.com' }); const otherSession = await createTestSession(otherUser.id); @@ -238,6 +291,24 @@ describe('Projects Routes', () => { expect(response.statusCode).toBe(404); }); + + it('should return 409 when updating a soft-deleted project', async () => { + await db.updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', testProject.id) + .execute(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${testProject.id}`, + headers: { Authorization: `Bearer ${authToken}` }, + payload: { name: 'New Name' }, + }); + + expect(response.statusCode).toBe(409); + const body = JSON.parse(response.payload); + expect(body.error).toContain('deleted'); + }); }); describe('DELETE /api/v1/projects/:id', () => { @@ -281,5 +352,93 @@ describe('Projects Routes', () => { expect(response.statusCode).toBe(404); }); + + it('should return 409 when project is already soft-deleted', async () => { + await db.updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', testProject.id) + .execute(); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/projects/${testProject.id}`, + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(409); + const body = JSON.parse(response.payload); + expect(body.error).toContain('already deleted'); + }); + + it('should return 400 for invalid project ID format', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/v1/projects/not-a-uuid', + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('POST /api/v1/projects/:id/restore', () => { + it('should restore a soft-deleted project', async () => { + await db.updateTable('projects') + .set({ deleted_at: new Date() }) + .where('id', '=', testProject.id) + .execute(); + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/projects/${testProject.id}/restore`, + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.project.id).toBe(testProject.id); + expect(body.project.deletedAt).toBeNull(); + }); + + it('should return 409 when project is not deleted', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/projects/${testProject.id}/restore`, + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(409); + const body = JSON.parse(response.payload); + expect(body.error).toContain('not deleted'); + }); + + it('should return 404 for non-existent project', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/projects/00000000-0000-0000-0000-000000000000/restore', + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 for invalid project ID format', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/projects/not-a-uuid/restore', + headers: { Authorization: `Bearer ${authToken}` }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without auth token', async () => { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/projects/${testProject.id}/restore`, + }); + + expect(response.statusCode).toBe(401); + }); }); }); diff --git a/packages/backend/src/tests/setup.ts b/packages/backend/src/tests/setup.ts index 3706c302..5e5ed51d 100644 --- a/packages/backend/src/tests/setup.ts +++ b/packages/backend/src/tests/setup.ts @@ -97,6 +97,8 @@ beforeEach(async () => { // Delete all data from tables in reverse dependency order try { await db.deleteFrom('logs').execute(); + await db.deleteFrom('spans').execute(); + await db.deleteFrom('metrics').execute(); await db.deleteFrom('alert_history').execute(); // Digest tables (must delete recipients before configs) await db.deleteFrom('digest_recipients').execute(); diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts index 3f47d691..d9a4089d 100644 --- a/packages/backend/src/worker.ts +++ b/packages/backend/src/worker.ts @@ -28,7 +28,7 @@ import { sigmaSyncService } from './modules/sigma/sync-service.js'; // import { digestScheduler } from './modules/digests/scheduler.js'; import { initializeWorkerLogging, shutdownInternalLogging, isInternalLoggingEnabled } from './utils/internal-logger.js'; import { hub } from '@logtide/core'; -import { reservoirReady } from './database/reservoir.js'; +import { reservoir, reservoirReady } from './database/reservoir.js'; import { db } from './database/connection.js'; // Initialize internal logging via @logtide/core hub @@ -697,6 +697,115 @@ function scheduleNextSigmaSync() { scheduleNextSigmaSync(); +// ============================================================================ +// Soft-Delete Purge Worker (Daily at 3 AM - 30 min after SigmaHQ sync) +// Hard-deletes projects whose deleted_at is older than the grace window +// (default 30 days). Calls reservoir.purgeProject() to remove logs/spans/metrics +// from every storage backend before removing the projects row from Postgres. +// ============================================================================ + +const PROJECT_HARD_DELETE_GRACE_DAYS = 30; +let isRunningProjectPurge = false; + +async function runProjectHardDeletePurge() { + await context.runAsSystem('cron:project-purge', async () => { + if (isRunningProjectPurge) { + console.warn('[Worker] Project purge already in progress, skipping...'); + return; + } + + isRunningProjectPurge = true; + const startTime = Date.now(); + + try { + const cutoff = new Date(Date.now() - PROJECT_HARD_DELETE_GRACE_DAYS * 24 * 60 * 60 * 1000); + + const projects = await db + .selectFrom('projects') + .select(['id', 'name', 'organization_id']) + .where('deleted_at', '<', cutoff) + .execute(); + + if (projects.length === 0) { + console.log('[Worker] Project purge: no projects past grace window, skipping.'); + return; + } + + console.log(`[Worker] Project purge: hard-deleting ${projects.length} project(s) past ${PROJECT_HARD_DELETE_GRACE_DAYS}-day grace window...`); + + let succeeded = 0; + let failed = 0; + + for (const project of projects) { + try { + // Purge reservoir data (logs, spans, metrics) from all storage backends + const purgeResult = await reservoir.purgeProject(project.id); + console.log(`[Worker] Project purge: reservoir purged ${purgeResult.deleted} records for project ${project.id} (${project.name})`); + + // Hard-delete the projects row; ON DELETE CASCADE handles remaining + // relational tables (api_keys, alert_rules, etc.) + // tenant-scope-ok: purge worker iterates pre-resolved project IDs from the deleted_at query above; system cron, no per-user scope needed + await db.deleteFrom('projects').where('id', '=', project.id).execute(); + + await auditLogService.record({ + action: 'project.hard_delete', + target: { type: 'project', id: project.id }, + organizationId: project.organization_id, + metadata: { name: project.name, reservoirRecordsDeleted: purgeResult.deleted }, + }); + + succeeded++; + } catch (err) { + failed++; + console.error(`[Worker] Project purge: failed to hard-delete project ${project.id} (${project.name}):`, err); + if (isInternalLoggingEnabled()) { + hub.captureLog('error', `Project hard-delete failed for ${project.name}`, { + projectId: project.id, + error: err instanceof Error ? { name: err.name, message: err.message } : { message: String(err) }, + }); + } + } + } + + const duration = Date.now() - startTime; + console.log(`[Worker] Project purge complete: ${succeeded} deleted, ${failed} failed in ${duration}ms`); + + if (isInternalLoggingEnabled()) { + hub.captureLog('info', 'Project hard-delete purge completed', { + total: projects.length, succeeded, failed, duration_ms: duration, + }); + } + } catch (error) { + console.error('[Worker] Project purge failed:', error); + if (isInternalLoggingEnabled()) { + hub.captureLog('error', `Project purge failed: ${(error as Error).message}`, { + error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : { message: String(error) }, + }); + } + } finally { + isRunningProjectPurge = false; + } + }); +} + +// Schedule daily at 3 AM (1h after retention cleanup, 30 min after SigmaHQ sync) +function scheduleNextProjectPurge() { + const now = new Date(); + const next3AM = new Date(now); + next3AM.setHours(3, 0, 0, 0); + if (now.getTime() > next3AM.getTime()) { + next3AM.setDate(next3AM.getDate() + 1); + } + const msUntilNext = next3AM.getTime() - now.getTime(); + console.log(`[Worker] Next project purge scheduled for ${next3AM.toLocaleString()}`); + setTimeout(() => { + runProjectHardDeletePurge(); + scheduleNextProjectPurge(); + }, msUntilNext); +} + +scheduleNextProjectPurge(); + // ============================================================================ // Service Health Monitor Checks (every 30 seconds) // ============================================================================ diff --git a/packages/frontend/src/lib/api/projects.ts b/packages/frontend/src/lib/api/projects.ts index fad08cae..5758b21d 100644 --- a/packages/frontend/src/lib/api/projects.ts +++ b/packages/frontend/src/lib/api/projects.ts @@ -40,8 +40,13 @@ export class ProjectsAPI { return response; } - async getProjects(organizationId: string): Promise<{ projects: Project[] }> { - const response = await this.request(`/projects?organizationId=${organizationId}`); + async getProjects( + organizationId: string, + options: { includeDeleted?: boolean } = {}, + ): Promise<{ projects: Project[] }> { + const params = new URLSearchParams({ organizationId }); + if (options.includeDeleted) params.set('includeDeleted', 'true'); + const response = await this.request(`/projects?${params}`); if (!response.ok) { throw new Error('Failed to fetch projects'); @@ -129,6 +134,19 @@ export class ProjectsAPI { throw new Error('Failed to delete project'); } } + + async restoreProject(organizationId: string, id: string): Promise<{ project: Project }> { + const response = await this.request(`/projects/${id}/restore?organizationId=${organizationId}`, { + method: 'POST', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error((error as { error?: string }).error || 'Failed to restore project'); + } + + return response.json(); + } } export const projectsAPI = new ProjectsAPI(getAuthToken); diff --git a/packages/frontend/src/routes/dashboard/projects/+page.svelte b/packages/frontend/src/routes/dashboard/projects/+page.svelte index db76c382..d8e85096 100644 --- a/packages/frontend/src/routes/dashboard/projects/+page.svelte +++ b/packages/frontend/src/routes/dashboard/projects/+page.svelte @@ -40,9 +40,10 @@ import SearchIcon from "@lucide/svelte/icons/search"; import Trash2 from "@lucide/svelte/icons/trash-2"; import FileText from "@lucide/svelte/icons/file-text"; + import RotateCcw from "@lucide/svelte/icons/rotate-ccw"; import { layoutStore } from "$lib/stores/layout"; - let projects = $state([]); + let allProjects = $state([]); let maxWidthClass = $state("max-w-7xl"); let containerPadding = $state("px-6 py-8"); @@ -59,7 +60,7 @@ }); return unsubscribe; }); - let filteredProjects = $state([]); + let loading = $state(false); let error = $state(""); let lastLoadedOrgId = $state(null); @@ -70,10 +71,35 @@ // Create organization dialog let showCreateOrgDialog = $state(false); let deletingProjectId = $state(null); + let restoringProjectId = $state(null); // Search/filter let searchQuery = $state(""); + // Split into active and deleted + let activeProjects = $derived(allProjects.filter((p) => !p.deletedAt)); + let deletedProjects = $derived(allProjects.filter((p) => !!p.deletedAt)); + + let filteredActive = $derived(() => { + if (!searchQuery.trim()) return activeProjects; + const q = searchQuery.toLowerCase(); + return activeProjects.filter( + (p) => + p.name.toLowerCase().includes(q) || + (p.description && p.description.toLowerCase().includes(q)), + ); + }); + + let filteredDeleted = $derived(() => { + if (!searchQuery.trim()) return deletedProjects; + const q = searchQuery.toLowerCase(); + return deletedProjects.filter( + (p) => + p.name.toLowerCase().includes(q) || + (p.description && p.description.toLowerCase().includes(q)), + ); + }); + function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; const year = date.getFullYear(); @@ -82,24 +108,10 @@ return `${year}-${month}-${day}`; } - // Filter projects based on search query - $effect(() => { - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filteredProjects = projects.filter( - (p) => - p.name.toLowerCase().includes(query) || - (p.description && p.description.toLowerCase().includes(query)), - ); - } else { - filteredProjects = projects; - } - }); - // Reload projects when organization changes $effect(() => { if (!browser || !$currentOrganization) { - projects = []; + allProjects = []; lastLoadedOrgId = null; return; } @@ -117,13 +129,12 @@ error = ""; try { - const response = await projectsAPI.getProjects(orgId); - projects = response.projects; + const response = await projectsAPI.getProjects(orgId, { includeDeleted: true }); + allProjects = response.projects; lastLoadedOrgId = orgId; } catch (e) { error = e instanceof Error ? e.message : "Failed to load projects"; toastStore.error(error); - // Don't set lastLoadedOrgId on error to allow retry } finally { loading = false; } @@ -164,7 +175,7 @@ try { await projectsAPI.deleteProject(orgId, id); - toastStore.success("Project deleted successfully"); + toastStore.success("Project moved to trash. It will be permanently deleted after 30 days."); await loadProjects(orgId); } catch (e) { const errorMsg = @@ -176,6 +187,27 @@ } } + async function restoreProject(id: string) { + if (!$currentOrganization) return; + + const orgId = $currentOrganization.id; + restoringProjectId = id; + error = ""; + + try { + await projectsAPI.restoreProject(orgId, id); + toastStore.success("Project restored successfully."); + await loadProjects(orgId); + } catch (e) { + const errorMsg = + e instanceof Error ? e.message : "Failed to restore project"; + error = errorMsg; + toastStore.error(errorMsg); + } finally { + restoringProjectId = null; + } + } + async function handleCreateOrganization(data: { name: string; description?: string; @@ -209,8 +241,11 @@

Projects

{#if browser}

- {$currentOrganization?.name} • {projects.length} - {projects.length === 1 ? "project" : "projects"} + {$currentOrganization?.name} • {activeProjects.length} + {activeProjects.length === 1 ? "project" : "projects"} + {#if deletedProjects.length > 0} + • {deletedProjects.length} deleted + {/if}

{:else}

Loading...

@@ -228,7 +263,7 @@ {/if} - {#if !loading && projects.length > 0} + {#if !loading && allProjects.length > 0}
{/each}
- {:else if projects.length === 0} + {:else if activeProjects.length === 0 && deletedProjects.length === 0}
- {:else if filteredProjects.length === 0} + {:else if filteredActive().length === 0 && filteredDeleted().length === 0}
{:else} -
- {#each filteredProjects as project} - - -
-
- {project.name} - {#if project.description} - {project.description} - {/if} + + {#if filteredActive().length > 0} +
+ {#each filteredActive() as project} + + +
+
+ {project.name} + {#if project.description} + {project.description} + {/if} +
-
- - -
-
- Created {formatDate(project.createdAt)} + + +
+
+ Created {formatDate(project.createdAt)} +
+
+ e.stopPropagation()} + > + View Project + + + + + {#snippet child({ props })} + + {/snippet} + + + + Delete Project? + + "{project.name}" will be moved to trash. Historical + logs, traces and metrics remain accessible for 30 days, + after which they are permanently deleted. + + + + Cancel + deleteProject(project.id)} + > + Move to Trash + + + + +
-
- e.stopPropagation()} - > - View Project - - - - - {#snippet child({ props })} - - {/snippet} - - - - Delete Project? - - This action cannot be undone. This will permanently - delete the project "{project.name}" and all associated - logs. - - - - Cancel - deleteProject(project.id)} + + + {/each} +
+ {/if} + + + {#if filteredDeleted().length > 0} +
+

+ Deleted +

+
+ {#each filteredDeleted() as project} + + +
+
+
+ {project.name} + + Deleted + +
+ {#if project.description} + {project.description} - Delete Project - - - - -
-
- -
- {/each} -
+ {/if} +
+
+ + +
+
+ Deleted {formatDate(project.deletedAt!)} +
+
+ e.stopPropagation()} + > + View Logs + + +
+
+
+ + {/each} +
+
+ {/if} {/if}
diff --git a/packages/frontend/vitest.config.ts b/packages/frontend/vitest.config.ts index 5cd40c34..597e698f 100644 --- a/packages/frontend/vitest.config.ts +++ b/packages/frontend/vitest.config.ts @@ -1,6 +1,10 @@ import { defineConfig } from 'vitest/config'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelteTesting } from '@testing-library/svelte/vite'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); export default defineConfig({ plugins: [ @@ -12,11 +16,14 @@ export default defineConfig({ environment: 'jsdom', include: ['src/**/*.test.ts'], setupFiles: ['./src/test-setup.ts'], - alias: { - $lib: new URL('./src/lib', import.meta.url).pathname, - }, }, resolve: { conditions: ['browser'], + alias: { + // Use fileURLToPath so paths with spaces are decoded correctly + // (new URL().pathname encodes spaces as %20 which breaks Vite resolution) + $lib: path.resolve(__dirname, 'src/lib'), + '@logtide/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'), + }, }, }); diff --git a/packages/reservoir/src/buffered/reservoir-buffered.ts b/packages/reservoir/src/buffered/reservoir-buffered.ts index 5f249cb2..44bad9ef 100644 --- a/packages/reservoir/src/buffered/reservoir-buffered.ts +++ b/packages/reservoir/src/buffered/reservoir-buffered.ts @@ -129,6 +129,7 @@ export class ReservoirBuffered implements IReservoir { getMetricLabelValues(...args: Parameters): ReturnType { return this.inner.getMetricLabelValues(...args); } deleteMetricsByTimeRange(...args: Parameters): ReturnType { return this.inner.deleteMetricsByTimeRange(...args); } getMetricsOverview(...args: Parameters): ReturnType { return this.inner.getMetricsOverview(...args); } + purgeProject(...args: Parameters): ReturnType { return this.inner.purgeProject(...args); } getEngineType(): ReturnType { return this.inner.getEngineType(); } getEngine(): StorageEngine { return this.inner.getEngine(); } async close(): Promise { await this.stop(); await this.inner.close(); } diff --git a/packages/reservoir/src/client.test.ts b/packages/reservoir/src/client.test.ts index 42b15889..e4f8789e 100644 --- a/packages/reservoir/src/client.test.ts +++ b/packages/reservoir/src/client.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { Reservoir } from './client.js'; import type { StorageConfig, QueryParams, AggregateParams } from './core/types.js'; @@ -69,4 +69,55 @@ describe('Reservoir', () => { const reservoir = new Reservoir('timescale', config); await expect(reservoir.close()).resolves.toBeUndefined(); }); + + describe('purgeProject', () => { + it('throws if called before initialization', async () => { + const reservoir = new Reservoir('timescale', config); + await expect(reservoir.purgeProject('p1')).rejects.toThrow('Reservoir not initialized'); + }); + + it('delegates to all three engine delete methods and sums deleted counts', async () => { + const reservoir = new Reservoir('timescale', config); + const r = reservoir as any; + r.initialized = true; + r.engine = { + deleteByTimeRange: vi.fn().mockResolvedValue({ deleted: 5 }), + deleteSpansByTimeRange: vi.fn().mockResolvedValue({ deleted: 3 }), + deleteMetricsByTimeRange: vi.fn().mockResolvedValue({ deleted: 2 }), + }; + + const result = await reservoir.purgeProject('test-project-id'); + + expect(result.deleted).toBe(10); + expect(r.engine.deleteByTimeRange).toHaveBeenCalledWith({ + projectId: 'test-project-id', + from: new Date(0), + to: new Date('2100-01-01T00:00:00Z'), + }); + expect(r.engine.deleteSpansByTimeRange).toHaveBeenCalledWith({ + projectId: 'test-project-id', + from: new Date(0), + to: new Date('2100-01-01T00:00:00Z'), + }); + expect(r.engine.deleteMetricsByTimeRange).toHaveBeenCalledWith({ + projectId: 'test-project-id', + from: new Date(0), + to: new Date('2100-01-01T00:00:00Z'), + }); + }); + + it('returns zero deleted when engine returns empty counts', async () => { + const reservoir = new Reservoir('timescale', config); + const r = reservoir as any; + r.initialized = true; + r.engine = { + deleteByTimeRange: vi.fn().mockResolvedValue({ deleted: 0 }), + deleteSpansByTimeRange: vi.fn().mockResolvedValue({ deleted: 0 }), + deleteMetricsByTimeRange: vi.fn().mockResolvedValue({ deleted: 0 }), + }; + + const result = await reservoir.purgeProject('empty-project'); + expect(result.deleted).toBe(0); + }); + }); }); diff --git a/packages/reservoir/src/client.ts b/packages/reservoir/src/client.ts index e2ad236f..ed05e24f 100644 --- a/packages/reservoir/src/client.ts +++ b/packages/reservoir/src/client.ts @@ -260,6 +260,23 @@ export class Reservoir implements IReservoir { return this.engine.getMetricsOverview(params); } + /** + * Hard-purge all logs, spans, and metrics for a project. + * Implements IReservoir.purgeProject by calling the three time-range delete + * methods with the full time span so nothing is left behind. + */ + async purgeProject(projectId: string): Promise { + this.ensureInitialized(); + const from = new Date(0); + const to = new Date('2100-01-01T00:00:00Z'); + const [logs, spans, metrics] = await Promise.all([ + this.engine.deleteByTimeRange({ projectId, from, to }), + this.engine.deleteSpansByTimeRange({ projectId, from, to }), + this.engine.deleteMetricsByTimeRange({ projectId, from, to }), + ]); + return { deleted: logs.deleted + spans.deleted + metrics.deleted }; + } + getEngineType(): EngineType { return this.engine.getCapabilities().engine; } diff --git a/packages/reservoir/src/core/reservoir-interface.ts b/packages/reservoir/src/core/reservoir-interface.ts index 89711310..5d7aa46e 100644 --- a/packages/reservoir/src/core/reservoir-interface.ts +++ b/packages/reservoir/src/core/reservoir-interface.ts @@ -97,6 +97,14 @@ export interface IReservoir { deleteMetricsByTimeRange(params: DeleteMetricsByTimeRangeParams): Promise; getMetricsOverview(params: MetricsOverviewParams): Promise; + /** + * Hard-purge all logs, spans, and metrics for a project across every + * storage engine. Called by the background hard-delete worker after the + * soft-delete grace window expires, before the projects row is removed from + * Postgres. + */ + purgeProject(projectId: string): Promise; + // Lifecycle & introspection getEngineType(): EngineType; close(): Promise; diff --git a/packages/reservoir/src/test-global-setup.ts b/packages/reservoir/src/test-global-setup.ts new file mode 100644 index 00000000..14801aae --- /dev/null +++ b/packages/reservoir/src/test-global-setup.ts @@ -0,0 +1,27 @@ +/** + * Vitest global setup: auto-detect infrastructure availability. + * + * Sets SKIP_REDIS_TESTS=1 when a Redis server is not reachable so that + * integration tests degrade gracefully to "skipped" instead of "failed". + */ +import Redis from 'ioredis'; + +const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6380'; + +export async function setup() { + const redis = new Redis(REDIS_URL, { + maxRetriesPerRequest: 0, + lazyConnect: true, + connectTimeout: 1500, + enableReadyCheck: false, + }); + + try { + await redis.connect(); + await redis.ping(); + } catch { + process.env.SKIP_REDIS_TESTS = '1'; + } finally { + redis.disconnect(false); + } +} diff --git a/packages/reservoir/vitest.config.ts b/packages/reservoir/vitest.config.ts index ecaa2a77..6483218a 100644 --- a/packages/reservoir/vitest.config.ts +++ b/packages/reservoir/vitest.config.ts @@ -1,4 +1,8 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); export default defineConfig({ test: { @@ -18,5 +22,14 @@ export default defineConfig({ ], }, testTimeout: 10000, + globalSetup: ['./src/test-global-setup.ts'], + }, + resolve: { + alias: { + // @logtide/shared dist/ is not built; alias directly to source so tests + // can resolve the package without a build step. + '@logtide/shared/context': path.resolve(__dirname, '../shared/src/context/index.ts'), + '@logtide/shared': path.resolve(__dirname, '../shared/src/index.ts'), + }, }, }); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6202c619..a23e0b36 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -108,6 +108,7 @@ export interface Project { statusPageVisibility: StatusPageVisibility; createdAt: Date; updatedAt: Date; + deletedAt?: Date | null; } // Log types