feat(core): Add Git connection database table (no-changelog) (#36268)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Irénée
2026-08-18 13:54:47 +00:00
committed by GitHub
parent 2435ed5cf0
commit 9c947996da
7 changed files with 455 additions and 0 deletions
+16
View File
@@ -63,6 +63,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.execution_metadata](public.execution_metadata.md) | 4 | | BASE TABLE |
| [public.folder](public.folder.md) | 6 | | BASE TABLE |
| [public.folder_tag](public.folder_tag.md) | 2 | | BASE TABLE |
| [public.git_connection](public.git_connection.md) | 13 | | BASE TABLE |
| [public.insights_by_period](public.insights_by_period.md) | 6 | | BASE TABLE |
| [public.insights_metadata](public.insights_metadata.md) | 5 | | BASE TABLE |
| [public.insights_raw](public.insights_raw.md) | 5 | | BASE TABLE |
@@ -907,6 +908,21 @@ erDiagram
varchar_36_ folderId FK
varchar_36_ tagId FK
}
"public.git_connection" {
varchar_64_ baseCommit
varchar_255_ branchName
varchar_16_ connectionType
timestamp_3__with_time_zone createdAt
text encryptedPassword
text encryptedPrivateKey
text encryptedUsername
varchar_36_ id
varchar_16_ keyGeneratorType
varchar_128_ name
text publicKey
text repositoryUrl
timestamp_3__with_time_zone updatedAt
}
"public.insights_by_period" {
integer id
integer metaId FK
+68
View File
@@ -0,0 +1,68 @@
# public.git_connection
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| baseCommit | varchar(64) | | true | | | Last commit successfully reconciled for this connection; the base for three-way reconciliation. |
| branchName | varchar(255) | | true | | | |
| connectionType | varchar(16) | | false | | | GitConnectionType enum: "ssh", "https" |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| encryptedPassword | text | | true | | | Encrypted HTTPS password/token; set when connectionType is 'https', null for 'ssh'. |
| encryptedPrivateKey | text | | true | | | Encrypted SSH private key; set when connectionType is 'ssh', null for 'https'. |
| encryptedUsername | text | | true | | | Encrypted HTTPS username; set when connectionType is 'https', null for 'ssh'. |
| id | varchar(36) | | false | | | |
| keyGeneratorType | varchar(16) | | true | | | GitKeyGeneratorType enum: "ed25519", "rsa" |
| name | varchar(128) | | false | | | |
| publicKey | text | | true | | | SSH public key; set when connectionType is 'ssh', null for 'https'. |
| repositoryUrl | text | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_git_connection_connectionType | CHECK | CHECK ((("connectionType")::text = ANY ((ARRAY['ssh'::character varying, 'https'::character varying])::text[]))) |
| CHK_git_connection_https_auth | CHECK | CHECK (((("connectionType")::text <> 'https'::text) OR (("publicKey" IS NULL) AND ("encryptedPrivateKey" IS NULL) AND ("keyGeneratorType" IS NULL)))) |
| CHK_git_connection_keyGeneratorType | CHECK | CHECK ((("keyGeneratorType")::text = ANY ((ARRAY['ed25519'::character varying, 'rsa'::character varying])::text[]))) |
| CHK_git_connection_ssh_auth | CHECK | CHECK (((("connectionType")::text <> 'ssh'::text) OR (("encryptedUsername" IS NULL) AND ("encryptedPassword" IS NULL)))) |
| PK_92966b7ba4ce2cf61a62017a6ff | PRIMARY KEY | PRIMARY KEY (id) |
| git_connection_connectionType_not_null | n | NOT NULL "connectionType" |
| git_connection_createdAt_not_null | n | NOT NULL "createdAt" |
| git_connection_id_not_null | n | NOT NULL id |
| git_connection_name_not_null | n | NOT NULL name |
| git_connection_repositoryUrl_not_null | n | NOT NULL "repositoryUrl" |
| git_connection_updatedAt_not_null | n | NOT NULL "updatedAt" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| PK_92966b7ba4ce2cf61a62017a6ff | CREATE UNIQUE INDEX "PK_92966b7ba4ce2cf61a62017a6ff" ON public.git_connection USING btree (id) |
## Relations
```mermaid
erDiagram
"public.git_connection" {
varchar_64_ baseCommit
varchar_255_ branchName
varchar_16_ connectionType
timestamp_3__with_time_zone createdAt
text encryptedPassword
text encryptedPrivateKey
text encryptedUsername
varchar_36_ id
varchar_16_ keyGeneratorType
varchar_128_ name
text publicKey
text repositoryUrl
timestamp_3__with_time_zone updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+16
View File
@@ -63,6 +63,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [execution_metadata](execution_metadata.md) | 4 | | table |
| [folder](folder.md) | 6 | | table |
| [folder_tag](folder_tag.md) | 2 | | table |
| [git_connection](git_connection.md) | 13 | | table |
| [insights_by_period](insights_by_period.md) | 6 | | table |
| [insights_metadata](insights_metadata.md) | 5 | | table |
| [insights_raw](insights_raw.md) | 5 | | table |
@@ -894,6 +895,21 @@ erDiagram
varchar_36_ folderId PK
varchar_36_ tagId PK
}
"git_connection" {
varchar_64_ baseCommit
varchar_255_ branchName
varchar_16_ connectionType
datetime_3_ createdAt
TEXT encryptedPassword
TEXT encryptedPrivateKey
TEXT encryptedUsername
varchar_36_ id PK
varchar_16_ keyGeneratorType
varchar_128_ name
TEXT publicKey
TEXT repositoryUrl
datetime_3_ updatedAt
}
"insights_by_period" {
INTEGER id
INTEGER metaId FK
+74
View File
@@ -0,0 +1,74 @@
# git_connection
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "git_connection" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "repositoryUrl" text NOT NULL, "branchName" varchar(255), "connectionType" varchar(16) NOT NULL, "publicKey" text, "encryptedPrivateKey" text, "encryptedUsername" text, "encryptedPassword" text, "keyGeneratorType" varchar(16), "baseCommit" varchar(64), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_git_connection_ssh_auth" CHECK ("connectionType" <> 'ssh' OR ("encryptedUsername" IS NULL AND "encryptedPassword" IS NULL)), CONSTRAINT "CHK_git_connection_https_auth" CHECK ("connectionType" <> 'https' OR ("publicKey" IS NULL AND "encryptedPrivateKey" IS NULL AND "keyGeneratorType" IS NULL)), CONSTRAINT "CHK_git_connection_connectionType" CHECK ("connectionType" IN ('ssh', 'https')), CONSTRAINT "CHK_git_connection_keyGeneratorType" CHECK ("keyGeneratorType" IN ('ed25519', 'rsa')))
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| baseCommit | varchar(64) | | true | | | |
| branchName | varchar(255) | | true | | | |
| connectionType | varchar(16) | | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| encryptedPassword | TEXT | | true | | | |
| encryptedPrivateKey | TEXT | | true | | | |
| encryptedUsername | TEXT | | true | | | |
| id | varchar(36) | | false | | | |
| keyGeneratorType | varchar(16) | | true | | | |
| name | varchar(128) | | false | | | |
| publicKey | TEXT | | true | | | |
| repositoryUrl | TEXT | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("connectionType" <> 'ssh' OR ("encryptedUsername" IS NULL AND "encryptedPassword" IS NULL)) |
| - | CHECK | CHECK ("connectionType" <> 'https' OR ("publicKey" IS NULL AND "encryptedPrivateKey" IS NULL AND "keyGeneratorType" IS NULL)) |
| - | CHECK | CHECK ("connectionType" IN ('ssh', 'https')) |
| - | CHECK | CHECK ("keyGeneratorType" IN ('ed25519', 'rsa')) |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_git_connection_1 | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| sqlite_autoindex_git_connection_1 | PRIMARY KEY (id) |
## Relations
```mermaid
erDiagram
"git_connection" {
varchar_64_ baseCommit
varchar_255_ branchName
varchar_16_ connectionType
datetime_3_ createdAt
TEXT encryptedPassword
TEXT encryptedPrivateKey
TEXT encryptedUsername
varchar_36_ id PK
varchar_16_ keyGeneratorType
varchar_128_ name
TEXT publicKey
TEXT repositoryUrl
datetime_3_ updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
@@ -0,0 +1,53 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateGitConnectionTable1787056876306 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column }, tablePrefix }: MigrationContext) {
await createTable('git_connection')
.withColumns(
column('id').varchar(36).primary,
column('name').varchar(128).notNull,
column('repositoryUrl').text.notNull,
column('branchName').varchar(255),
column('connectionType')
.varchar(16)
.notNull.withEnumCheck(['ssh', 'https'])
.comment('GitConnectionType enum: "ssh", "https"'),
column('publicKey').text.comment(
"SSH public key; set when connectionType is 'ssh', null for 'https'.",
),
column('encryptedPrivateKey').text.comment(
"Encrypted SSH private key; set when connectionType is 'ssh', null for 'https'.",
),
column('encryptedUsername').text.comment(
"Encrypted HTTPS username; set when connectionType is 'https', null for 'ssh'.",
),
column('encryptedPassword').text.comment(
"Encrypted HTTPS password/token; set when connectionType is 'https', null for 'ssh'.",
),
column('keyGeneratorType')
.varchar(16)
.withEnumCheck(['ed25519', 'rsa'])
.comment('GitKeyGeneratorType enum: "ed25519", "rsa"'),
column('baseCommit')
.varchar(64)
.comment(
'Last commit successfully reconciled for this connection; the base for three-way reconciliation.',
),
)
// Credentials may be filled in after creation, but a connection must never carry
// columns that belong to the other connectionType.
.withCheck(
`CHK_${tablePrefix}git_connection_ssh_auth`,
'"connectionType" <> \'ssh\' OR ("encryptedUsername" IS NULL AND "encryptedPassword" IS NULL)',
)
.withCheck(
`CHK_${tablePrefix}git_connection_https_auth`,
'"connectionType" <> \'https\' OR ("publicKey" IS NULL AND "encryptedPrivateKey" IS NULL' +
' AND "keyGeneratorType" IS NULL)',
).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('git_connection');
}
}
@@ -0,0 +1,46 @@
import { WithTimestampsAndStringId } from '@n8n/db';
import { Column, Entity } from '@n8n/typeorm';
// Defined inline so this DB-layer entity stays self-contained; the shared
// @n8n/api-types DTOs that own these unions arrive in a later PR.
type GitConnectionType = 'ssh' | 'https';
type GitKeyGeneratorType = 'ed25519' | 'rsa';
@Entity('git_connection')
export class GitConnection extends WithTimestampsAndStringId {
@Column({ type: 'varchar', length: 128 })
name: string;
@Column({ type: 'text' })
repositoryUrl: string;
@Column({ type: 'varchar', length: 255, nullable: true })
branchName: string | null;
@Column({ type: 'varchar', length: 16 })
connectionType: GitConnectionType;
/** SSH public key; set when {@link connectionType} is `ssh`, null for `https`. */
@Column({ type: 'text', nullable: true })
publicKey: string | null;
/** Encrypted SSH private key; set when {@link connectionType} is `ssh`, null for `https`. */
@Column({ type: 'text', nullable: true })
encryptedPrivateKey: string | null;
/** Encrypted HTTPS username; set when {@link connectionType} is `https`, null for `ssh`. */
@Column({ type: 'text', nullable: true })
encryptedUsername: string | null;
/** Encrypted HTTPS password/token; set when {@link connectionType} is `https`, null for `ssh`. */
@Column({ type: 'text', nullable: true })
encryptedPassword: string | null;
/** Algorithm used to generate the SSH key pair; null for `https`. */
@Column({ type: 'varchar', length: 16, nullable: true })
keyGeneratorType: GitKeyGeneratorType | null;
/** Last commit successfully reconciled for this connection; the base for three-way reconciliation. */
@Column({ type: 'varchar', length: 64, nullable: true })
baseCommit: string | null;
}
@@ -0,0 +1,182 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { randomUUID } from 'node:crypto';
const MIGRATION_NAME = 'CreateGitConnectionTable1787056876306';
describe('CreateGitConnectionTable migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
await initDbUpToMigration(MIGRATION_NAME);
await runSingleMigration(MIGRATION_NAME);
});
afterAll(async () => {
await Container.get(DbConnection).close();
});
type ConnectionOverrides = {
connectionType?: string;
publicKey?: string | null;
encryptedPrivateKey?: string | null;
encryptedUsername?: string | null;
encryptedPassword?: string | null;
keyGeneratorType?: string | null;
};
// Credential columns default to a valid set for the connectionType so rows pass
// the ssh/https auth CHECK constraints unless a test deliberately overrides them.
function credentialDefaults(connectionType: string) {
if (connectionType === 'ssh') {
return {
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5',
encryptedPrivateKey: 'encrypted-private-key',
encryptedUsername: null,
encryptedPassword: null,
keyGeneratorType: 'ed25519',
};
}
return {
publicKey: null,
encryptedPrivateKey: null,
encryptedUsername: 'encrypted-username',
encryptedPassword: 'encrypted-password',
keyGeneratorType: null,
};
}
async function insertConnection(
context: TestMigrationContext,
overrides: ConnectionOverrides = {},
) {
const table = context.escape.tableName('git_connection');
const now = new Date();
const connectionType = overrides.connectionType ?? 'https';
await context.runQuery(
`INSERT INTO ${table} ("id", "name", "repositoryUrl", "connectionType", "publicKey", "encryptedPrivateKey", "encryptedUsername", "encryptedPassword", "keyGeneratorType", "createdAt", "updatedAt")
VALUES (:id, :name, :repositoryUrl, :connectionType, :publicKey, :encryptedPrivateKey, :encryptedUsername, :encryptedPassword, :keyGeneratorType, :createdAt, :updatedAt)`,
{
id: randomUUID(),
name: 'Deployments',
repositoryUrl: 'https://example.com/org/repo.git',
connectionType,
...credentialDefaults(connectionType),
...overrides,
createdAt: now,
updatedAt: now,
},
);
}
describe('Up migration', () => {
it('creates a table that stores SSH and HTTPS connections', async () => {
const context = createTestMigrationContext(dataSource);
const table = context.escape.tableName('git_connection');
await insertConnection(context, { connectionType: 'https' });
await insertConnection(context, { connectionType: 'ssh', keyGeneratorType: 'ed25519' });
const rows = await context.runQuery<
Array<{ branchName: string | null; connectionType: string }>
>(`SELECT "branchName", "connectionType" FROM ${table} ORDER BY "connectionType"`);
expect(rows).toEqual([
{ branchName: null, connectionType: 'https' },
{ branchName: null, connectionType: 'ssh' },
]);
await context.queryRunner.release();
});
it('rejects an out-of-set connectionType via the CHECK constraint', async () => {
const context = createTestMigrationContext(dataSource);
await expect(insertConnection(context, { connectionType: 'bogus' })).rejects.toThrow();
await context.queryRunner.release();
});
it('rejects an out-of-set keyGeneratorType via the CHECK constraint', async () => {
const context = createTestMigrationContext(dataSource);
await expect(
insertConnection(context, { connectionType: 'ssh', keyGeneratorType: 'bogus' }),
).rejects.toThrow();
await context.queryRunner.release();
});
it('accepts a NULL keyGeneratorType (unset for HTTPS connections)', async () => {
const context = createTestMigrationContext(dataSource);
await expect(
insertConnection(context, { connectionType: 'https', keyGeneratorType: null }),
).resolves.not.toThrow();
await context.queryRunner.release();
});
it('accepts an SSH connection whose key material is not set yet', async () => {
const context = createTestMigrationContext(dataSource);
await expect(
insertConnection(context, {
connectionType: 'ssh',
publicKey: null,
encryptedPrivateKey: null,
keyGeneratorType: null,
}),
).resolves.not.toThrow();
await context.queryRunner.release();
});
it('accepts an HTTPS connection whose credentials are not set yet', async () => {
const context = createTestMigrationContext(dataSource);
await expect(
insertConnection(context, {
connectionType: 'https',
encryptedUsername: null,
encryptedPassword: null,
}),
).resolves.not.toThrow();
await context.queryRunner.release();
});
it('rejects an SSH connection that also carries HTTPS credentials via the auth CHECK constraint', async () => {
const context = createTestMigrationContext(dataSource);
await expect(
insertConnection(context, { connectionType: 'ssh', encryptedUsername: 'leaked' }),
).rejects.toThrow();
await context.queryRunner.release();
});
it('rejects an HTTPS connection that also carries SSH key material via the auth CHECK constraint', async () => {
const context = createTestMigrationContext(dataSource);
await expect(
insertConnection(context, { connectionType: 'https', publicKey: 'ssh-ed25519 AAAA' }),
).rejects.toThrow();
await context.queryRunner.release();
});
});
describe('Down migration', () => {
it('drops the table and can be re-applied', async () => {
await dataSource.undoLastMigration({ transaction: 'each' });
const context = createTestMigrationContext(dataSource);
const table = `${context.tablePrefix}git_connection`;
expect(await context.queryRunner.hasTable(table)).toBe(false);
await context.queryRunner.release();
// Round-trip: up() must run cleanly again after a full revert.
await runSingleMigration(MIGRATION_NAME);
const context2 = createTestMigrationContext(dataSource);
expect(await context2.queryRunner.hasTable(table)).toBe(true);
await context2.queryRunner.release();
});
});
});