feat(API): Add an idempotency key table for the Public API (no-changelog) (#40381)

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Ali Elkhateeb
2026-10-09 14:52:36 +00:00
committed by GitHub
co-authored by Cursor
parent 5e01906a1e
commit cdf3787834
15 changed files with 706 additions and 3 deletions
+13
View File
@@ -75,6 +75,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.idempotency_key](public.idempotency_key.md) | 9 | | 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 |
@@ -300,6 +301,7 @@ erDiagram
"public.folder" }o--o| "public.folder" : "FOREIGN KEY (#quot;parentFolderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
"public.folder_tag" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
"public.folder_tag" }o--|| "public.folder" : "FOREIGN KEY (#quot;folderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
"public.idempotency_key" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.insights_by_period" }o--|| "public.insights_metadata" : "FOREIGN KEY (#quot;metaId#quot;) REFERENCES insights_metadata(#quot;metaId#quot;) ON DELETE CASCADE"
"public.insights_metadata" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL"
"public.insights_metadata" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE SET NULL"
@@ -1123,6 +1125,17 @@ erDiagram
varchar_36_ folderId FK
varchar_36_ tagId FK
}
"public.idempotency_key" {
timestamp_3__with_time_zone createdAt
text fingerprint
varchar_16_ id
varchar_128_ idempotencyKey
json responseBody
smallint responseStatus
varchar_16_ status
timestamp_3__with_time_zone updatedAt
uuid userId FK
}
"public.insights_by_period" {
integer id
integer metaId FK
+80
View File
@@ -0,0 +1,80 @@
# public.idempotency_key
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| fingerprint | text | | false | | | Hash of the request method, path, and body |
| id | varchar(16) | | false | | | Application-generated n8n nano ID |
| idempotencyKey | varchar(128) | | false | | | Client Idempotency-Key header. Opaque ASCII, length 1 to 128 |
| responseBody | json | | true | | | Stored response body. NULL while status is processing |
| responseStatus | smallint | | true | | | HTTP status of the stored response. NULL while processing, required once completed |
| status | varchar(16) | 'processing'::character varying | false | | | processing while the handler runs. completed after the response is stored |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| userId | uuid | | false | | [public.user](public.user.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_idempotency_key_responseStatus | CHECK | CHECK (((((status)::text = 'processing'::text) AND ("responseStatus" IS NULL)) OR (((status)::text = 'completed'::text) AND ("responseStatus" IS NOT NULL)))) |
| CHK_idempotency_key_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['processing'::character varying, 'completed'::character varying])::text[]))) |
| FK_idempotency_key_userId | FOREIGN KEY | FOREIGN KEY ("userId") REFERENCES "user"(id) ON DELETE CASCADE |
| PK_213f125e14469be304f9ff1d452 | PRIMARY KEY | PRIMARY KEY (id) |
| idempotency_key_createdAt_not_null | n | NOT NULL "createdAt" |
| idempotency_key_fingerprint_not_null | n | NOT NULL fingerprint |
| idempotency_key_id_not_null | n | NOT NULL id |
| idempotency_key_idempotencyKey_not_null | n | NOT NULL "idempotencyKey" |
| idempotency_key_status_not_null | n | NOT NULL status |
| idempotency_key_updatedAt_not_null | n | NOT NULL "updatedAt" |
| idempotency_key_userId_not_null | n | NOT NULL "userId" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_25289244569e505292cde510d7 | CREATE UNIQUE INDEX "IDX_25289244569e505292cde510d7" ON public.idempotency_key USING btree ("userId", "idempotencyKey") |
| IDX_38536b1937c55d305af4d69a97 | CREATE INDEX "IDX_38536b1937c55d305af4d69a97" ON public.idempotency_key USING btree ("createdAt") |
| PK_213f125e14469be304f9ff1d452 | CREATE UNIQUE INDEX "PK_213f125e14469be304f9ff1d452" ON public.idempotency_key USING btree (id) |
## Relations
```mermaid
erDiagram
"public.idempotency_key" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.idempotency_key" {
timestamp_3__with_time_zone createdAt
text fingerprint
varchar_16_ id
varchar_128_ idempotencyKey
json responseBody
smallint responseStatus
varchar_16_ status
timestamp_3__with_time_zone updatedAt
uuid userId FK
}
"public.user" {
timestamp_3__with_time_zone createdAt
boolean disabled
varchar_255_ email
varchar_32_ firstName
uuid id
date lastActiveAt
varchar_32_ lastName
boolean mfaEnabled
text mfaRecoveryCodes
text mfaSecret
varchar_255_ password
json personalizationAnswers
varchar_128_ roleSlug FK
json settings
timestamp_3__with_time_zone updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+13 -1
View File
@@ -8,7 +8,7 @@
| disabled | boolean | false | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.self_healing_result](public.self_healing_result.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflow_suggestion_activity](public.workflow_suggestion_activity.md) | | |
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.idempotency_key](public.idempotency_key.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.self_healing_result](public.self_healing_result.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflow_suggestion_activity](public.workflow_suggestion_activity.md) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | false | false | | | |
@@ -61,6 +61,7 @@ erDiagram
"public.chat_hub_tools" }o--|| "public.user" : "FOREIGN KEY (#quot;ownerId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.dynamic_credential_user_entry" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.evaluation_collection" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.idempotency_key" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_mcp_registry_connections" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
@@ -268,6 +269,17 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.idempotency_key" {
timestamp_3__with_time_zone createdAt
text fingerprint
varchar_16_ id
varchar_128_ idempotencyKey
json responseBody
smallint responseStatus
varchar_16_ status
timestamp_3__with_time_zone updatedAt
uuid userId FK
}
"public.instance_ai_mcp_registry_connections" {
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId FK
+13
View File
@@ -75,6 +75,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 |
| [idempotency_key](idempotency_key.md) | 9 | | table |
| [insights_by_period](insights_by_period.md) | 6 | | table |
| [insights_metadata](insights_metadata.md) | 5 | | table |
| [insights_raw](insights_raw.md) | 5 | | table |
@@ -282,6 +283,7 @@ erDiagram
"folder" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"folder_tag" |o--|| "tag_entity" : "FOREIGN KEY (tagId) REFERENCES tag_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"folder_tag" |o--|| "folder" : "FOREIGN KEY (folderId) REFERENCES folder (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"idempotency_key" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"insights_by_period" }o--|| "insights_metadata" : "FOREIGN KEY (metaId) REFERENCES insights_metadata (metaId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"insights_metadata" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"insights_metadata" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@@ -1109,6 +1111,17 @@ erDiagram
varchar_36_ folderId PK
varchar_36_ tagId PK
}
"idempotency_key" {
datetime_3_ createdAt
TEXT fingerprint
varchar_16_ id PK
varchar_128_ idempotencyKey
TEXT responseBody
smallint responseStatus
varchar_16_ status
datetime_3_ updatedAt
varchar userId FK
}
"insights_by_period" {
INTEGER id
INTEGER metaId FK
+85
View File
@@ -0,0 +1,85 @@
# idempotency_key
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "idempotency_key" ("id" varchar(16) PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "idempotencyKey" varchar(128) NOT NULL, "fingerprint" text NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('processing'), "responseStatus" smallint, "responseBody" text, "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_idempotency_key_responseStatus" CHECK (("status" = 'processing' AND "responseStatus" IS NULL) OR ("status" = 'completed' AND "responseStatus" IS NOT NULL)), CONSTRAINT "CHK_idempotency_key_status" CHECK ("status" IN ('processing', 'completed')), CONSTRAINT "FK_idempotency_key_userId" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE)
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| fingerprint | TEXT | | false | | | |
| id | varchar(16) | | false | | | |
| idempotencyKey | varchar(128) | | false | | | |
| responseBody | TEXT | | true | | | |
| responseStatus | smallint | | true | | | |
| status | varchar(16) | 'processing' | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| userId | varchar | | false | | [user](user.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK (("status" = 'processing' AND "responseStatus" IS NULL) OR ("status" = 'completed' AND "responseStatus" IS NOT NULL)) |
| - | CHECK | CHECK ("status" IN ('processing', 'completed')) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_idempotency_key_1 | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_25289244569e505292cde510d7 | CREATE UNIQUE INDEX "IDX_25289244569e505292cde510d7" ON "idempotency_key" ("userId", "idempotencyKey") |
| IDX_38536b1937c55d305af4d69a97 | CREATE INDEX "IDX_38536b1937c55d305af4d69a97" ON "idempotency_key" ("createdAt") |
| sqlite_autoindex_idempotency_key_1 | PRIMARY KEY (id) |
## Relations
```mermaid
erDiagram
"idempotency_key" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"idempotency_key" {
datetime_3_ createdAt
TEXT fingerprint
varchar_16_ id PK
varchar_128_ idempotencyKey
TEXT responseBody
smallint responseStatus
varchar_16_ status
datetime_3_ updatedAt
varchar userId FK
}
"user" {
datetime_3_ createdAt
boolean disabled
varchar_255_ email
varchar_32_ firstName
varchar id PK
date lastActiveAt
varchar_32_ lastName
boolean mfaEnabled
TEXT mfaRecoveryCodes
TEXT mfaSecret
varchar password
TEXT personalizationAnswers
varchar_128_ roleSlug FK
TEXT settings
datetime_3_ updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+13 -1
View File
@@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName"
| disabled | boolean | FALSE | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [migration_workflow_owner](migration_workflow_owner.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [self_healing_result](self_healing_result.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_suggestion](workflow_suggestion.md) [workflow_suggestion_activity](workflow_suggestion_activity.md) | | |
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [idempotency_key](idempotency_key.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [migration_workflow_owner](migration_workflow_owner.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [self_healing_result](self_healing_result.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_suggestion](workflow_suggestion.md) [workflow_suggestion_activity](workflow_suggestion_activity.md) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | FALSE | false | | | |
@@ -67,6 +67,7 @@ erDiagram
"chat_hub_tools" }o--|| "user" : "FOREIGN KEY (ownerId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"dynamic_credential_user_entry" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"evaluation_collection" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"idempotency_key" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_mcp_registry_connections" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -274,6 +275,17 @@ erDiagram
datetime_3_ updatedAt
varchar_36_ workflowId FK
}
"idempotency_key" {
datetime_3_ createdAt
TEXT fingerprint
varchar_16_ id PK
varchar_128_ idempotencyKey
TEXT responseBody
smallint responseStatus
varchar_16_ status
datetime_3_ updatedAt
varchar userId FK
}
"instance_ai_mcp_registry_connections" {
datetime_3_ createdAt
varchar_36_ credentialId FK
@@ -0,0 +1,40 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, Relation } from '@n8n/typeorm';
import { JsonColumn, WithTimestampsAndStringId } from './abstract-entity';
import type { User } from './user';
export const idempotencyKeyStatuses = ['processing', 'completed'] as const;
export type IdempotencyKeyStatus = (typeof idempotencyKeyStatuses)[number];
/**
* One stored Public API write per user and Idempotency-Key.
* A retry with the same user, key, and fingerprint returns this row.
* The handler does not run again.
*/
@Entity({ name: 'idempotency_key' })
@Index(['userId', 'idempotencyKey'], { unique: true })
@Index(['createdAt'])
export class IdempotencyKey extends WithTimestampsAndStringId {
@Column({ type: 'uuid' })
userId: string;
@ManyToOne('User', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: Relation<User>;
@Column({ type: 'varchar', length: 128 })
idempotencyKey: string;
@Column({ type: 'text' })
fingerprint: string;
@Column({ type: 'varchar', length: 16, default: 'processing' })
status: IdempotencyKeyStatus;
@Column({ type: 'smallint', nullable: true })
responseStatus: number | null;
@JsonColumn({ nullable: true })
responseBody: unknown;
}
+9
View File
@@ -37,6 +37,11 @@ import type { ExecutionDataStorageLocation } from './execution-entity';
import { ExecutionMetadata } from './execution-metadata';
import { Folder } from './folder';
import { FolderTagMapping } from './folder-tag-mapping';
import {
IdempotencyKey,
idempotencyKeyStatuses,
type IdempotencyKeyStatus,
} from './idempotency-key';
import { InstanceCredentialAssignment } from './instance-credential-assignment';
import { InvalidAuthToken } from './invalid-auth-token';
import { PollerState } from './poller-state';
@@ -124,6 +129,9 @@ export {
type AgentEvalResultStatus,
AgentEvalRating,
type AgentEvalVote,
IdempotencyKey,
idempotencyKeyStatuses,
type IdempotencyKeyStatus,
InvalidAuthToken,
InstanceCredentialAssignment,
AiBuilderTemporaryWorkflow,
@@ -219,6 +227,7 @@ export const entities = {
AgentEvalRun,
AgentEvalResult,
AgentEvalRating,
IdempotencyKey,
InvalidAuthToken,
InstanceCredentialAssignment,
AiBuilderTemporaryWorkflow,
@@ -0,0 +1,47 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'idempotency_key';
export class CreateIdempotencyKeyTable1791540050944 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column }, escape, tablePrefix }: MigrationContext) {
const status = escape.columnName('status');
const responseStatus = escape.columnName('responseStatus');
await createTable(tableName)
.withColumns(
column('id').varchar(16).primary.comment('Application-generated n8n nano ID'),
column('userId').uuid.notNull,
column('idempotencyKey')
.varchar(128)
.notNull.comment('Client Idempotency-Key header. Opaque ASCII, length 1 to 128'),
column('fingerprint').text.notNull.comment('Hash of the request method, path, and body'),
column('status')
.varchar(16)
.notNull.default("'processing'")
.withEnumCheck(['processing', 'completed'])
.comment('processing while the handler runs. completed after the response is stored'),
column('responseStatus').smallint.comment(
'HTTP status of the stored response. NULL while processing, required once completed',
),
column('responseBody').json.comment(
'Stored response body. NULL while status is processing',
),
)
.withIndexOn(['userId', 'idempotencyKey'], true)
.withIndexOn('createdAt')
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
name: `FK_${tablePrefix}idempotency_key_userId`,
})
.withCheck(
`CHK_${tablePrefix}idempotency_key_responseStatus`,
`(${status} = 'processing' AND ${responseStatus} IS NULL) OR (${status} = 'completed' AND ${responseStatus} IS NOT NULL)`,
).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(tableName);
}
}
@@ -0,0 +1,13 @@
import { Service } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { BaseRepository } from './base-repository';
import { IdempotencyKey } from '../entities';
import { TransactionRunner } from '../services/transaction';
@Service()
export class IdempotencyKeyRepository extends BaseRepository<IdempotencyKey> {
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
super(IdempotencyKey, dataSource.manager, transactionRunner);
}
}
@@ -55,6 +55,7 @@ export { FolderRepository } from './folder.repository';
export { FolderAccessRepository } from './folder-access.repository';
export { FolderTagMappingRepository } from './folder-tag-mapping.repository';
export { ScopeRepository } from './scope.repository';
export { IdempotencyKeyRepository } from './idempotency-key.repository';
export { InvalidAuthTokenRepository } from './invalid-auth-token.repository';
export { InstanceCredentialAssignmentRepository } from './instance-credential-assignment.repository';
export { LicenseMetricsRepository } from './license-metrics.repository';
@@ -0,0 +1,88 @@
# Replay the first Public API response for an Idempotency-Key
Date: 2026-10-05
Status: Active
Decision Owner: API & OEM
Source: https://linear.app/n8n/issue/API-294
## Context
The Public API accepts an Idempotency-Key header on a POST that creates a
resource or starts work. The client opts in by sending the header. A request
with no header keeps today's behaviour and does no store I/O.
PUT and PATCH return the same outcome on a repeat. A second DELETE can return
404 after the first returns 200. The record stays deleted. The store ignores
the header on those routes, and on GET and HEAD.
A handler can save a resource and then fail. The status code does not show
whether that save happened. The credential create handler inserts the row,
then loads the owning project. A failure in that later step returns 500, and
the credential already exists.
## Decision
We keep the first finished response and return it when the same user sends the
same key and the same fingerprint again. The stored response includes 4xx and
5xx. The handler runs only for the first request. The client sends a new key
after a real failure.
1. Compare the fingerprint first. It is a hash of the method, the path, the
query string, and the body. The path is the request path. It includes the
public API prefix and the version, for example `/api/v1/workflows`. It
excludes the scheme and the host. The query string is the raw text after
`?`, without that `?`. No query is an empty string. The body is the raw
JSON text. A retry must send the same characters. For
`POST /api/v1/workflows?source=api` with body `{"name":"Hello"}`, the hash
covers `POST`, `/api/v1/workflows`, `source=api`, and `{"name":"Hello"}`.
The same key with a different fingerprint returns 422. This applies while
the first request is `processing` and after it completes. The handler does
not run. The client sends a new key.
2. The same key and the same fingerprint replay the stored response. A retry
while that request is still `processing` returns 409 Conflict. A completed
row returns the stored response, including a stored 400.
3. The key is an opaque string. We compare it exactly after trim. The length
is 1 to 128 characters, and the characters are visible ASCII. A longer value
or a disallowed character returns 400. An empty value, or a value that is
only whitespace, is the same as no header.
4. The internal `/rest` API stays out of this version. The store has no HTTP
types, so `/rest` can mount later.
## Alternatives Considered
1. **Wait for the in-flight request, then return its response.** Waiting needs
coordination across mains. A 409 tells the client to retry later.
2. **Return only the new resource id when the body is large.** The same POST
would then have two response shapes.
3. **Apply the store on every request.** Clients that send no key would pay for
a store read. The header stays opt-in.
4. **Mount the store on `/rest` in this version.** `/rest` has different auth,
a higher write volume, and existing version checks.
5. **Release the key when the handler returns an error.** A later retry would
run the handler again. A 500 can follow a committed write, so that retry
can create a second resource. The stored error blocks that second write.
## Consequences
1. If the process dies after the claim and before it stores the response, a
later retry with the same fingerprint receives 409 until the row expires.
2. An instance setting can turn the feature off. While it is off, the header is
ignored and the store is idle.
3. Idempotency leaves version checks unchanged. `forceSave` still works as it
does today.
4. The stored row is defined in
ADR-20261005-store-public-api-idempotency-keys-in-the-instance-database.
5. After an error, the same fingerprint returns the stored response until the
row expires. A bug fix does not change the stored response. A new key runs
the handler again.
## Links
RFC: https://app.notion.com/p/n8n/Public-API-idempotency-3d55b6e0c94f816cb074fef7efcb6280
Documentation: https://linear.app/n8n/issue/API-294
Related ADRs: ADR-20261005-store-public-api-idempotency-keys-in-the-instance-database
@@ -0,0 +1,74 @@
# Store Public API idempotency keys in the instance database
Date: 2026-10-05
Status: Active
Decision Owner: API & OEM
Source: https://linear.app/n8n/issue/API-386
## Context
A dropped connection can make a client send the same Public API POST twice.
The second call can create a second workflow, credential, or execution. The
client sends an Idempotency-Key header so a retry returns the first result.
The record must survive a process restart and stay unique across mains. A
flush or eviction drops a Redis row. A lost row lets the POST run again. The
record holds a status and a response for hours.
## Decision
We store each key in the instance database, in the table `idempotency_key`.
Postgres and SQLite both receive the table.
1. The unique key is `(userId, idempotencyKey)`. Two API keys of the same user
share one namespace. Two users may send the same key.
2. The row stores the fingerprint and the status (`processing` or
`completed`). It also stores the response status and the response body.
Those two columns stay empty while the handler runs. The stored response
includes a 4xx or a 5xx. We redact sensitive values in the response body
before we store it. We then encrypt that body. The column holds
ciphertext. Replay decrypts the stored body and returns it. The first
response and the replay are that same body. The replay rules are in
ADR-20261005-replay-the-first-public-api-response-for-an-idempotency-key.
3. `userId` references `user.id`. Deleting the user deletes that user's keys.
4. A row expires 12 hours after `createdAt`. The read path treats an older row
as a miss. A changed TTL applies to rows that already exist, because expiry
is `createdAt` plus the TTL.
5. A system task deletes expired rows on an interval.
## Alternatives Considered
1. **Redis for the key store.** A flush or eviction drops the row, and the POST
can run again. The instance database keeps the row for the full retention
window.
2. **A 24 hour window, as Stripe uses.** The dropped-connection case lasts
seconds to minutes. 12 hours covers same-day recovery.
3. **A 6 hour window.** A late-day retry of a morning write misses the stored
response.
4. **Store the response body as plaintext JSON.** A workflow response can
contain values the user put in node parameters. The row would keep a
second plaintext copy of those values until it expires.
## Consequences
1. A keyed write takes the SQLite writer lock. A client that sends a key on
every request should use Postgres.
2. The same key can run the operation again after the row expires.
3. A create response can be larger than the request. The JSON body parser caps
the incoming payload. Express does not cap the outgoing response. Many keyed
creates hold that response until cleanup.
4. The request contract is recorded in
ADR-20261005-replay-the-first-public-api-response-for-an-idempotency-key.
5. A database reader sees ciphertext for the response body. Replay decrypts
that body before it sends the response.
## Links
RFC: https://app.notion.com/p/n8n/Public-API-idempotency-3d55b6e0c94f816cb074fef7efcb6280
Documentation: https://linear.app/n8n/issue/API-386
Related ADRs: ADR-20261005-replay-the-first-public-api-response-for-an-idempotency-key
@@ -0,0 +1,216 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { generateNanoId } from '@n8n/utils/generate-nano-id';
import { randomUUID } from 'node:crypto';
const MIGRATION_NAME = 'CreateIdempotencyKeyTable1791540050944';
const TABLE_NAME = 'idempotency_key';
type IdempotencyKeyRow = {
id: string;
userId: string;
idempotencyKey: string;
status: string;
};
describe('CreateIdempotencyKeyTable migration', () => {
let dataSource: DataSource;
async function withContext<T>(fn: (context: TestMigrationContext) => Promise<T>): Promise<T> {
const context = createTestMigrationContext(dataSource);
try {
return await fn(context);
} finally {
await context.queryRunner.release();
}
}
beforeAll(async () => {
await Container.get(DbConnection).init();
dataSource = Container.get(DataSource);
});
beforeEach(async () => {
await withContext(async (context) => {
await context.queryRunner.clearDatabase();
});
await initDbUpToMigration(MIGRATION_NAME);
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
});
afterAll(async () => {
await Container.get(DbConnection).close();
});
async function insertUser(context: TestMigrationContext, id: string) {
const table = context.escape.tableName('user');
const now = new Date();
await context.runQuery(
`INSERT INTO ${table} ("id", "email", "firstName", "lastName", "password", "roleSlug", "createdAt", "updatedAt")
VALUES (:id, :email, :firstName, :lastName, :password, :roleSlug, :createdAt, :updatedAt)`,
{
id,
email: `${id}@test.com`,
firstName: 'Test',
lastName: 'User',
password: 'hashed',
roleSlug: 'global:member',
createdAt: now,
updatedAt: now,
},
);
}
async function insertKey(
context: TestMigrationContext,
row: {
userId: string;
idempotencyKey: string;
status?: string;
responseStatus?: number | null;
responseBody?: string | null;
},
) {
const table = context.escape.tableName(TABLE_NAME);
const now = new Date();
await context.runQuery(
`INSERT INTO ${table}
("id", "userId", "idempotencyKey", "fingerprint", "status",
"responseStatus", "responseBody", "createdAt", "updatedAt")
VALUES (:id, :userId, :idempotencyKey, :fingerprint, :status,
:responseStatus, :responseBody, :createdAt, :updatedAt)`,
{
id: generateNanoId(),
userId: row.userId,
idempotencyKey: row.idempotencyKey,
fingerprint: 'method-path-body-hash',
status: row.status ?? 'processing',
responseStatus: row.responseStatus ?? null,
responseBody: row.responseBody ?? null,
createdAt: now,
updatedAt: now,
},
);
}
async function getKeys(context: TestMigrationContext): Promise<IdempotencyKeyRow[]> {
const table = context.escape.tableName(TABLE_NAME);
return await context.runQuery<IdempotencyKeyRow[]>(
`SELECT "id" AS "id", "userId" AS "userId",
"idempotencyKey" AS "idempotencyKey", "status" AS "status"
FROM ${table}`,
);
}
it('stores one key per user and rejects a second insert of the same pair', async () => {
const userId = randomUUID();
const otherUserId = randomUUID();
const rows = await withContext(async (context) => {
await insertUser(context, userId);
await insertUser(context, otherUserId);
await insertKey(context, { userId, idempotencyKey: 'same-key' });
await insertKey(context, { userId: otherUserId, idempotencyKey: 'same-key' });
return await getKeys(context);
});
expect(rows).toHaveLength(2);
await expect(
withContext(async (context) => {
await insertKey(context, { userId, idempotencyKey: 'same-key' });
}),
).rejects.toThrow(/unique/i);
});
it('accepts processing and completed, and rejects any other status', async () => {
const userId = randomUUID();
const rows = await withContext(async (context) => {
await insertUser(context, userId);
await insertKey(context, { userId, idempotencyKey: 'in-flight' });
await insertKey(context, {
userId,
idempotencyKey: 'done',
status: 'completed',
responseStatus: 201,
});
return await getKeys(context);
});
expect(rows.map((row) => row.status).sort()).toEqual(['completed', 'processing']);
await expect(
withContext(async (context) => {
await insertKey(context, { userId, idempotencyKey: 'key-1', status: 'failed' });
}),
).rejects.toThrow(/check constraint/i);
});
it('requires a response status only after the request completes', async () => {
const userId = randomUUID();
await expect(
withContext(async (context) => {
await insertUser(context, userId);
await insertKey(context, { userId, idempotencyKey: 'done', status: 'completed' });
}),
).rejects.toThrow(/check constraint/i);
await expect(
withContext(async (context) => {
await insertKey(context, {
userId,
idempotencyKey: 'early',
responseStatus: 200,
});
}),
).rejects.toThrow(/check constraint/i);
});
it('removes keys owned by a deleted user', async () => {
const userId = randomUUID();
const otherUserId = randomUUID();
const rows = await withContext(async (context) => {
await insertUser(context, userId);
await insertUser(context, otherUserId);
await insertKey(context, {
userId,
idempotencyKey: 'key-1',
status: 'completed',
responseStatus: 201,
responseBody: '{"id":"wf-1"}',
});
await insertKey(context, { userId: otherUserId, idempotencyKey: 'key-1' });
const userTable = context.escape.tableName('user');
await context.runQuery(`DELETE FROM ${userTable} WHERE "id" = :userId`, { userId });
return await getKeys(context);
});
expect(rows).toEqual([
expect.objectContaining({ userId: otherUserId, idempotencyKey: 'key-1' }),
]);
});
it('drops the table on revert', async () => {
await undoLastSingleMigration();
dataSource = Container.get(DataSource);
await withContext(async (context) => {
const table = context.escape.tableName(TABLE_NAME);
await expect(context.runQuery(`SELECT 1 FROM ${table}`)).rejects.toThrow();
});
});
});
@@ -34,7 +34,7 @@ const defaultRuleSettings: RuleSettingsMap = {
'adr-conventions': {
enabled: true,
severity: 'error',
options: { allowedOwners: ['Catalysts'] },
options: { allowedOwners: ['API & OEM', 'Catalysts'] },
},
'catalog-violations': {
enabled: true,