mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
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:
Generated
+13
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+13
@@ -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
@@ -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)
|
||||
Generated
+13
-1
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+88
@@ -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
|
||||
+74
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user