@shipfox/api-integration-core 12.1.1 → 12.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-core",
3
3
  "license": "MIT",
4
- "version": "12.1.1",
4
+ "version": "12.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -23,29 +23,29 @@
23
23
  "ajv": "^8.20.0",
24
24
  "drizzle-orm": "^0.45.2",
25
25
  "zod": "^4.4.3",
26
- "@shipfox/api-agent-dto": "12.0.0",
27
- "@shipfox/api-auth-context": "12.0.0",
28
- "@shipfox/api-workflows-dto": "12.1.0",
29
- "@shipfox/api-integration-core-dto": "12.0.0",
30
- "@shipfox/api-integration-spi": "1.0.0",
26
+ "@shipfox/api-agent-dto": "12.2.0",
27
+ "@shipfox/api-auth-context": "12.2.0",
28
+ "@shipfox/api-workflows-dto": "12.2.0",
29
+ "@shipfox/api-integration-core-dto": "12.2.0",
30
+ "@shipfox/api-integration-spi": "1.0.1",
31
31
  "@shipfox/api-workspaces-dto": "12.0.0",
32
- "@shipfox/api-integration-gitea": "12.0.0",
33
- "@shipfox/api-integration-github": "12.0.0",
34
- "@shipfox/api-integration-jira": "12.1.1",
35
- "@shipfox/api-integration-linear": "12.0.0",
36
- "@shipfox/api-integration-sentry": "12.0.0",
37
- "@shipfox/api-integration-slack": "12.0.0",
38
- "@shipfox/api-integration-webhook": "12.0.0",
32
+ "@shipfox/api-integration-gitea": "12.2.0",
33
+ "@shipfox/api-integration-github": "12.2.0",
34
+ "@shipfox/api-integration-jira": "12.2.0",
35
+ "@shipfox/api-integration-linear": "12.2.0",
36
+ "@shipfox/api-integration-sentry": "12.2.0",
37
+ "@shipfox/api-integration-slack": "12.2.0",
38
+ "@shipfox/api-integration-webhook": "12.2.0",
39
39
  "@shipfox/config": "1.2.4",
40
40
  "@shipfox/inter-module": "0.2.3",
41
41
  "@shipfox/node-drizzle": "0.3.5",
42
- "@shipfox/node-fastify": "0.4.1",
43
- "@shipfox/node-module": "1.0.5",
44
- "@shipfox/node-opentelemetry": "0.6.3",
42
+ "@shipfox/node-fastify": "0.4.2",
43
+ "@shipfox/node-module": "1.0.6",
44
+ "@shipfox/node-opentelemetry": "0.6.4",
45
45
  "@shipfox/node-error-monitoring": "0.3.0",
46
46
  "@shipfox/node-outbox": "0.2.6",
47
47
  "@shipfox/node-postgres": "0.5.0",
48
- "@shipfox/node-temporal": "0.4.4",
48
+ "@shipfox/node-temporal": "0.4.5",
49
49
  "@shipfox/regex": "0.2.4",
50
50
  "@shipfox/redact": "0.2.6"
51
51
  },
@@ -1,5 +1,7 @@
1
+ import {INTEGRATION_CONNECTION_AVAILABLE} from '@shipfox/api-integration-core-dto';
1
2
  import {upsertGithubInstallation} from '@shipfox/api-integration-github';
2
3
  import {ConnectionSlugConflictError} from '@shipfox/api-integration-spi';
4
+ import {sql} from 'drizzle-orm';
3
5
  import {IntegrationConnectionAlreadyExistsError} from '#core/errors.js';
4
6
  import {
5
7
  createIntegrationConnection,
@@ -13,6 +15,16 @@ import {
13
15
  upsertIntegrationConnection,
14
16
  } from './connections.js';
15
17
  import {db} from './db.js';
18
+ import {integrationsOutbox} from './schema/outbox.js';
19
+
20
+ function connectionEvents(connectionId: string) {
21
+ return db()
22
+ .select()
23
+ .from(integrationsOutbox)
24
+ .where(
25
+ sql`${integrationsOutbox.eventType} = ${INTEGRATION_CONNECTION_AVAILABLE} AND ${integrationsOutbox.payload}->>'connectionId' = ${connectionId}`,
26
+ );
27
+ }
16
28
 
17
29
  describe('integration connection queries', () => {
18
30
  let workspaceId: string;
@@ -41,6 +53,31 @@ describe('integration connection queries', () => {
41
53
  expect(second.id).toBe(first.id);
42
54
  expect(second.displayName).toBe('Renamed Debug');
43
55
  expect(second.slug).toBe('gitea_owner');
56
+ expect(await connectionEvents(first.id)).toHaveLength(1);
57
+ });
58
+
59
+ it('publishes availability when an upsert activates an existing connection', async () => {
60
+ const connection = await upsertIntegrationConnection({
61
+ workspaceId,
62
+ provider: 'linear',
63
+ externalAccountId: 'linear-acme',
64
+ slug: 'linear_acme',
65
+ displayName: 'Linear Acme',
66
+ lifecycleStatus: 'disabled',
67
+ });
68
+
69
+ expect(await connectionEvents(connection.id)).toHaveLength(0);
70
+
71
+ await upsertIntegrationConnection({
72
+ workspaceId,
73
+ provider: 'linear',
74
+ externalAccountId: 'linear-acme',
75
+ slug: 'linear_acme',
76
+ displayName: 'Linear Acme',
77
+ lifecycleStatus: 'active',
78
+ });
79
+
80
+ expect(await connectionEvents(connection.id)).toHaveLength(1);
44
81
  });
45
82
 
46
83
  it('allows multiple same-provider connections when external account differs', async () => {
@@ -148,6 +185,7 @@ describe('integration connection queries', () => {
148
185
  expect(connections).toHaveLength(1);
149
186
  expect(connections[0]?.id).toBe(first.id);
150
187
  expect(connections[0]?.displayName).toBe('Stripe');
188
+ expect(await connectionEvents(first.id)).toHaveLength(1);
151
189
  });
152
190
 
153
191
  it('reports slug collisions separately from duplicate external accounts', async () => {
@@ -252,6 +290,51 @@ describe('integration connection queries', () => {
252
290
  expect(updated?.lifecycleStatus).toBe('disabled');
253
291
  const reloaded = await getIntegrationConnectionById(connection.id);
254
292
  expect(reloaded?.lifecycleStatus).toBe('disabled');
293
+ expect(await connectionEvents(connection.id)).toHaveLength(1);
294
+ });
295
+
296
+ it('publishes availability when a disabled connection becomes active', async () => {
297
+ const connection = await upsertIntegrationConnection({
298
+ workspaceId,
299
+ provider: 'linear',
300
+ externalAccountId: 'linear-acme',
301
+ slug: 'linear_acme',
302
+ displayName: 'Linear Acme',
303
+ lifecycleStatus: 'disabled',
304
+ });
305
+
306
+ expect(await connectionEvents(connection.id)).toHaveLength(0);
307
+
308
+ await updateIntegrationConnectionLifecycleStatus({
309
+ id: connection.id,
310
+ lifecycleStatus: 'active',
311
+ });
312
+
313
+ const events = await connectionEvents(connection.id);
314
+ expect(events).toHaveLength(1);
315
+ expect(events[0]?.payload).toEqual({
316
+ provider: 'linear',
317
+ workspaceId,
318
+ connectionId: connection.id,
319
+ slug: 'linear_acme',
320
+ });
321
+ });
322
+
323
+ it('does not republish availability when an active connection stays active', async () => {
324
+ const connection = await upsertIntegrationConnection({
325
+ workspaceId,
326
+ provider: 'linear',
327
+ externalAccountId: 'linear-acme',
328
+ slug: 'linear_acme',
329
+ displayName: 'Linear Acme',
330
+ });
331
+
332
+ await updateIntegrationConnectionLifecycleStatus({
333
+ id: connection.id,
334
+ lifecycleStatus: 'active',
335
+ });
336
+
337
+ expect(await connectionEvents(connection.id)).toHaveLength(1);
255
338
  });
256
339
 
257
340
  it('returns undefined when updating the lifecycle status of an unknown connection', async () => {
@@ -310,5 +393,12 @@ describe('integration connection queries', () => {
310
393
 
311
394
  const connections = await listIntegrationConnections({workspaceId});
312
395
  expect(connections).toHaveLength(0);
396
+ const events = await db()
397
+ .select()
398
+ .from(integrationsOutbox)
399
+ .where(
400
+ sql`${integrationsOutbox.eventType} = ${INTEGRATION_CONNECTION_AVAILABLE} AND ${integrationsOutbox.payload}->>'workspaceId' = ${workspaceId}`,
401
+ );
402
+ expect(events).toHaveLength(0);
313
403
  });
314
404
  });
@@ -1,5 +1,10 @@
1
- import {CONNECTION_SLUG_MAX_LENGTH} from '@shipfox/api-integration-core-dto';
1
+ import {
2
+ CONNECTION_SLUG_MAX_LENGTH,
3
+ INTEGRATION_CONNECTION_AVAILABLE,
4
+ type IntegrationsEventMap,
5
+ } from '@shipfox/api-integration-core-dto';
2
6
  import {ConnectionSlugConflictError} from '@shipfox/api-integration-spi';
7
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
3
8
  import {and, eq} from 'drizzle-orm';
4
9
  import type {
5
10
  IntegrationConnection,
@@ -9,6 +14,7 @@ import type {IntegrationProviderKind} from '#core/entities/provider.js';
9
14
  import {IntegrationConnectionAlreadyExistsError} from '#core/errors.js';
10
15
  import {db} from './db.js';
11
16
  import {integrationConnections, toIntegrationConnection} from './schema/connections.js';
17
+ import {integrationsOutbox} from './schema/outbox.js';
12
18
 
13
19
  type IntegrationDb = ReturnType<typeof db>;
14
20
  type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
@@ -26,9 +32,13 @@ export async function upsertIntegrationConnection(
26
32
  params: UpsertIntegrationConnectionParams,
27
33
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
28
34
  ): Promise<IntegrationConnection> {
29
- const executor = options.tx ?? db();
35
+ if (options.tx === undefined) {
36
+ return await db().transaction((tx) => upsertIntegrationConnection(params, {tx}));
37
+ }
38
+
39
+ const executor = options.tx;
30
40
  const now = new Date();
31
- const [row] = await executor
41
+ let [row] = await executor
32
42
  .insert(integrationConnections)
33
43
  .values({
34
44
  workspaceId: params.workspaceId,
@@ -38,22 +48,52 @@ export async function upsertIntegrationConnection(
38
48
  displayName: params.displayName,
39
49
  lifecycleStatus: params.lifecycleStatus ?? 'active',
40
50
  })
41
- .onConflictDoUpdate({
51
+ .onConflictDoNothing({
42
52
  target: [
43
53
  integrationConnections.workspaceId,
44
54
  integrationConnections.provider,
45
55
  integrationConnections.externalAccountId,
46
56
  ],
47
- set: {
57
+ })
58
+ .returning();
59
+
60
+ let becameAvailable = row?.lifecycleStatus === 'active';
61
+ if (!row) {
62
+ const [existing] = await executor
63
+ .select({
64
+ id: integrationConnections.id,
65
+ lifecycleStatus: integrationConnections.lifecycleStatus,
66
+ })
67
+ .from(integrationConnections)
68
+ .where(
69
+ and(
70
+ eq(integrationConnections.workspaceId, params.workspaceId),
71
+ eq(integrationConnections.provider, params.provider),
72
+ eq(integrationConnections.externalAccountId, params.externalAccountId),
73
+ ),
74
+ )
75
+ .limit(1)
76
+ .for('update');
77
+ if (!existing) throw new Error('Integration connection upsert conflict row was not found');
78
+
79
+ [row] = await executor
80
+ .update(integrationConnections)
81
+ .set({
48
82
  displayName: params.displayName,
49
83
  lifecycleStatus: params.lifecycleStatus ?? 'active',
50
84
  updatedAt: now,
51
- },
52
- })
53
- .returning();
85
+ })
86
+ .where(eq(integrationConnections.id, existing.id))
87
+ .returning();
88
+ becameAvailable = existing.lifecycleStatus !== 'active' && row?.lifecycleStatus === 'active';
89
+ }
54
90
 
55
91
  if (!row) throw new Error('Integration connection upsert returned no rows');
56
- return toIntegrationConnection(row);
92
+ const connection = toIntegrationConnection(row);
93
+ if (becameAvailable) {
94
+ await writeConnectionAvailableEvent(executor, connection);
95
+ }
96
+ return connection;
57
97
  }
58
98
 
59
99
  export interface CreateIntegrationConnectionParams {
@@ -100,7 +140,11 @@ export async function createIntegrationConnection(
100
140
  params: CreateIntegrationConnectionParams,
101
141
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
102
142
  ): Promise<IntegrationConnection> {
103
- const executor = options.tx ?? db();
143
+ if (options.tx === undefined) {
144
+ return await db().transaction((tx) => createIntegrationConnection(params, {tx}));
145
+ }
146
+
147
+ const executor = options.tx;
104
148
  let rows: (typeof integrationConnections.$inferSelect)[];
105
149
  try {
106
150
  rows = await executor
@@ -130,7 +174,11 @@ export async function createIntegrationConnection(
130
174
 
131
175
  const row = rows[0];
132
176
  if (!row) throw new Error('Integration connection insert returned no rows');
133
- return toIntegrationConnection(row);
177
+ const connection = toIntegrationConnection(row);
178
+ if (connection.lifecycleStatus === 'active') {
179
+ await writeConnectionAvailableEvent(executor, connection);
180
+ }
181
+ return connection;
134
182
  }
135
183
 
136
184
  export interface ResolveUniqueConnectionSlugParams {
@@ -225,19 +273,50 @@ export async function updateIntegrationConnectionLifecycleStatus(
225
273
  params: UpdateIntegrationConnectionLifecycleStatusParams,
226
274
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
227
275
  ): Promise<IntegrationConnection | undefined> {
228
- const executor = options.tx ?? db();
276
+ if (options.tx === undefined) {
277
+ return await db().transaction((tx) => updateIntegrationConnectionLifecycleStatus(params, {tx}));
278
+ }
279
+
280
+ const executor = options.tx;
281
+ const [existing] = await executor
282
+ .select({lifecycleStatus: integrationConnections.lifecycleStatus})
283
+ .from(integrationConnections)
284
+ .where(eq(integrationConnections.id, params.id))
285
+ .limit(1)
286
+ .for('update');
287
+ if (!existing) return undefined;
288
+
229
289
  const [row] = await executor
230
290
  .update(integrationConnections)
231
291
  .set({lifecycleStatus: params.lifecycleStatus, updatedAt: new Date()})
232
292
  .where(eq(integrationConnections.id, params.id))
233
293
  .returning();
234
294
  if (!row) return undefined;
235
- return toIntegrationConnection(row);
295
+ const connection = toIntegrationConnection(row);
296
+ if (existing.lifecycleStatus !== 'active' && connection.lifecycleStatus === 'active') {
297
+ await writeConnectionAvailableEvent(executor, connection);
298
+ }
299
+ return connection;
236
300
  }
237
301
 
238
302
  export type UpdateIntegrationConnectionLifecycleStatusFn =
239
303
  typeof updateIntegrationConnectionLifecycleStatus;
240
304
 
305
+ async function writeConnectionAvailableEvent(
306
+ executor: IntegrationDb | IntegrationTx,
307
+ connection: IntegrationConnection,
308
+ ): Promise<void> {
309
+ await writeOutboxEvent<IntegrationsEventMap>(executor, integrationsOutbox, {
310
+ type: INTEGRATION_CONNECTION_AVAILABLE,
311
+ payload: {
312
+ provider: connection.provider,
313
+ workspaceId: connection.workspaceId,
314
+ connectionId: connection.id,
315
+ slug: connection.slug,
316
+ },
317
+ });
318
+ }
319
+
241
320
  export async function deleteIntegrationConnection(
242
321
  params: {id: string},
243
322
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
@@ -1,16 +1,65 @@
1
1
  import {
2
2
  getJiraInstallationByConnectionId,
3
3
  upsertJiraInstallation,
4
+ withJiraRefreshLock,
4
5
  } from '@shipfox/api-integration-jira';
5
6
  import {runMigrations} from '@shipfox/node-drizzle';
6
7
  import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
8
+ import {db} from '#db/db.js';
9
+ import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
7
10
 
8
11
  describe('jiraProviderModule', () => {
12
+ const context = useIntegrationRouteTest();
13
+
9
14
  afterEach(() => {
10
15
  vi.unstubAllEnvs();
11
16
  vi.resetModules();
12
17
  });
13
18
 
19
+ async function createJiraCleanupFixture() {
20
+ vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
21
+ vi.resetModules();
22
+ const deleteSecrets = vi.fn(() => Promise.resolve(2));
23
+ const scopedSecrets = {
24
+ getSecret: vi.fn(() => Promise.resolve(null)),
25
+ setSecrets: vi.fn(() => Promise.resolve()),
26
+ deleteSecrets,
27
+ };
28
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
29
+ createPostgresClient();
30
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
31
+ const parts = await loadEnabledProviderModules({
32
+ secrets: {jira: scopedSecrets, deleteSecrets},
33
+ });
34
+ const jiraPart = parts.find((part) => part.provider.provider === 'jira');
35
+ if (!jiraPart?.database) throw new Error('Jira provider database is not configured');
36
+ const cloudId = crypto.randomUUID();
37
+
38
+ await runMigrations(
39
+ jiraPart.database.db(),
40
+ jiraPart.database.migrationsPath,
41
+ `__drizzle_migrations_${jiraPart.database.databaseNamespace}`,
42
+ );
43
+ const connection = await upsertIntegrationConnection({
44
+ workspaceId: context.workspaceId,
45
+ provider: 'jira',
46
+ externalAccountId: cloudId,
47
+ slug: 'jira_acme',
48
+ displayName: 'Jira Acme',
49
+ });
50
+ await upsertJiraInstallation({
51
+ connectionId: connection.id,
52
+ cloudId,
53
+ siteUrl: 'https://acme.atlassian.net',
54
+ siteName: 'Acme',
55
+ authorizingAccountId: 'user-1',
56
+ scopes: ['read:jira-work'],
57
+ status: 'installed',
58
+ });
59
+
60
+ return {connection, deleteSecrets, jiraPart, cloudId};
61
+ }
62
+
14
63
  it('loads its database descriptor and persists a core connection with its Jira installation', async () => {
15
64
  vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
16
65
  vi.resetModules();
@@ -59,4 +108,88 @@ describe('jiraProviderModule', () => {
59
108
  cloudId,
60
109
  });
61
110
  });
111
+
112
+ it('waits for refresh before deleting tokens and allowing a reinstall', async () => {
113
+ const {cloudId, connection, deleteSecrets, jiraPart} = await createJiraCleanupFixture();
114
+ const app = await createTestApp([jiraPart.provider]);
115
+
116
+ let releaseRefreshLock!: () => void;
117
+ let refreshLockEntered!: () => void;
118
+ const refreshLockReady = new Promise<void>((resolve) => {
119
+ refreshLockEntered = resolve;
120
+ });
121
+ const refreshLockReleased = new Promise<void>((resolve) => {
122
+ releaseRefreshLock = resolve;
123
+ });
124
+ const refreshLock = withJiraRefreshLock(connection.id, async () => {
125
+ refreshLockEntered();
126
+ await refreshLockReleased;
127
+ });
128
+ await refreshLockReady;
129
+
130
+ const deletion = app.inject({
131
+ method: 'DELETE',
132
+ url: `/integration-connections/${connection.id}`,
133
+ headers: {authorization: 'Bearer user'},
134
+ });
135
+
136
+ await vi.waitFor(async () => {
137
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
138
+ });
139
+ let res!: Awaited<ReturnType<typeof app.inject>>;
140
+ try {
141
+ expect(deleteSecrets).not.toHaveBeenCalled();
142
+ } finally {
143
+ releaseRefreshLock();
144
+ await expect(refreshLock).resolves.toMatchObject({acquired: true});
145
+ res = await deletion;
146
+ }
147
+
148
+ expect(res.statusCode).toBe(204);
149
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
150
+ await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
151
+ expect(deleteSecrets).toHaveBeenCalledWith({
152
+ workspaceId: context.workspaceId,
153
+ namespace: connection.id,
154
+ });
155
+
156
+ const replacement = await upsertIntegrationConnection({
157
+ workspaceId: context.workspaceId,
158
+ provider: 'jira',
159
+ externalAccountId: cloudId,
160
+ slug: 'jira_acme_again',
161
+ displayName: 'Jira Acme',
162
+ });
163
+ await upsertJiraInstallation({
164
+ connectionId: replacement.id,
165
+ cloudId,
166
+ siteUrl: 'https://acme.atlassian.net',
167
+ siteName: 'Acme',
168
+ authorizingAccountId: 'user-1',
169
+ scopes: ['read:jira-work'],
170
+ status: 'installed',
171
+ });
172
+
173
+ await expect(getJiraInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
174
+ cloudId,
175
+ });
176
+ });
177
+
178
+ it('rolls back provider record cleanup when its transaction fails', async () => {
179
+ const {connection, jiraPart} = await createJiraCleanupFixture();
180
+
181
+ await expect(
182
+ db().transaction(async (tx) => {
183
+ await jiraPart.provider.deleteConnectionRecords?.(connection, {tx});
184
+ throw new Error('transaction failed');
185
+ }),
186
+ ).rejects.toThrow('transaction failed');
187
+
188
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
189
+ id: connection.id,
190
+ });
191
+ await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toMatchObject({
192
+ connectionId: connection.id,
193
+ });
194
+ });
62
195
  });
@@ -30,10 +30,13 @@ async function loadJiraModuleParts(
30
30
  createJiraPendingSelectionStore,
31
31
  createJiraTokenStore,
32
32
  db: jiraDb,
33
+ deleteJiraInstallationByConnectionId,
33
34
  disconnectJiraInstallation: disconnectJiraInstallationRecords,
34
35
  getJiraInstallationByCloudId,
36
+ jiraSecretsNamespace,
35
37
  migrationsPath,
36
38
  upsertJiraInstallation,
39
+ withJiraRefreshLockAndWait,
37
40
  } = await import('@shipfox/api-integration-jira');
38
41
 
39
42
  async function getExistingJiraConnection(input: {
@@ -144,6 +147,20 @@ async function loadJiraModuleParts(
144
147
 
145
148
  const integrationProvider = createJiraIntegrationProvider({
146
149
  agentTools: {tokenStore},
150
+ cleanup: {
151
+ deleteConnectionRecords: async (connection, {tx}) => {
152
+ await deleteJiraInstallationByConnectionId(connection.id, {tx});
153
+ },
154
+ deleteConnectionSecrets: async (connection) => {
155
+ await withJiraRefreshLockAndWait(connection.id, async () => {
156
+ // Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
157
+ await (options.secrets?.jira?.deleteSecrets({
158
+ workspaceId: connection.workspaceId,
159
+ namespace: jiraNamespaceSuffix(jiraSecretsNamespace(connection.id)),
160
+ }) ?? Promise.resolve());
161
+ });
162
+ },
163
+ },
147
164
  routes: {
148
165
  tokenStore,
149
166
  pendingStore,