@shipfox/api-integration-jira 11.0.0 → 12.1.0

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 (126) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +34 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +131 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/config.d.ts +0 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -4
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-tools-provider.d.ts +26 -0
  13. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  14. package/dist/core/agent-tools-provider.js +130 -0
  15. package/dist/core/agent-tools-provider.js.map +1 -0
  16. package/dist/core/agent-tools.d.ts +16 -0
  17. package/dist/core/agent-tools.d.ts.map +1 -0
  18. package/dist/core/agent-tools.js +441 -0
  19. package/dist/core/agent-tools.js.map +1 -0
  20. package/dist/core/install.d.ts +22 -0
  21. package/dist/core/install.d.ts.map +1 -1
  22. package/dist/core/install.js +68 -20
  23. package/dist/core/install.js.map +1 -1
  24. package/dist/core/tokens.d.ts +2 -0
  25. package/dist/core/tokens.d.ts.map +1 -1
  26. package/dist/core/tokens.js +129 -37
  27. package/dist/core/tokens.js.map +1 -1
  28. package/dist/core/webhook-processor.d.ts +15 -0
  29. package/dist/core/webhook-processor.d.ts.map +1 -0
  30. package/dist/core/webhook-processor.js +137 -0
  31. package/dist/core/webhook-processor.js.map +1 -0
  32. package/dist/core/webhook-registration.d.ts +26 -0
  33. package/dist/core/webhook-registration.d.ts.map +1 -0
  34. package/dist/core/webhook-registration.js +165 -0
  35. package/dist/core/webhook-registration.js.map +1 -0
  36. package/dist/core/webhook.d.ts +18 -0
  37. package/dist/core/webhook.d.ts.map +1 -0
  38. package/dist/core/webhook.js +38 -0
  39. package/dist/core/webhook.js.map +1 -0
  40. package/dist/db/db.d.ts +68 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/installations.d.ts +33 -6
  43. package/dist/db/installations.d.ts.map +1 -1
  44. package/dist/db/installations.js +79 -7
  45. package/dist/db/installations.js.map +1 -1
  46. package/dist/db/schema/installations.d.ts +34 -0
  47. package/dist/db/schema/installations.d.ts.map +1 -1
  48. package/dist/db/schema/installations.js +12 -2
  49. package/dist/db/schema/installations.js.map +1 -1
  50. package/dist/index.d.ts +34 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +76 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/presentation/routes/install.d.ts +22 -0
  55. package/dist/presentation/routes/install.d.ts.map +1 -1
  56. package/dist/presentation/routes/install.js.map +1 -1
  57. package/dist/presentation/routes/webhooks.d.ts +8 -0
  58. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  59. package/dist/presentation/routes/webhooks.js +108 -0
  60. package/dist/presentation/routes/webhooks.js.map +1 -0
  61. package/dist/temporal/activities/index.d.ts +2 -0
  62. package/dist/temporal/activities/index.d.ts.map +1 -0
  63. package/dist/temporal/activities/index.js +3 -0
  64. package/dist/temporal/activities/index.js.map +1 -0
  65. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  66. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  67. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  68. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  69. package/dist/temporal/constants.d.ts +6 -0
  70. package/dist/temporal/constants.d.ts.map +1 -0
  71. package/dist/temporal/constants.js +8 -0
  72. package/dist/temporal/constants.js.map +1 -0
  73. package/dist/temporal/worker.d.ts +9 -0
  74. package/dist/temporal/worker.d.ts.map +1 -0
  75. package/dist/temporal/worker.js +26 -0
  76. package/dist/temporal/worker.js.map +1 -0
  77. package/dist/temporal/workflows/index.bundle.js +25225 -0
  78. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  79. package/dist/temporal/workflows/index.d.ts +2 -0
  80. package/dist/temporal/workflows/index.d.ts.map +1 -0
  81. package/dist/temporal/workflows/index.js +3 -0
  82. package/dist/temporal/workflows/index.js.map +1 -0
  83. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  84. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  85. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  86. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  89. package/drizzle/meta/0001_snapshot.json +245 -0
  90. package/drizzle/meta/_journal.json +7 -0
  91. package/package.json +17 -8
  92. package/src/api/client.test.ts +236 -9
  93. package/src/api/client.ts +192 -0
  94. package/src/config.test.ts +0 -1
  95. package/src/config.ts +1 -4
  96. package/src/core/agent-tools-provider.test.ts +299 -0
  97. package/src/core/agent-tools-provider.ts +175 -0
  98. package/src/core/agent-tools.ts +450 -0
  99. package/src/core/install.test.ts +179 -0
  100. package/src/core/install.ts +85 -19
  101. package/src/core/tokens-refresh.test.ts +223 -4
  102. package/src/core/tokens.test.ts +1 -1
  103. package/src/core/tokens.ts +153 -33
  104. package/src/core/webhook-processor.test.ts +418 -0
  105. package/src/core/webhook-processor.ts +166 -0
  106. package/src/core/webhook-registration.test.ts +375 -0
  107. package/src/core/webhook-registration.ts +216 -0
  108. package/src/core/webhook.ts +69 -0
  109. package/src/db/installations.test.ts +148 -2
  110. package/src/db/installations.ts +133 -7
  111. package/src/db/schema/installations.ts +21 -1
  112. package/src/index.test.ts +42 -0
  113. package/src/index.ts +148 -5
  114. package/src/presentation/routes/install.ts +12 -0
  115. package/src/presentation/routes/webhooks.test.ts +132 -0
  116. package/src/presentation/routes/webhooks.ts +115 -0
  117. package/src/temporal/activities/index.ts +5 -0
  118. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  119. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  120. package/src/temporal/constants.ts +8 -0
  121. package/src/temporal/worker.ts +39 -0
  122. package/src/temporal/workflows/index.ts +1 -0
  123. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  124. package/test/env.ts +0 -1
  125. package/test/factories/jira-installation.ts +4 -0
  126. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,216 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import type {JiraApiClient} from '#api/client.js';
3
+ import {
4
+ getJiraInstallationByConnectionId,
5
+ type JiraInstallationLock,
6
+ updateJiraInstallationWebhook,
7
+ withJiraInstallationLock,
8
+ } from '#db/installations.js';
9
+
10
+ export const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
11
+
12
+ export interface RegisterJiraWebhookParams {
13
+ jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;
14
+ connectionId: string;
15
+ cloudId: string;
16
+ accessToken: string;
17
+ webhookUrl: string;
18
+ now?: () => Date;
19
+ getInstallation?: typeof getJiraInstallationByConnectionId;
20
+ updateInstallation?: typeof updateJiraInstallationWebhook;
21
+ withRegistrationLock?: JiraInstallationLock;
22
+ onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;
23
+ onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;
24
+ }
25
+
26
+ /** Register Jira's curated webhook and replace local metadata as one serialized state transition. */
27
+ export async function registerJiraWebhook(
28
+ params: RegisterJiraWebhookParams,
29
+ ): Promise<{webhookId: number; webhookExpiresAt: Date}> {
30
+ const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;
31
+ let callbackFailed = false;
32
+ let registeredWebhookId: number | undefined;
33
+ let previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>;
34
+ let metadataPersisted = false;
35
+ let cleanupSupersededWebhooks: (() => Promise<void>) | undefined;
36
+ let result: {webhookId: number; webhookExpiresAt: Date};
37
+ try {
38
+ result = await withRegistrationLock(params.cloudId, async () => {
39
+ try {
40
+ const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
41
+ previous = await getInstallation(params.connectionId);
42
+ const registration = await params.jira.registerDynamicWebhook({
43
+ accessToken: params.accessToken,
44
+ cloudId: params.cloudId,
45
+ url: params.webhookUrl,
46
+ });
47
+ registeredWebhookId = registration.webhookId;
48
+ const now = params.now ?? (() => new Date());
49
+ const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);
50
+
51
+ const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
52
+ const updateInput = {
53
+ connectionId: params.connectionId,
54
+ webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],
55
+ webhookExpiresAt,
56
+ };
57
+ const installation = await updateInstallation(updateInput);
58
+ if (!installation)
59
+ throw new Error('Jira webhook registration lost its installation record');
60
+ metadataPersisted = true;
61
+ await params.onRegistrationSuccess?.({});
62
+
63
+ cleanupSupersededWebhooks = () =>
64
+ finishSupersededWebhookCleanup(
65
+ params,
66
+ previous,
67
+ registration.webhookId,
68
+ webhookExpiresAt,
69
+ );
70
+ return {webhookId: registration.webhookId, webhookExpiresAt};
71
+ } catch (error) {
72
+ callbackFailed = true;
73
+ if (registeredWebhookId !== undefined) {
74
+ const deleted = await deleteNewWebhookAfterPersistenceFailure(
75
+ params,
76
+ registeredWebhookId,
77
+ );
78
+ if (metadataPersisted || !deleted) {
79
+ await restorePreviousWebhookMetadata(
80
+ params,
81
+ previous,
82
+ deleted ? undefined : registeredWebhookId,
83
+ );
84
+ }
85
+ } else if (metadataPersisted) {
86
+ await restorePreviousWebhookMetadata(params, previous);
87
+ }
88
+ await bestEffortRegistrationFailure(params);
89
+ throw error;
90
+ }
91
+ });
92
+ } catch (error) {
93
+ if (!callbackFailed) {
94
+ if (registeredWebhookId !== undefined) {
95
+ const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
96
+ if (metadataPersisted || !deleted) {
97
+ await restorePreviousWebhookMetadata(
98
+ params,
99
+ previous,
100
+ deleted ? undefined : registeredWebhookId,
101
+ );
102
+ }
103
+ } else if (metadataPersisted) {
104
+ await restorePreviousWebhookMetadata(params, previous);
105
+ }
106
+ await bestEffortRegistrationFailure(params);
107
+ }
108
+ throw error;
109
+ }
110
+
111
+ if (cleanupSupersededWebhooks) {
112
+ try {
113
+ await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);
114
+ } catch (error) {
115
+ logger().warn(
116
+ {err: error, connectionId: params.connectionId},
117
+ 'Jira superseded webhook cleanup persistence failed',
118
+ );
119
+ }
120
+ }
121
+
122
+ return result;
123
+ }
124
+
125
+ async function finishSupersededWebhookCleanup(
126
+ params: RegisterJiraWebhookParams,
127
+ previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,
128
+ registeredWebhookId: number,
129
+ webhookExpiresAt: Date,
130
+ ): Promise<void> {
131
+ const supersededWebhookIds = (previous?.webhookIds ?? []).filter(
132
+ (webhookId) => webhookId !== registeredWebhookId,
133
+ );
134
+ if (supersededWebhookIds.length === 0) return;
135
+
136
+ const failedCleanupIds: number[] = [];
137
+ for (const webhookId of supersededWebhookIds) {
138
+ try {
139
+ await params.jira.deleteDynamicWebhook({
140
+ accessToken: params.accessToken,
141
+ cloudId: params.cloudId,
142
+ webhookId,
143
+ });
144
+ } catch (error) {
145
+ failedCleanupIds.push(webhookId);
146
+ logger().warn(
147
+ {err: error, connectionId: params.connectionId, webhookId},
148
+ 'Jira superseded webhook cleanup failed',
149
+ );
150
+ }
151
+ }
152
+
153
+ const retainedWebhookIds = [registeredWebhookId, ...failedCleanupIds];
154
+ const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
155
+ const cleanupMetadata = await updateInstallation({
156
+ connectionId: params.connectionId,
157
+ webhookIds: retainedWebhookIds,
158
+ webhookExpiresAt,
159
+ });
160
+ if (!cleanupMetadata)
161
+ throw new Error('Jira webhook cleanup metadata persistence returned no installation');
162
+ }
163
+
164
+ async function bestEffortRegistrationFailure(params: RegisterJiraWebhookParams): Promise<void> {
165
+ try {
166
+ await params.onRegistrationFailure?.({});
167
+ } catch (error) {
168
+ logger().warn(
169
+ {err: error, connectionId: params.connectionId},
170
+ 'Jira connection error-state update failed after webhook registration rejection',
171
+ );
172
+ }
173
+ }
174
+
175
+ async function restorePreviousWebhookMetadata(
176
+ params: RegisterJiraWebhookParams,
177
+ previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,
178
+ retainedWebhookId?: number,
179
+ ): Promise<void> {
180
+ try {
181
+ const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
182
+ await updateInstallation({
183
+ connectionId: params.connectionId,
184
+ webhookIds: [
185
+ ...(retainedWebhookId === undefined ? [] : [retainedWebhookId]),
186
+ ...(previous?.webhookIds ?? []).filter((webhookId) => webhookId !== retainedWebhookId),
187
+ ],
188
+ webhookExpiresAt: previous?.webhookExpiresAt ?? null,
189
+ });
190
+ } catch (error) {
191
+ logger().warn(
192
+ {err: error, connectionId: params.connectionId},
193
+ 'Jira webhook metadata restoration failed after registration rejection',
194
+ );
195
+ }
196
+ }
197
+
198
+ async function deleteNewWebhookAfterPersistenceFailure(
199
+ params: RegisterJiraWebhookParams,
200
+ webhookId: number,
201
+ ): Promise<boolean> {
202
+ try {
203
+ await params.jira.deleteDynamicWebhook({
204
+ accessToken: params.accessToken,
205
+ cloudId: params.cloudId,
206
+ webhookId,
207
+ });
208
+ return true;
209
+ } catch (cleanupError) {
210
+ logger().warn(
211
+ {err: cleanupError, connectionId: params.connectionId, webhookId},
212
+ 'Jira webhook cleanup failed after metadata persistence rejection',
213
+ );
214
+ return false;
215
+ }
216
+ }
@@ -0,0 +1,69 @@
1
+ import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';
2
+ import type {
3
+ IntegrationConnection,
4
+ IntegrationTx,
5
+ PublishIntegrationEventReceivedFn,
6
+ RecordDeliveryOnlyFn,
7
+ } from '@shipfox/api-integration-spi';
8
+ import type {JiraInstallation} from '#db/installations.js';
9
+
10
+ const JIRA_PROVIDER = 'jira';
11
+
12
+ export interface HandleJiraWebhookParams {
13
+ tx: IntegrationTx;
14
+ deliveryId: string;
15
+ receivedAt: string;
16
+ rawPayload: JiraWebhookEnvelopeDto;
17
+ cloudId: string;
18
+ connection: IntegrationConnection<'jira'>;
19
+ authorizingAccountId: string;
20
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
21
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
22
+ }
23
+
24
+ export type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';
25
+
26
+ export async function handleJiraWebhook(
27
+ params: HandleJiraWebhookParams,
28
+ ): Promise<HandleJiraWebhookResult> {
29
+ if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {
30
+ await params.recordDeliveryOnly({
31
+ tx: params.tx,
32
+ provider: JIRA_PROVIDER,
33
+ deliveryId: params.deliveryId,
34
+ });
35
+ return 'discarded';
36
+ }
37
+
38
+ const payload = {...params.rawPayload, cloudId: params.cloudId};
39
+ const result = await params.publishIntegrationEventReceived({
40
+ tx: params.tx,
41
+ event: {
42
+ provider: JIRA_PROVIDER,
43
+ source: params.connection.slug,
44
+ event: params.rawPayload.webhookEvent,
45
+ workspaceId: params.connection.workspaceId,
46
+ connectionId: params.connection.id,
47
+ connectionName: params.connection.displayName,
48
+ deliveryId: params.deliveryId,
49
+ receivedAt: params.receivedAt,
50
+ payload,
51
+ },
52
+ });
53
+ return result.published ? 'published' : 'duplicate';
54
+ }
55
+
56
+ function isSelfAuthoredEvent(payload: JiraWebhookEnvelopeDto, authorizingAccountId: string) {
57
+ return (
58
+ (payload.webhookEvent === 'comment_created' ||
59
+ payload.webhookEvent === 'comment_updated' ||
60
+ payload.webhookEvent === 'jira:issue_updated') &&
61
+ payload.user.accountId === authorizingAccountId
62
+ );
63
+ }
64
+
65
+ export function isJiraInstallationUsable(
66
+ installation: JiraInstallation | undefined,
67
+ ): installation is JiraInstallation {
68
+ return installation?.status === 'installed';
69
+ }
@@ -7,9 +7,13 @@ import {
7
7
  getJiraInstallationByCloudId,
8
8
  getJiraInstallationByConnectionId,
9
9
  getJiraInstallationByWebhookId,
10
+ listJiraInstallationsDueForTokenRefresh,
10
11
  markJiraInstallationRevoked,
12
+ markJiraInstallationTokenRefreshAttempt,
11
13
  updateJiraInstallationTokenExpiry,
14
+ updateJiraInstallationWebhook,
12
15
  upsertJiraInstallation,
16
+ withJiraWebhookRegistrationLock,
13
17
  } from './installations.js';
14
18
 
15
19
  function createInstallationInput(
@@ -62,6 +66,36 @@ describe('jira installations', () => {
62
66
  });
63
67
  });
64
68
 
69
+ it('preserves webhook metadata when reconnect upsert omits it', async () => {
70
+ const webhookExpiresAt = new Date('2030-01-01T00:00:00.000Z');
71
+ const input = createInstallationInput({webhookIds: [321], webhookExpiresAt});
72
+ await upsertJiraInstallation(input);
73
+
74
+ const result = await upsertJiraInstallation({
75
+ ...input,
76
+ scopes: ['read:jira-work', 'write:jira-work'],
77
+ webhookIds: undefined,
78
+ webhookExpiresAt: undefined,
79
+ });
80
+
81
+ expect(result.webhookIds).toEqual([321]);
82
+ expect(result.webhookExpiresAt).toEqual(webhookExpiresAt);
83
+ });
84
+
85
+ it('replaces webhook ids and expiry together after registration', async () => {
86
+ const input = createInstallationInput({webhookIds: [321]});
87
+ await upsertJiraInstallation(input);
88
+ const webhookExpiresAt = new Date('2030-02-01T00:00:00.000Z');
89
+
90
+ const result = await updateJiraInstallationWebhook({
91
+ connectionId: input.connectionId,
92
+ webhookIds: [654],
93
+ webhookExpiresAt,
94
+ });
95
+
96
+ expect(result).toMatchObject({webhookIds: [654], webhookExpiresAt});
97
+ });
98
+
65
99
  it('refuses to repoint a connection to a different Jira site', async () => {
66
100
  const first = createInstallationInput();
67
101
  await upsertJiraInstallation(first);
@@ -89,10 +123,19 @@ describe('jira installations', () => {
89
123
  });
90
124
 
91
125
  it('updates token metadata and deletes an installation', async () => {
92
- const input = createInstallationInput();
93
- await upsertJiraInstallation(input);
126
+ const input = createInstallationInput({
127
+ refreshTokenLastUsedAt: new Date('2020-01-01T00:00:00.000Z'),
128
+ refreshTokenLastAttemptedAt: new Date('2020-01-01T00:00:00.000Z'),
129
+ });
130
+ const initial = await upsertJiraInstallation(input);
94
131
  const tokenExpiresAt = new Date('2030-01-01T00:00:00.000Z');
95
132
 
133
+ const attempted = await markJiraInstallationTokenRefreshAttempt(input.connectionId);
134
+
135
+ expect(attempted?.refreshTokenLastAttemptedAt.getTime()).toBeGreaterThan(
136
+ initial.refreshTokenLastAttemptedAt.getTime(),
137
+ );
138
+
96
139
  const updated = await updateJiraInstallationTokenExpiry({
97
140
  connectionId: input.connectionId,
98
141
  tokenExpiresAt,
@@ -101,10 +144,81 @@ describe('jira installations', () => {
101
144
  const deleted = await deleteJiraInstallationByConnectionId(input.connectionId);
102
145
 
103
146
  expect(updated).toMatchObject({tokenExpiresAt, scopes: ['read:jira-work', 'write:jira-work']});
147
+ expect(updated?.refreshTokenLastUsedAt.getTime()).toBeGreaterThan(
148
+ initial.refreshTokenLastUsedAt.getTime(),
149
+ );
104
150
  expect(deleted).toBe(true);
105
151
  await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toBeUndefined();
106
152
  });
107
153
 
154
+ it('lists only installed installations whose refresh tokens are nearing idle expiry', async () => {
155
+ const due = createInstallationInput({
156
+ refreshTokenLastUsedAt: new Date('2026-01-01T00:00:00.000Z'),
157
+ });
158
+ const fresh = createInstallationInput({
159
+ refreshTokenLastUsedAt: new Date('2026-07-01T00:00:00.000Z'),
160
+ });
161
+ const boundary = createInstallationInput({
162
+ refreshTokenLastUsedAt: new Date('2026-06-01T00:00:00.000Z'),
163
+ });
164
+ const revoked = createInstallationInput({
165
+ refreshTokenLastUsedAt: new Date('2026-01-01T00:00:00.000Z'),
166
+ status: 'revoked',
167
+ });
168
+ await upsertJiraInstallation(due);
169
+ await upsertJiraInstallation(fresh);
170
+ await upsertJiraInstallation(boundary);
171
+ await upsertJiraInstallation(revoked);
172
+
173
+ const result = await listJiraInstallationsDueForTokenRefresh({
174
+ before: new Date('2026-06-01T00:00:00.000Z'),
175
+ });
176
+
177
+ expect(result.map((installation) => installation.connectionId)).toContain(due.connectionId);
178
+ expect(result.map((installation) => installation.connectionId)).toContain(
179
+ boundary.connectionId,
180
+ );
181
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
182
+ fresh.connectionId,
183
+ );
184
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
185
+ revoked.connectionId,
186
+ );
187
+ });
188
+
189
+ it('orders due installations by their latest refresh attempt', async () => {
190
+ const refreshTokenLastUsedAt = new Date('1970-01-01T00:00:00.000Z');
191
+ const attemptBase = Date.now();
192
+ const latestAttempt = createInstallationInput({
193
+ refreshTokenLastUsedAt,
194
+ refreshTokenLastAttemptedAt: new Date(attemptBase + 3_000),
195
+ });
196
+ const middleAttempt = createInstallationInput({
197
+ refreshTokenLastUsedAt,
198
+ refreshTokenLastAttemptedAt: new Date(attemptBase + 2_000),
199
+ });
200
+ const oldestAttempt = createInstallationInput({
201
+ refreshTokenLastUsedAt,
202
+ refreshTokenLastAttemptedAt: new Date(attemptBase + 1_000),
203
+ });
204
+ await upsertJiraInstallation(latestAttempt);
205
+ await upsertJiraInstallation(middleAttempt);
206
+ await upsertJiraInstallation(oldestAttempt);
207
+
208
+ const result = await listJiraInstallationsDueForTokenRefresh({
209
+ before: new Date('2000-01-01T00:00:00.000Z'),
210
+ limit: 1_000,
211
+ });
212
+ const connectionIds = result.map((installation) => installation.connectionId);
213
+
214
+ expect(connectionIds.indexOf(oldestAttempt.connectionId)).toBeLessThan(
215
+ connectionIds.indexOf(middleAttempt.connectionId),
216
+ );
217
+ expect(connectionIds.indexOf(middleAttempt.connectionId)).toBeLessThan(
218
+ connectionIds.indexOf(latestAttempt.connectionId),
219
+ );
220
+ });
221
+
108
222
  it('returns undefined for unknown connection and webhook ids', async () => {
109
223
  const connectionId = crypto.randomUUID();
110
224
  const webhookId = Number.MAX_SAFE_INTEGER;
@@ -126,4 +240,36 @@ describe('jira installations', () => {
126
240
  expect(revoked?.status).toBe('revoked');
127
241
  expect(missing).toBeUndefined();
128
242
  });
243
+
244
+ it('serializes webhook registration lock contenders for one connection', async () => {
245
+ const connectionId = crypto.randomUUID();
246
+ let releaseFirst!: () => void;
247
+ let firstEntered!: () => void;
248
+ const firstReady = new Promise<void>((resolve) => {
249
+ firstEntered = resolve;
250
+ });
251
+ const firstRelease = new Promise<void>((resolve) => {
252
+ releaseFirst = resolve;
253
+ });
254
+
255
+ const first = withJiraWebhookRegistrationLock(connectionId, async () => {
256
+ firstEntered();
257
+ await firstRelease;
258
+ return 'first';
259
+ });
260
+ await firstReady;
261
+
262
+ let secondFinished = false;
263
+ const second = withJiraWebhookRegistrationLock(connectionId, () => {
264
+ secondFinished = true;
265
+ return Promise.resolve('second');
266
+ });
267
+ await Promise.resolve();
268
+ expect(secondFinished).toBe(false);
269
+
270
+ releaseFirst();
271
+ await expect(first).resolves.toBe('first');
272
+ await expect(second).resolves.toBe('second');
273
+ expect(secondFinished).toBe(true);
274
+ });
129
275
  });
@@ -1,6 +1,6 @@
1
1
  import {isUniqueViolation} from '@shipfox/node-drizzle';
2
- import {pgClient} from '@shipfox/node-postgres';
3
- import {eq, sql} from 'drizzle-orm';
2
+ import {pgClient, withPostgresSession} from '@shipfox/node-postgres';
3
+ import {and, asc, eq, lte, sql} from 'drizzle-orm';
4
4
  import {
5
5
  JiraConnectionAlreadyLinkedError,
6
6
  JiraInstallationAlreadyLinkedError,
@@ -9,6 +9,33 @@ import {
9
9
  import {db} from './db.js';
10
10
  import {jiraInstallations, toJiraInstallation} from './schema/installations.js';
11
11
 
12
+ export type JiraInstallationLock = <T>(lockKey: string, fn: () => Promise<T>) => Promise<T>;
13
+
14
+ const JIRA_ADVISORY_LOCK_RETRY_DELAY_MS = 100;
15
+ const JIRA_ADVISORY_LOCK_MAX_RETRY_DELAY_MS = 1_000;
16
+ const JIRA_ADVISORY_LOCK_TIMEOUT_MS = 30_000;
17
+
18
+ type JiraAdvisoryLockAttempt<T> = {acquired: true; value: T} | {acquired: false};
19
+
20
+ export function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>) {
21
+ const advisoryKey = `jira-installation:${lockKey}`;
22
+ return withJiraAdvisoryLockWait(`Jira installation lock: ${lockKey}`, () =>
23
+ withPostgresSession(async (client) => {
24
+ const lock = await client.query<{acquired: boolean}>(
25
+ 'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
26
+ [advisoryKey],
27
+ );
28
+ if (lock.rows[0]?.acquired !== true) return {acquired: false as const};
29
+
30
+ try {
31
+ return {acquired: true as const, value: await fn()};
32
+ } finally {
33
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [advisoryKey]);
34
+ }
35
+ }),
36
+ );
37
+ }
38
+
12
39
  export type JiraInstallationStatus = 'installed' | 'revoked';
13
40
 
14
41
  export interface JiraInstallation {
@@ -23,6 +50,8 @@ export interface JiraInstallation {
23
50
  webhookExpiresAt: Date | null;
24
51
  status: JiraInstallationStatus;
25
52
  tokenExpiresAt: Date | null;
53
+ refreshTokenLastUsedAt: Date;
54
+ refreshTokenLastAttemptedAt: Date;
26
55
  createdAt: Date;
27
56
  updatedAt: Date;
28
57
  }
@@ -38,6 +67,8 @@ export interface UpsertJiraInstallationParams {
38
67
  webhookExpiresAt?: Date | null | undefined;
39
68
  status: JiraInstallationStatus;
40
69
  tokenExpiresAt?: Date | null | undefined;
70
+ refreshTokenLastUsedAt?: Date | undefined;
71
+ refreshTokenLastAttemptedAt?: Date | undefined;
41
72
  }
42
73
 
43
74
  export interface UpdateJiraInstallationTokenExpiryParams {
@@ -46,6 +77,12 @@ export interface UpdateJiraInstallationTokenExpiryParams {
46
77
  scopes?: string[] | undefined;
47
78
  }
48
79
 
80
+ export interface UpdateJiraInstallationWebhookParams {
81
+ connectionId: string;
82
+ webhookIds: number[];
83
+ webhookExpiresAt: Date | null;
84
+ }
85
+
49
86
  type JiraDb = ReturnType<typeof db>;
50
87
  type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
51
88
 
@@ -55,7 +92,6 @@ export async function upsertJiraInstallation(
55
92
  ): Promise<JiraInstallation> {
56
93
  const executor = (options.tx ?? db()) as JiraDb | JiraTx;
57
94
  const now = new Date();
58
- const webhookIds = params.webhookIds ?? [];
59
95
  let row: typeof jiraInstallations.$inferSelect | undefined;
60
96
  try {
61
97
  [row] = await executor
@@ -67,10 +103,12 @@ export async function upsertJiraInstallation(
67
103
  siteName: params.siteName,
68
104
  authorizingAccountId: params.authorizingAccountId,
69
105
  scopes: params.scopes,
70
- webhookIds,
106
+ webhookIds: params.webhookIds ?? [],
71
107
  webhookExpiresAt: params.webhookExpiresAt ?? null,
72
108
  status: params.status,
73
109
  tokenExpiresAt: params.tokenExpiresAt ?? null,
110
+ refreshTokenLastUsedAt: params.refreshTokenLastUsedAt ?? now,
111
+ refreshTokenLastAttemptedAt: params.refreshTokenLastAttemptedAt ?? now,
74
112
  })
75
113
  .onConflictDoUpdate({
76
114
  target: jiraInstallations.connectionId,
@@ -81,10 +119,14 @@ export async function upsertJiraInstallation(
81
119
  siteName: params.siteName,
82
120
  authorizingAccountId: params.authorizingAccountId,
83
121
  scopes: params.scopes,
84
- webhookIds,
85
- webhookExpiresAt: params.webhookExpiresAt ?? null,
122
+ ...(params.webhookIds === undefined ? {} : {webhookIds: params.webhookIds}),
123
+ ...(params.webhookExpiresAt === undefined
124
+ ? {}
125
+ : {webhookExpiresAt: params.webhookExpiresAt}),
86
126
  status: params.status,
87
127
  tokenExpiresAt: params.tokenExpiresAt ?? null,
128
+ refreshTokenLastUsedAt: params.refreshTokenLastUsedAt ?? now,
129
+ refreshTokenLastAttemptedAt: params.refreshTokenLastAttemptedAt ?? now,
88
130
  updatedAt: now,
89
131
  },
90
132
  })
@@ -122,11 +164,63 @@ export async function updateJiraInstallationTokenExpiry(
122
164
  options: {tx?: unknown} = {},
123
165
  ): Promise<JiraInstallation | undefined> {
124
166
  const executor = (options.tx ?? db()) as JiraDb | JiraTx;
167
+ const now = new Date();
125
168
  const [row] = await executor
126
169
  .update(jiraInstallations)
127
170
  .set({
128
171
  tokenExpiresAt: params.tokenExpiresAt,
172
+ refreshTokenLastUsedAt: now,
173
+ refreshTokenLastAttemptedAt: now,
129
174
  ...(params.scopes === undefined ? {} : {scopes: params.scopes}),
175
+ updatedAt: now,
176
+ })
177
+ .where(eq(jiraInstallations.connectionId, params.connectionId))
178
+ .returning();
179
+ return row ? toJiraInstallation(row) : undefined;
180
+ }
181
+
182
+ export async function markJiraInstallationTokenRefreshAttempt(
183
+ connectionId: string,
184
+ options: {tx?: unknown} = {},
185
+ ): Promise<JiraInstallation | undefined> {
186
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
187
+ const [row] = await executor
188
+ .update(jiraInstallations)
189
+ .set({refreshTokenLastAttemptedAt: new Date()})
190
+ .where(eq(jiraInstallations.connectionId, connectionId))
191
+ .returning();
192
+ return row ? toJiraInstallation(row) : undefined;
193
+ }
194
+
195
+ export async function listJiraInstallationsDueForTokenRefresh(
196
+ params: {before: Date; limit?: number | undefined},
197
+ options: {tx?: unknown} = {},
198
+ ): Promise<JiraInstallation[]> {
199
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
200
+ const rows = await executor
201
+ .select()
202
+ .from(jiraInstallations)
203
+ .where(
204
+ and(
205
+ eq(jiraInstallations.status, 'installed'),
206
+ lte(jiraInstallations.refreshTokenLastUsedAt, params.before),
207
+ ),
208
+ )
209
+ .orderBy(asc(jiraInstallations.refreshTokenLastAttemptedAt))
210
+ .limit(params.limit ?? 100);
211
+ return rows.map(toJiraInstallation);
212
+ }
213
+
214
+ export async function updateJiraInstallationWebhook(
215
+ params: UpdateJiraInstallationWebhookParams,
216
+ options: {tx?: unknown} = {},
217
+ ): Promise<JiraInstallation | undefined> {
218
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
219
+ const [row] = await executor
220
+ .update(jiraInstallations)
221
+ .set({
222
+ webhookIds: params.webhookIds,
223
+ webhookExpiresAt: params.webhookExpiresAt,
130
224
  updatedAt: new Date(),
131
225
  })
132
226
  .where(eq(jiraInstallations.connectionId, params.connectionId))
@@ -145,7 +239,7 @@ export async function deleteJiraInstallationByConnectionId(
145
239
  return (result.rowCount ?? 0) > 0;
146
240
  }
147
241
 
148
- export type JiraRefreshLockResult<T> = {acquired: true; value: T} | {acquired: false};
242
+ export type JiraRefreshLockResult<T> = JiraAdvisoryLockAttempt<T>;
149
243
 
150
244
  export function withJiraRefreshLock<T>(
151
245
  connectionId: string,
@@ -154,6 +248,22 @@ export function withJiraRefreshLock<T>(
154
248
  return withJiraRefreshLockClient(connectionId, fn);
155
249
  }
156
250
 
251
+ export function withJiraRefreshLockAndWait<T>(
252
+ connectionId: string,
253
+ fn: () => Promise<T>,
254
+ ): Promise<T> {
255
+ return withJiraAdvisoryLockWait(`Jira refresh lock: ${connectionId}`, () =>
256
+ withJiraRefreshLock(connectionId, fn),
257
+ );
258
+ }
259
+
260
+ export function withJiraWebhookRegistrationLock<T>(
261
+ lockKey: string,
262
+ fn: (tx?: unknown) => Promise<T>,
263
+ ): Promise<T> {
264
+ return withJiraInstallationLock(lockKey, () => fn());
265
+ }
266
+
157
267
  async function withJiraRefreshLockClient<T>(
158
268
  connectionId: string,
159
269
  fn: () => Promise<T>,
@@ -177,6 +287,22 @@ async function withJiraRefreshLockClient<T>(
177
287
  }
178
288
  }
179
289
 
290
+ async function withJiraAdvisoryLockWait<T>(
291
+ lockDescription: string,
292
+ attemptLock: () => Promise<JiraAdvisoryLockAttempt<T>>,
293
+ ): Promise<T> {
294
+ const deadline = Date.now() + JIRA_ADVISORY_LOCK_TIMEOUT_MS;
295
+ let retryDelayMs = JIRA_ADVISORY_LOCK_RETRY_DELAY_MS;
296
+
297
+ while (true) {
298
+ const attempt = await attemptLock();
299
+ if (attempt.acquired) return attempt.value;
300
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${lockDescription}`);
301
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
302
+ retryDelayMs = Math.min(retryDelayMs * 2, JIRA_ADVISORY_LOCK_MAX_RETRY_DELAY_MS);
303
+ }
304
+ }
305
+
180
306
  export async function getJiraInstallationByConnectionId(
181
307
  connectionId: string,
182
308
  options: {tx?: unknown} = {},