agrs-sequelize-sdk 1.4.59 → 1.4.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/migrations/2026-05-17-PIXELS-FOLLOWUP.md +85 -85
  2. package/migrations/2026-05-17-add-platform-to-ad.js +19 -19
  3. package/migrations/2026-05-17-add-platform-to-adaccount.js +19 -19
  4. package/migrations/2026-05-17-add-platform-to-adperformance.js +19 -19
  5. package/migrations/2026-05-17-add-platform-to-adset.js +19 -19
  6. package/migrations/2026-05-17-add-platform-to-adsetperformance.js +19 -19
  7. package/migrations/2026-05-17-add-platform-to-aiarticleretryqueue.js +19 -19
  8. package/migrations/2026-05-17-add-platform-to-aicampaignqueue.js +19 -19
  9. package/migrations/2026-05-17-add-platform-to-article.js +19 -19
  10. package/migrations/2026-05-17-add-platform-to-campaign.js +19 -19
  11. package/migrations/2026-05-17-add-platform-to-codefuelcampaign.js +19 -19
  12. package/migrations/2026-05-17-add-platform-to-dynamicfeed.js +19 -19
  13. package/migrations/2026-05-17-add-platform-to-facebookretryqueue.js +19 -19
  14. package/migrations/2026-05-17-add-platform-to-pages.js +19 -19
  15. package/migrations/2026-05-17-add-platform-to-presets.js +19 -19
  16. package/migrations/2026-05-18-add-group-id-to-ai-campaign-queue.js +18 -18
  17. package/migrations/2026-05-18-add-platform-code-to-creation-logs.js +65 -65
  18. package/migrations/2026-05-18-add-platform-code-to-pixels.js +55 -55
  19. package/migrations/2026-05-18-add-platform-code-to-rsoc-feed-campaigns.js +55 -55
  20. package/migrations/2026-05-18-add-platform-date-index-to-adperformance.js +16 -16
  21. package/migrations/2026-05-18-add-platform-date-index-to-adsetperformance.js +16 -16
  22. package/migrations/2026-05-18-add-review-status-to-ad.js +36 -36
  23. package/migrations/2026-05-18-create-canonical-insights.js +84 -84
  24. package/migrations/2026-05-18-create-snapchat-public-profiles.js +68 -68
  25. package/migrations/2026-05-18-create-tiktok-identities.js +71 -71
  26. package/migrations/2026-05-18-create-tiktok-snapchat-campaigns.js +143 -143
  27. package/migrations/2026-05-18-create-tt-snp-adset-ad-tables.js +309 -309
  28. package/migrations/RUN_ME_MANUALLY_2026-05-17.sql +99 -99
  29. package/migrations/add-requested-from-dashboard-to-articles.js +17 -17
  30. package/migrations/change-adset-name-to-text.js +79 -79
  31. package/models/AICampaignQueue.js +151 -151
  32. package/models/AIGenerationLog.js +85 -85
  33. package/models/AIGenerationRequest.js +212 -212
  34. package/models/AdAccountValues.js +25 -25
  35. package/models/AdHistory.js +30 -30
  36. package/models/AdPerformance.js +104 -104
  37. package/models/AdSet.js +295 -295
  38. package/models/AdSetHistory.js +30 -30
  39. package/models/AdsetPerformance.js +137 -137
  40. package/models/AiArticleRetryQueue.js +157 -157
  41. package/models/AiCreationRetryQueue.js +134 -134
  42. package/models/ApiAuditLog.js +127 -127
  43. package/models/ApiToken.js +67 -67
  44. package/models/Article.js +222 -222
  45. package/models/AutomationRule.js +179 -179
  46. package/models/BannerTemplate.js +129 -129
  47. package/models/Buyers.js +25 -25
  48. package/models/Campaign.js +168 -168
  49. package/models/CampaignActionHistory.js +101 -101
  50. package/models/CampaignCreationLog.js +340 -340
  51. package/models/CampaignCreationLogV2.js +392 -392
  52. package/models/CampaignHistory.js +33 -33
  53. package/models/CampaignTag.js +46 -46
  54. package/models/CampaignTagAssignment.js +48 -48
  55. package/models/CanonicalInsights.js +68 -68
  56. package/models/Channel.js +55 -55
  57. package/models/Domain.js +44 -44
  58. package/models/DynamicFeed.js +218 -218
  59. package/models/ExplorAdsChannel.js +61 -61
  60. package/models/FacebookRetryQueue.js +163 -163
  61. package/models/Feed.js +33 -33
  62. package/models/FeedArticleConfiguration.js +80 -80
  63. package/models/FrontStoryChannel.js +59 -59
  64. package/models/FrontStoryChannelV2.js +60 -60
  65. package/models/GenericFlowRequest.js +114 -114
  66. package/models/MidoWebChannel.js +47 -47
  67. package/models/MineBrandBlocklist.js +56 -56
  68. package/models/MineBrandConfig.js +26 -26
  69. package/models/MineChannel.js +47 -47
  70. package/models/MineRedirectDomain.js +42 -42
  71. package/models/MineWebhookEvent.js +53 -53
  72. package/models/Pages.js +117 -117
  73. package/models/PipelineExecution.js +59 -59
  74. package/models/PolicyDogsCreativeCache.js +50 -50
  75. package/models/PolicyDogsImageCache.js +30 -30
  76. package/models/Presets.js +40 -40
  77. package/models/PublicApiBatch.js +155 -155
  78. package/models/PublicApiBatchItem.js +166 -166
  79. package/models/PublicApiIdempotencyCache.js +135 -135
  80. package/models/RSOCFeedCampaign.js +387 -387
  81. package/models/RSOCFeedConfig.js +46 -46
  82. package/models/RSOCRedirectWsCode.js +22 -22
  83. package/models/RsocKeywordPerformance.js +110 -110
  84. package/models/RuleAction.js +90 -90
  85. package/models/RuleCondition.js +137 -137
  86. package/models/RuleExecution.js +107 -107
  87. package/models/RulesValues.js +56 -56
  88. package/models/SingularChannel.js +31 -31
  89. package/models/SnapchatAd.js +69 -69
  90. package/models/SnapchatAdSquad.js +92 -92
  91. package/models/SnapchatCampaign.js +69 -69
  92. package/models/SnapchatChannel.js +31 -31
  93. package/models/SnapchatPublicProfiles.js +67 -67
  94. package/models/SupportedLocale.js +23 -23
  95. package/models/SyncHistory.js +249 -249
  96. package/models/TTQChannel.js +42 -42
  97. package/models/Team.js +33 -43
  98. package/models/TemplateMetadata.js +260 -260
  99. package/models/Tier2_AdAccounts.js +110 -110
  100. package/models/Tier2_Assets.js +70 -70
  101. package/models/Tier2_BusinessManagers.js +105 -105
  102. package/models/Tier2_CreditLines.js +99 -99
  103. package/models/Tier2_Pages.js +91 -91
  104. package/models/Tier2_Pixels.js +82 -82
  105. package/models/Tier2_Tokens.js +64 -64
  106. package/models/Tier2_UserAdAccounts.js +83 -83
  107. package/models/TikTokAd.js +71 -71
  108. package/models/TikTokAdGroup.js +82 -82
  109. package/models/TikTokCampaign.js +71 -71
  110. package/models/TiktokChannel.js +31 -31
  111. package/models/TiktokIdentities.js +65 -65
  112. package/models/TokenRotationState.js +121 -121
  113. package/models/TonicRSOCKeywordPerformance.js +122 -122
  114. package/models/Users.js +204 -204
  115. package/models/Vertical.js +25 -25
  116. package/models/newFiles.js +137 -137
  117. package/package.json +21 -19
  118. package/run.sh +214 -214
  119. package/services/sequelizeService.js +110 -110
  120. package/tarzo-skalio-integration-docs.md +691 -691
@@ -1,166 +1,166 @@
1
- module.exports = (sequelize, DataTypes) => {
2
- // public_api_batch_items — one durable work row per campaign inside a bulk
3
- // batch. This is the queue the bounded-concurrency poller claims from:
4
- // UPDATE public_api_batch_items
5
- // SET status='PROCESSING', lease_until = now() + interval 'N minutes'
6
- // WHERE id IN (SELECT id FROM public_api_batch_items
7
- // WHERE status='PENDING' ORDER BY created_at
8
- // LIMIT :n FOR UPDATE SKIP LOCKED)
9
- // RETURNING id;
10
- // The claim is admission control (exactly-once START across N instances), NOT
11
- // mutual exclusion across the multi-minute FB round-trip — that is what
12
- // `lease_until` + the in-core 0-ad / 0-campaign rechecks guard (R1/R2).
13
- const PublicApiBatchItem = sequelize.define(
14
- "PublicApiBatchItem",
15
- {
16
- id: {
17
- type: DataTypes.UUID,
18
- primaryKey: true,
19
- defaultValue: DataTypes.UUIDV4,
20
- },
21
- batch_id: {
22
- // Parent public_api_batches.id (UUID). No hard DB FK constraint by
23
- // design — matches the ai_campaign_queue precedent: this is a
24
- // high-throughput work queue where per-row FK checks and sync-time
25
- // create-ordering add cost/risk for no integrity we don't already get
26
- // from the atomic batch+items TXN. Association below is for querying.
27
- type: DataTypes.UUID,
28
- allowNull: false,
29
- field: "batch_id",
30
- },
31
- item_index: {
32
- // Position of this campaign within the submitted batch, for ordered
33
- // reporting in GET /processes/{batchId}.
34
- type: DataTypes.INTEGER,
35
- allowNull: true,
36
- field: "item_index",
37
- },
38
- status: {
39
- type: DataTypes.STRING(50),
40
- allowNull: false,
41
- defaultValue: "PENDING",
42
- validate: {
43
- isIn: [["PENDING", "PROCESSING", "COMPLETED", "FAILED"]],
44
- },
45
- },
46
- payload: {
47
- // The single-campaign creation payload consumed by
48
- // createSingleCampaign(payload, { batchItemId }). Self-contained.
49
- type: DataTypes.JSONB,
50
- allowNull: false,
51
- },
52
- process_id: {
53
- // Bridge to CampaignCreationLogV2.process_id (D5). Assigned when the
54
- // core starts; GET /processes joins item -> V2 log via this.
55
- type: DataTypes.UUID,
56
- allowNull: true,
57
- field: "process_id",
58
- },
59
- agrs_cid: {
60
- // AGRS Campaign ID — used by LOOP1 dedup and as the reclaim idempotency
61
- // anchor (R2). Indexed for the campaign-level existence recheck.
62
- type: DataTypes.STRING(255),
63
- allowNull: true,
64
- field: "agrs_cid",
65
- },
66
- idempotency_token: {
67
- // Campaign-level idempotency anchor keyed by AGRS_CID + country group
68
- // (R2). Lets a reclaimed re-entry prove "this campaign was already
69
- // POSTed to FB" without a from-scratch re-create. Forward-compat now,
70
- // populated when the S3 recheck lands.
71
- type: DataTypes.STRING(255),
72
- allowNull: true,
73
- field: "idempotency_token",
74
- },
75
- lease_until: {
76
- // R1: the REAL lease. A claimed item is owned until this instant. The
77
- // reclaim sweep only re-dispatches when lease_until < now() (window set
78
- // STRICTLY greater than worst-case single-creation wall time, incl.
79
- // 80004 backoffs). A worker that loses its lease must abort, not write.
80
- type: DataTypes.DATE,
81
- allowNull: true,
82
- field: "lease_until",
83
- },
84
- heartbeat_at: {
85
- // R1 (optional gate): last liveness ping from the owning worker, so a
86
- // long-but-alive creation isn't reclaimed on bare wall-clock alone.
87
- type: DataTypes.DATE,
88
- allowNull: true,
89
- field: "heartbeat_at",
90
- },
91
- retry_count: {
92
- // D-FAILURE: capped requeue count. Poison (exceeds cap) -> terminal
93
- // FAILED, never re-looped.
94
- type: DataTypes.INTEGER,
95
- allowNull: false,
96
- defaultValue: 0,
97
- field: "retry_count",
98
- },
99
- error_message: {
100
- type: DataTypes.TEXT,
101
- allowNull: true,
102
- field: "error_message",
103
- },
104
- error_code: {
105
- // Machine-readable terminal cause (FB subcode / PG code / classifier
106
- // verdict) surfaced via GET /processes.
107
- type: DataTypes.STRING(100),
108
- allowNull: true,
109
- field: "error_code",
110
- },
111
- facebook_campaign_id: {
112
- type: DataTypes.STRING(255),
113
- allowNull: true,
114
- field: "facebook_campaign_id",
115
- },
116
- claimed_at: {
117
- type: DataTypes.DATE,
118
- allowNull: true,
119
- field: "claimed_at",
120
- },
121
- created_at: {
122
- type: DataTypes.DATE,
123
- allowNull: false,
124
- defaultValue: DataTypes.NOW,
125
- field: "created_at",
126
- },
127
- updated_at: {
128
- type: DataTypes.DATE,
129
- allowNull: true,
130
- field: "updated_at",
131
- },
132
- completed_at: {
133
- type: DataTypes.DATE,
134
- allowNull: true,
135
- field: "completed_at",
136
- },
137
- },
138
- {
139
- tableName: "public_api_batch_items",
140
- timestamps: false,
141
- // Named explicitly: prod sync runs alter:false, so any index added later
142
- // to this (by-then existing) table needs a migration targeting the name.
143
- indexes: [
144
- { name: "pabi_batch_id_idx", fields: ["batch_id"] },
145
- { name: "pabi_status_idx", fields: ["status"] },
146
- // Composite: the reconciler roll-up filters by (batch_id, status).
147
- { name: "pabi_batch_id_status_idx", fields: ["batch_id", "status"] },
148
- // The poller claim scans PENDING oldest-first: (status, created_at).
149
- { name: "pabi_status_created_at_idx", fields: ["status", "created_at"] },
150
- { name: "pabi_process_id_idx", fields: ["process_id"] },
151
- { name: "pabi_agrs_cid_idx", fields: ["agrs_cid"] },
152
- { name: "pabi_lease_until_idx", fields: ["lease_until"] },
153
- ],
154
- }
155
- );
156
-
157
- PublicApiBatchItem.associate = (models) => {
158
- PublicApiBatchItem.belongsTo(models.PublicApiBatch, {
159
- foreignKey: "batch_id",
160
- targetKey: "id",
161
- as: "batch",
162
- });
163
- };
164
-
165
- return PublicApiBatchItem;
166
- };
1
+ module.exports = (sequelize, DataTypes) => {
2
+ // public_api_batch_items — one durable work row per campaign inside a bulk
3
+ // batch. This is the queue the bounded-concurrency poller claims from:
4
+ // UPDATE public_api_batch_items
5
+ // SET status='PROCESSING', lease_until = now() + interval 'N minutes'
6
+ // WHERE id IN (SELECT id FROM public_api_batch_items
7
+ // WHERE status='PENDING' ORDER BY created_at
8
+ // LIMIT :n FOR UPDATE SKIP LOCKED)
9
+ // RETURNING id;
10
+ // The claim is admission control (exactly-once START across N instances), NOT
11
+ // mutual exclusion across the multi-minute FB round-trip — that is what
12
+ // `lease_until` + the in-core 0-ad / 0-campaign rechecks guard (R1/R2).
13
+ const PublicApiBatchItem = sequelize.define(
14
+ "PublicApiBatchItem",
15
+ {
16
+ id: {
17
+ type: DataTypes.UUID,
18
+ primaryKey: true,
19
+ defaultValue: DataTypes.UUIDV4,
20
+ },
21
+ batch_id: {
22
+ // Parent public_api_batches.id (UUID). No hard DB FK constraint by
23
+ // design — matches the ai_campaign_queue precedent: this is a
24
+ // high-throughput work queue where per-row FK checks and sync-time
25
+ // create-ordering add cost/risk for no integrity we don't already get
26
+ // from the atomic batch+items TXN. Association below is for querying.
27
+ type: DataTypes.UUID,
28
+ allowNull: false,
29
+ field: "batch_id",
30
+ },
31
+ item_index: {
32
+ // Position of this campaign within the submitted batch, for ordered
33
+ // reporting in GET /processes/{batchId}.
34
+ type: DataTypes.INTEGER,
35
+ allowNull: true,
36
+ field: "item_index",
37
+ },
38
+ status: {
39
+ type: DataTypes.STRING(50),
40
+ allowNull: false,
41
+ defaultValue: "PENDING",
42
+ validate: {
43
+ isIn: [["PENDING", "PROCESSING", "COMPLETED", "FAILED"]],
44
+ },
45
+ },
46
+ payload: {
47
+ // The single-campaign creation payload consumed by
48
+ // createSingleCampaign(payload, { batchItemId }). Self-contained.
49
+ type: DataTypes.JSONB,
50
+ allowNull: false,
51
+ },
52
+ process_id: {
53
+ // Bridge to CampaignCreationLogV2.process_id (D5). Assigned when the
54
+ // core starts; GET /processes joins item -> V2 log via this.
55
+ type: DataTypes.UUID,
56
+ allowNull: true,
57
+ field: "process_id",
58
+ },
59
+ agrs_cid: {
60
+ // AGRS Campaign ID — used by LOOP1 dedup and as the reclaim idempotency
61
+ // anchor (R2). Indexed for the campaign-level existence recheck.
62
+ type: DataTypes.STRING(255),
63
+ allowNull: true,
64
+ field: "agrs_cid",
65
+ },
66
+ idempotency_token: {
67
+ // Campaign-level idempotency anchor keyed by AGRS_CID + country group
68
+ // (R2). Lets a reclaimed re-entry prove "this campaign was already
69
+ // POSTed to FB" without a from-scratch re-create. Forward-compat now,
70
+ // populated when the S3 recheck lands.
71
+ type: DataTypes.STRING(255),
72
+ allowNull: true,
73
+ field: "idempotency_token",
74
+ },
75
+ lease_until: {
76
+ // R1: the REAL lease. A claimed item is owned until this instant. The
77
+ // reclaim sweep only re-dispatches when lease_until < now() (window set
78
+ // STRICTLY greater than worst-case single-creation wall time, incl.
79
+ // 80004 backoffs). A worker that loses its lease must abort, not write.
80
+ type: DataTypes.DATE,
81
+ allowNull: true,
82
+ field: "lease_until",
83
+ },
84
+ heartbeat_at: {
85
+ // R1 (optional gate): last liveness ping from the owning worker, so a
86
+ // long-but-alive creation isn't reclaimed on bare wall-clock alone.
87
+ type: DataTypes.DATE,
88
+ allowNull: true,
89
+ field: "heartbeat_at",
90
+ },
91
+ retry_count: {
92
+ // D-FAILURE: capped requeue count. Poison (exceeds cap) -> terminal
93
+ // FAILED, never re-looped.
94
+ type: DataTypes.INTEGER,
95
+ allowNull: false,
96
+ defaultValue: 0,
97
+ field: "retry_count",
98
+ },
99
+ error_message: {
100
+ type: DataTypes.TEXT,
101
+ allowNull: true,
102
+ field: "error_message",
103
+ },
104
+ error_code: {
105
+ // Machine-readable terminal cause (FB subcode / PG code / classifier
106
+ // verdict) surfaced via GET /processes.
107
+ type: DataTypes.STRING(100),
108
+ allowNull: true,
109
+ field: "error_code",
110
+ },
111
+ facebook_campaign_id: {
112
+ type: DataTypes.STRING(255),
113
+ allowNull: true,
114
+ field: "facebook_campaign_id",
115
+ },
116
+ claimed_at: {
117
+ type: DataTypes.DATE,
118
+ allowNull: true,
119
+ field: "claimed_at",
120
+ },
121
+ created_at: {
122
+ type: DataTypes.DATE,
123
+ allowNull: false,
124
+ defaultValue: DataTypes.NOW,
125
+ field: "created_at",
126
+ },
127
+ updated_at: {
128
+ type: DataTypes.DATE,
129
+ allowNull: true,
130
+ field: "updated_at",
131
+ },
132
+ completed_at: {
133
+ type: DataTypes.DATE,
134
+ allowNull: true,
135
+ field: "completed_at",
136
+ },
137
+ },
138
+ {
139
+ tableName: "public_api_batch_items",
140
+ timestamps: false,
141
+ // Named explicitly: prod sync runs alter:false, so any index added later
142
+ // to this (by-then existing) table needs a migration targeting the name.
143
+ indexes: [
144
+ { name: "pabi_batch_id_idx", fields: ["batch_id"] },
145
+ { name: "pabi_status_idx", fields: ["status"] },
146
+ // Composite: the reconciler roll-up filters by (batch_id, status).
147
+ { name: "pabi_batch_id_status_idx", fields: ["batch_id", "status"] },
148
+ // The poller claim scans PENDING oldest-first: (status, created_at).
149
+ { name: "pabi_status_created_at_idx", fields: ["status", "created_at"] },
150
+ { name: "pabi_process_id_idx", fields: ["process_id"] },
151
+ { name: "pabi_agrs_cid_idx", fields: ["agrs_cid"] },
152
+ { name: "pabi_lease_until_idx", fields: ["lease_until"] },
153
+ ],
154
+ }
155
+ );
156
+
157
+ PublicApiBatchItem.associate = (models) => {
158
+ PublicApiBatchItem.belongsTo(models.PublicApiBatch, {
159
+ foreignKey: "batch_id",
160
+ targetKey: "id",
161
+ as: "batch",
162
+ });
163
+ };
164
+
165
+ return PublicApiBatchItem;
166
+ };
@@ -1,135 +1,135 @@
1
- module.exports = (sequelize, DataTypes) => {
2
- const PublicApiIdempotencyCache = sequelize.define(
3
- "PublicApiIdempotencyCache",
4
- {
5
- id: {
6
- type: DataTypes.UUID,
7
- defaultValue: DataTypes.UUIDV4,
8
- primaryKey: true,
9
- },
10
- // Scope every cached response to the buyer's user. Two different
11
- // buyers using the same Idempotency-Key value must NOT collide, so
12
- // the uniqueness index below is (user_id, key), not (key) alone.
13
- user_id: {
14
- type: DataTypes.UUID,
15
- allowNull: false,
16
- references: {
17
- model: "Users",
18
- key: "id",
19
- },
20
- },
21
- // Which specific token issued the original request. Useful for
22
- // support/debug ("was it the same token retrying?") but not part
23
- // of uniqueness — a buyer rotating tokens mid-retry should still
24
- // hit the cached response.
25
- token_id: {
26
- type: DataTypes.UUID,
27
- allowNull: true,
28
- references: {
29
- model: "ApiToken",
30
- key: "id",
31
- },
32
- },
33
- // Client-supplied Idempotency-Key header. Buyer controls the value;
34
- // we just store it and dedupe on it.
35
- key: {
36
- type: DataTypes.STRING(255),
37
- allowNull: false,
38
- },
39
- // SHA-256 hex of the canonicalized request body. Used to reject
40
- // "same key + different body" (Stripe-style strict mode) with 422.
41
- // Canonicalization = JSON.stringify with sorted keys, so field-order
42
- // differences don't cause a false conflict.
43
- request_hash: {
44
- type: DataTypes.STRING(64),
45
- allowNull: false,
46
- },
47
- // HTTP method + path recorded for debug + admin views. Not used for
48
- // dedup logic — dedup is by (user_id, key). Different endpoints
49
- // reusing the same key is a client bug we surface via debug, not
50
- // silently accommodate.
51
- method: {
52
- type: DataTypes.STRING(10),
53
- allowNull: false,
54
- },
55
- path: {
56
- type: DataTypes.STRING,
57
- allowNull: false,
58
- },
59
- // NULL while the original request is still in-flight. Set to the
60
- // final HTTP status on completion. A row with status_code IS NULL
61
- // that hasn't expired means "another instance is currently
62
- // processing this key" → we return 409 idempotency_inflight to
63
- // the second caller so they retry after a beat.
64
- status_code: {
65
- type: DataTypes.INTEGER,
66
- allowNull: true,
67
- },
68
- // Cached response body — replayed verbatim on retry hit.
69
- response_body: {
70
- type: DataTypes.JSONB,
71
- allowNull: true,
72
- },
73
- // Cached response headers we want to replay (e.g. X-Request-Id
74
- // from the original, Location, etc). Optional — most replays are
75
- // fine without.
76
- response_headers: {
77
- type: DataTypes.JSONB,
78
- allowNull: true,
79
- },
80
- // Explicit expiry column so the TTL-sweep cron/pg_cron can
81
- // `DELETE WHERE expires_at < NOW()` without computing from
82
- // created_at. Defaults to created_at + 24h; controller sets this
83
- // when inserting so we don't rely on a DB trigger.
84
- expires_at: {
85
- type: DataTypes.DATE,
86
- allowNull: false,
87
- },
88
- },
89
- {
90
- tableName: "PublicApiIdempotencyCache",
91
- indexes: [
92
- // Primary lookup: (user_id, key). UNIQUE so Postgres resolves N
93
- // concurrent claims atomically — the loser hits a unique violation,
94
- // Sequelize's findOrCreate catches it, re-finds the winner's row, and
95
- // returns created=false. That is what makes exactly ONE of the "9
96
- // concurrent retries" execute the work while the rest get 409.
97
- //
98
- // THIS INDEX IS THE ENTIRE FEATURE. Without it every concurrent INSERT
99
- // succeeds and every retry re-runs the operation. Verified in prod on
100
- // 2026-07-12: index absent -> a 9-way burst gave 5 executions; index
101
- // present (local) -> exactly 1.
102
- //
103
- // Named explicitly so the migration that backfills it on already-created
104
- // tables targets the same index. sequelize.sync() runs with alter:false
105
- // in prod (server.js gates it on DB_COLUMN_TYPE_UPDATE), so it creates
106
- // the table but does NOT add indexes to a table that already exists.
107
- {
108
- name: "PublicApiIdempotencyCache_user_id_key_uniq",
109
- unique: true,
110
- fields: ["user_id", "key"],
111
- },
112
- // Sweep index — cron does `DELETE WHERE expires_at < NOW()`. Not
113
- // partial (would need to update after every insert); regular
114
- // btree is fine for the sweep pattern.
115
- {
116
- name: "PublicApiIdempotencyCache_expires_at_idx",
117
- fields: ["expires_at"],
118
- },
119
- ],
120
- }
121
- );
122
-
123
- PublicApiIdempotencyCache.associate = (models) => {
124
- PublicApiIdempotencyCache.belongsTo(models.Users, {
125
- foreignKey: "user_id",
126
- as: "user",
127
- });
128
- PublicApiIdempotencyCache.belongsTo(models.ApiToken, {
129
- foreignKey: "token_id",
130
- as: "token",
131
- });
132
- };
133
-
134
- return PublicApiIdempotencyCache;
135
- };
1
+ module.exports = (sequelize, DataTypes) => {
2
+ const PublicApiIdempotencyCache = sequelize.define(
3
+ "PublicApiIdempotencyCache",
4
+ {
5
+ id: {
6
+ type: DataTypes.UUID,
7
+ defaultValue: DataTypes.UUIDV4,
8
+ primaryKey: true,
9
+ },
10
+ // Scope every cached response to the buyer's user. Two different
11
+ // buyers using the same Idempotency-Key value must NOT collide, so
12
+ // the uniqueness index below is (user_id, key), not (key) alone.
13
+ user_id: {
14
+ type: DataTypes.UUID,
15
+ allowNull: false,
16
+ references: {
17
+ model: "Users",
18
+ key: "id",
19
+ },
20
+ },
21
+ // Which specific token issued the original request. Useful for
22
+ // support/debug ("was it the same token retrying?") but not part
23
+ // of uniqueness — a buyer rotating tokens mid-retry should still
24
+ // hit the cached response.
25
+ token_id: {
26
+ type: DataTypes.UUID,
27
+ allowNull: true,
28
+ references: {
29
+ model: "ApiToken",
30
+ key: "id",
31
+ },
32
+ },
33
+ // Client-supplied Idempotency-Key header. Buyer controls the value;
34
+ // we just store it and dedupe on it.
35
+ key: {
36
+ type: DataTypes.STRING(255),
37
+ allowNull: false,
38
+ },
39
+ // SHA-256 hex of the canonicalized request body. Used to reject
40
+ // "same key + different body" (Stripe-style strict mode) with 422.
41
+ // Canonicalization = JSON.stringify with sorted keys, so field-order
42
+ // differences don't cause a false conflict.
43
+ request_hash: {
44
+ type: DataTypes.STRING(64),
45
+ allowNull: false,
46
+ },
47
+ // HTTP method + path recorded for debug + admin views. Not used for
48
+ // dedup logic — dedup is by (user_id, key). Different endpoints
49
+ // reusing the same key is a client bug we surface via debug, not
50
+ // silently accommodate.
51
+ method: {
52
+ type: DataTypes.STRING(10),
53
+ allowNull: false,
54
+ },
55
+ path: {
56
+ type: DataTypes.STRING,
57
+ allowNull: false,
58
+ },
59
+ // NULL while the original request is still in-flight. Set to the
60
+ // final HTTP status on completion. A row with status_code IS NULL
61
+ // that hasn't expired means "another instance is currently
62
+ // processing this key" → we return 409 idempotency_inflight to
63
+ // the second caller so they retry after a beat.
64
+ status_code: {
65
+ type: DataTypes.INTEGER,
66
+ allowNull: true,
67
+ },
68
+ // Cached response body — replayed verbatim on retry hit.
69
+ response_body: {
70
+ type: DataTypes.JSONB,
71
+ allowNull: true,
72
+ },
73
+ // Cached response headers we want to replay (e.g. X-Request-Id
74
+ // from the original, Location, etc). Optional — most replays are
75
+ // fine without.
76
+ response_headers: {
77
+ type: DataTypes.JSONB,
78
+ allowNull: true,
79
+ },
80
+ // Explicit expiry column so the TTL-sweep cron/pg_cron can
81
+ // `DELETE WHERE expires_at < NOW()` without computing from
82
+ // created_at. Defaults to created_at + 24h; controller sets this
83
+ // when inserting so we don't rely on a DB trigger.
84
+ expires_at: {
85
+ type: DataTypes.DATE,
86
+ allowNull: false,
87
+ },
88
+ },
89
+ {
90
+ tableName: "PublicApiIdempotencyCache",
91
+ indexes: [
92
+ // Primary lookup: (user_id, key). UNIQUE so Postgres resolves N
93
+ // concurrent claims atomically — the loser hits a unique violation,
94
+ // Sequelize's findOrCreate catches it, re-finds the winner's row, and
95
+ // returns created=false. That is what makes exactly ONE of the "9
96
+ // concurrent retries" execute the work while the rest get 409.
97
+ //
98
+ // THIS INDEX IS THE ENTIRE FEATURE. Without it every concurrent INSERT
99
+ // succeeds and every retry re-runs the operation. Verified in prod on
100
+ // 2026-07-12: index absent -> a 9-way burst gave 5 executions; index
101
+ // present (local) -> exactly 1.
102
+ //
103
+ // Named explicitly so the migration that backfills it on already-created
104
+ // tables targets the same index. sequelize.sync() runs with alter:false
105
+ // in prod (server.js gates it on DB_COLUMN_TYPE_UPDATE), so it creates
106
+ // the table but does NOT add indexes to a table that already exists.
107
+ {
108
+ name: "PublicApiIdempotencyCache_user_id_key_uniq",
109
+ unique: true,
110
+ fields: ["user_id", "key"],
111
+ },
112
+ // Sweep index — cron does `DELETE WHERE expires_at < NOW()`. Not
113
+ // partial (would need to update after every insert); regular
114
+ // btree is fine for the sweep pattern.
115
+ {
116
+ name: "PublicApiIdempotencyCache_expires_at_idx",
117
+ fields: ["expires_at"],
118
+ },
119
+ ],
120
+ }
121
+ );
122
+
123
+ PublicApiIdempotencyCache.associate = (models) => {
124
+ PublicApiIdempotencyCache.belongsTo(models.Users, {
125
+ foreignKey: "user_id",
126
+ as: "user",
127
+ });
128
+ PublicApiIdempotencyCache.belongsTo(models.ApiToken, {
129
+ foreignKey: "token_id",
130
+ as: "token",
131
+ });
132
+ };
133
+
134
+ return PublicApiIdempotencyCache;
135
+ };