|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + * |
| 4 | + * Set FORK_REVISION_TEST_DATABASE_URL to a local PostgreSQL database. Each run uses an |
| 5 | + * isolated schema and executes the real revision query against execution-heavy workspaces. |
| 6 | + */ |
| 7 | +import * as schema from '@sim/db/schema' |
| 8 | +import { generateShortId } from '@sim/utils/id' |
| 9 | +import { drizzle } from 'drizzle-orm/postgres-js' |
| 10 | +import postgres from 'postgres' |
| 11 | +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' |
| 12 | +import { |
| 13 | + assertForkPreviewFresh, |
| 14 | + loadForkPreviewRevision, |
| 15 | +} from '@/ee/workspace-forking/application/revision' |
| 16 | + |
| 17 | +vi.unmock('drizzle-orm') |
| 18 | +vi.unmock('@sim/db/schema') |
| 19 | + |
| 20 | +const databaseUrl = process.env.FORK_REVISION_TEST_DATABASE_URL |
| 21 | +if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) { |
| 22 | + throw new Error('Fork revision PostgreSQL tests require a local database') |
| 23 | +} |
| 24 | + |
| 25 | +describe.runIf(Boolean(databaseUrl))('fork revision scope in PostgreSQL', () => { |
| 26 | + const testSchema = `fork_revision_${generateShortId() |
| 27 | + .replace(/[^a-zA-Z0-9]/g, '') |
| 28 | + .toLowerCase()}` |
| 29 | + let client: ReturnType<typeof postgres> |
| 30 | + let executor: ReturnType<typeof drizzle<typeof schema>> |
| 31 | + const scope = { |
| 32 | + sourceWorkspaceId: 'source', |
| 33 | + targetWorkspaceId: 'target', |
| 34 | + edge: { parentWorkspaceId: 'target', childWorkspaceId: 'source' }, |
| 35 | + } |
| 36 | + |
| 37 | + beforeAll(async () => { |
| 38 | + client = postgres(databaseUrl!, { max: 1, connection: { search_path: testSchema } }) |
| 39 | + executor = drizzle(client, { schema }) |
| 40 | + await client.unsafe(`CREATE SCHEMA ${testSchema}`) |
| 41 | + await client.unsafe(` |
| 42 | + CREATE TABLE workspace ( |
| 43 | + id text PRIMARY KEY, organization_id text, name text, |
| 44 | + storage_used_bytes bigint DEFAULT 0, updated_at timestamp |
| 45 | + ); |
| 46 | + CREATE TABLE workflow ( |
| 47 | + id text PRIMARY KEY, workspace_id text, name text, archived_at timestamp, |
| 48 | + fork_sync_excluded boolean DEFAULT false, is_deployed boolean DEFAULT true, |
| 49 | + run_count integer DEFAULT 0, last_run_at timestamp, updated_at timestamp |
| 50 | + ); |
| 51 | + CREATE TABLE workflow_deployment_version ( |
| 52 | + id text PRIMARY KEY, workflow_id text, is_active boolean, state jsonb |
| 53 | + ); |
| 54 | + CREATE TABLE workspace_files ( |
| 55 | + id text PRIMARY KEY, workspace_id text, context text, deleted_at timestamp, |
| 56 | + key text, original_name text, size_bytes bigint, content_updated_at timestamp |
| 57 | + ); |
| 58 | + CREATE INDEX ON workspace_files (workspace_id) |
| 59 | + WHERE context = 'workspace' AND deleted_at IS NULL; |
| 60 | + CREATE TABLE permissions (id text PRIMARY KEY, entity_id text, entity_type text, permission_type text); |
| 61 | + CREATE TABLE custom_block (id text PRIMARY KEY, organization_id text, workflow_id text); |
| 62 | + `) |
| 63 | + for (const table of ['workflow_blocks', 'workflow_edges', 'workflow_subflows', 'webhook']) { |
| 64 | + await client.unsafe( |
| 65 | + `CREATE TABLE ${table} (id text PRIMARY KEY, workflow_id text, data jsonb)` |
| 66 | + ) |
| 67 | + } |
| 68 | + for (const table of [ |
| 69 | + 'folder', |
| 70 | + 'user_table_definitions', |
| 71 | + 'knowledge_base', |
| 72 | + 'custom_tools', |
| 73 | + 'skill', |
| 74 | + 'mcp_servers', |
| 75 | + 'credential', |
| 76 | + 'workspace_environment', |
| 77 | + 'workspace_sandbox', |
| 78 | + ]) { |
| 79 | + await client.unsafe( |
| 80 | + `CREATE TABLE ${table} (id text PRIMARY KEY, workspace_id text, data jsonb)` |
| 81 | + ) |
| 82 | + } |
| 83 | + for (const table of [ |
| 84 | + 'workspace_fork_resource_map', |
| 85 | + 'workspace_fork_block_map', |
| 86 | + 'workspace_fork_dependent_value', |
| 87 | + ]) { |
| 88 | + await client.unsafe( |
| 89 | + `CREATE TABLE ${table} (id text PRIMARY KEY, child_workspace_id text, data jsonb)` |
| 90 | + ) |
| 91 | + } |
| 92 | + await client`INSERT INTO workspace (id, name) VALUES ('source', 'Source'), ('target', 'Target')` |
| 93 | + await client`INSERT INTO workflow (id, workspace_id, name) |
| 94 | + VALUES ('source-workflow', 'source', 'Source workflow'), ('target-workflow', 'target', 'Target workflow')` |
| 95 | + await client`INSERT INTO workflow_deployment_version (id, workflow_id, is_active, state) |
| 96 | + VALUES ('deployment', 'source-workflow', true, '{"blocks":{}}')` |
| 97 | + }) |
| 98 | + |
| 99 | + afterAll(async () => { |
| 100 | + if (!client) return |
| 101 | + await client.unsafe(`DROP SCHEMA IF EXISTS ${testSchema} CASCADE`) |
| 102 | + await client.end() |
| 103 | + }) |
| 104 | + |
| 105 | + beforeEach(async () => { |
| 106 | + await client`TRUNCATE workspace_files, workflow_blocks, permissions` |
| 107 | + await client`UPDATE workspace SET storage_used_bytes = 0, updated_at = null` |
| 108 | + await client`INSERT INTO workspace_files |
| 109 | + (id, workspace_id, context, key, original_name, size_bytes, content_updated_at) |
| 110 | + VALUES ('source-file', 'source', 'workspace', 'workspace/source/file', 'file.txt', 24, '2026-09-01'), |
| 111 | + ('target-file', 'target', 'workspace', 'workspace/target/file', 'file.txt', 24, '2026-09-01')` |
| 112 | + }) |
| 113 | + |
| 114 | + it('ignores more than 100,000 execution files, deleted files, chat uploads, and runtime storage changes', async () => { |
| 115 | + const before = await loadForkPreviewRevision(executor, scope, {}) |
| 116 | + await client`INSERT INTO workspace_files (id, workspace_id, context, key, original_name, size_bytes) |
| 117 | + SELECT 'execution-' || n, CASE WHEN n % 2 = 0 THEN 'source' ELSE 'target' END, |
| 118 | + 'execution', 'execution/' || n, repeat('x', 700), 128 |
| 119 | + FROM generate_series(1, 100001) n` |
| 120 | + await client`INSERT INTO workspace_files (id, workspace_id, context, deleted_at) |
| 121 | + VALUES ('deleted', 'source', 'workspace', now()), ('upload', 'target', 'mothership', null), |
| 122 | + ('kb-document', 'source', 'knowledge-base', null), ('other-workspace', 'unrelated', 'workspace', null)` |
| 123 | + await client`UPDATE workspace SET storage_used_bytes = 999999, updated_at = now()` |
| 124 | + await client`UPDATE workflow SET run_count = run_count + 1, last_run_at = now(), updated_at = now()` |
| 125 | + |
| 126 | + const after = await loadForkPreviewRevision(executor, scope, {}) |
| 127 | + expect(after).toEqual(before) |
| 128 | + await expect( |
| 129 | + assertForkPreviewFresh(executor, scope, { |
| 130 | + workspaceId: 'source', |
| 131 | + requestId: 'request', |
| 132 | + requestHash: 'hash', |
| 133 | + previewFingerprint: before.fingerprint, |
| 134 | + choices: {}, |
| 135 | + }) |
| 136 | + ).resolves.toBeUndefined() |
| 137 | + }) |
| 138 | + |
| 139 | + it.each(['source', 'target'])( |
| 140 | + 'invalidates previews when an active %s file changes or disappears', |
| 141 | + async (workspaceId) => { |
| 142 | + const before = await loadForkPreviewRevision(executor, scope, {}) |
| 143 | + await client`UPDATE workspace_files SET content_updated_at = '2026-09-02' WHERE workspace_id = ${workspaceId}` |
| 144 | + const edited = await loadForkPreviewRevision(executor, scope, {}) |
| 145 | + expect(edited.categories.files).not.toBe(before.categories.files) |
| 146 | + |
| 147 | + await client`UPDATE workspace_files SET deleted_at = now() WHERE workspace_id = ${workspaceId}` |
| 148 | + const deleted = await loadForkPreviewRevision(executor, scope, {}) |
| 149 | + expect(deleted.categories.files).not.toBe(edited.categories.files) |
| 150 | + |
| 151 | + await client`UPDATE workspace_files SET deleted_at = null WHERE workspace_id = ${workspaceId}` |
| 152 | + expect((await loadForkPreviewRevision(executor, scope, {})).fingerprint).toBe( |
| 153 | + edited.fingerprint |
| 154 | + ) |
| 155 | + } |
| 156 | + ) |
| 157 | + |
| 158 | + it('still detects graph edits, access changes, and changed copy choices', async () => { |
| 159 | + const before = await loadForkPreviewRevision(executor, scope, {}) |
| 160 | + await client`INSERT INTO workflow_blocks (id, workflow_id, data) |
| 161 | + VALUES ('block', 'target-workflow', '{"value":"changed"}')` |
| 162 | + await client`INSERT INTO permissions (id, entity_id, entity_type, permission_type) |
| 163 | + VALUES ('member', 'source', 'workspace', 'admin')` |
| 164 | + const after = await loadForkPreviewRevision(executor, scope, {}) |
| 165 | + expect(after.categories.target_graph).not.toBe(before.categories.target_graph) |
| 166 | + expect(after.categories.membership).not.toBe(before.categories.membership) |
| 167 | + expect( |
| 168 | + (await loadForkPreviewRevision(executor, scope, { copyResources: [] })).fingerprint |
| 169 | + ).not.toBe(after.fingerprint) |
| 170 | + }) |
| 171 | + |
| 172 | + it('retains the row limit for actual fork resources', async () => { |
| 173 | + await client`INSERT INTO workspace_files (id, workspace_id, context) |
| 174 | + SELECT 'durable-' || n, 'source', 'workspace' FROM generate_series(1, 100001) n` |
| 175 | + await expect(loadForkPreviewRevision(executor, scope, {})).rejects.toMatchObject({ |
| 176 | + statusCode: 413, |
| 177 | + message: 'Fork preview files exceeds its 100000 row ceiling', |
| 178 | + }) |
| 179 | + }) |
| 180 | +}) |
0 commit comments