Explorar o código

Tolerate a racing adoption marker and wait for the WebSocket route module before the first upgrade (#344)

Two page renders adopting an old AI or payment setup at the same time both
wrote the adopted marker; Prisma's upsert on a compound key is a select then
an insert, so the loser failed with a unique violation. The three markers now
share one writer that treats that violation as the row already being there.

next-ws reads a route module's exports the moment an upgrade arrives, and Next
16.3 loads route modules lazily, so the first WebSocket connection after every
boot died with "The lazy module is still loading". A script run after
next-ws patch, locally and in the runtime image, makes it await
ensureUserland() first and fails the build if the code it expects is gone.
Bernt Christian Egeland hai 3 semanas
pai
achega
0c14b1245c

+ 7 - 0
Dockerfile

@@ -93,6 +93,13 @@ RUN rm -rf \
 # resolve the binary against the platform the image will actually run on.
 RUN npx next-ws patch --yes
 
+# next-ws reads a route module's exports the moment an upgrade arrives, and
+# Next 16.3 loads route modules lazily, so the first WebSocket connection after
+# every boot died with "The lazy module is still loading" until the fix below
+# is applied to the fresh copy the install above pulled in.
+COPY --from=builder --chown=nextjs:nodejs /app/scripts/patch-next-ws-first-upgrade.mjs ./scripts/patch-next-ws-first-upgrade.mjs
+RUN node scripts/patch-next-ws-first-upgrade.mjs
+
 # Fail the build here rather than at the first certificate download: a native
 # module that cannot be loaded throws while the route module is being
 # evaluated, which reaches the browser as an empty HTTP 500 with nothing in it

+ 1 - 1
package.json

@@ -12,7 +12,7 @@
     "format": "biome format . --write",
     "check": "biome check .",
     "check:fix": "biome check . --write",
-    "prepare": "next-ws patch --yes",
+    "prepare": "next-ws patch --yes && node scripts/patch-next-ws-first-upgrade.mjs",
     "test": "vitest run",
     "test:coverage": "vitest --coverage",
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"

+ 32 - 0
scripts/patch-next-ws-first-upgrade.mjs

@@ -0,0 +1,32 @@
+/**
+ * Make next-ws wait for a route module before reading its exports.
+ *
+ * Next 16.3 loads route modules lazily: `routeModule.userland` throws
+ * "The lazy module is still loading" until `ensureUserland()` has resolved.
+ * next-ws 2.2.14 reads `userland` the moment an upgrade request arrives, so
+ * the first WebSocket connection after every boot died as an unhandled
+ * rejection and the browser had to reconnect. This runs after `next-ws patch`,
+ * both locally and in the runtime image, and fails loudly when the code it
+ * expects is not there, so a next-ws bump cannot skip it quietly.
+ */
+import { readFileSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+const require = createRequire(`${process.cwd()}/`)
+const file = require.resolve('next-ws/server')
+const source = readFileSync(file, 'utf8')
+
+const anchor = '    const handleUpgrade = module2.userland.UPGRADE;\n'
+const fix = '    if (typeof module2.ensureUserland === "function") await module2.ensureUserland();\n'
+
+if (source.includes(fix)) {
+  console.log(`[next-ws] first-upgrade fix already applied to ${file}`)
+} else if (source.includes(anchor)) {
+  writeFileSync(file, source.replace(anchor, fix + anchor))
+  console.log(`[next-ws] first-upgrade fix applied to ${file}`)
+} else {
+  console.error(
+    `[next-ws] could not apply the first-upgrade fix: ${file} no longer contains the expected upgrade handler. Check whether the installed next-ws already awaits ensureUserland() and update scripts/patch-next-ws-first-upgrade.mjs.`
+  )
+  process.exit(1)
+}

+ 42 - 0
src/__tests__/features/integrations/messaging.test.ts

@@ -377,6 +377,48 @@ describe('one vendor per channel', () => {
     expect(setup?.credentials.accountSid).toBe('AC123')
   })
 
+  it('treats a marker the other adopter wrote first as already written', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-1',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+    // Prisma's upsert is a select then an insert, so the loser of a race
+    // between two page renders sees the winner's marker as a unique violation.
+    appSetting.upsert.mockRejectedValueOnce({ code: 'P2002' })
+
+    const setup = await channelSetup('org-15', 'sms')
+    expect(setup?.connectionId).toBe('conn-1')
+    expect(appSetting.upsert).toHaveBeenCalledTimes(1)
+  })
+
+  it('still surfaces a marker write that fails for another reason', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-1',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+    appSetting.upsert.mockRejectedValueOnce(new Error('connection reset'))
+
+    await expect(channelSetup('org-16', 'sms')).rejects.toThrow('connection reset')
+  })
+
   it('keeps sending from the old rows when a connection cannot be unsealed', async () => {
     appSetting.findMany.mockResolvedValue(
       legacyRows({

+ 30 - 0
src/features/integrations/Lib/adoption-marker.ts

@@ -0,0 +1,30 @@
+import { db } from '@/lib/db'
+
+/** Prisma's code for a unique constraint the row already satisfies. */
+export function isUniqueViolation(err: unknown): boolean {
+  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
+}
+
+/**
+ * Write the setting that records an old setup as adopted, once.
+ *
+ * Prisma's upsert on a compound key is a select followed by an insert, so two
+ * adoptions landing at once, such as a prefetch and the navigation it was
+ * for, can both find no row and both insert. The loser's row is the same row
+ * the winner wrote, so its unique violation is the marker being there already.
+ */
+export async function writeAdoptionMarker(
+  organizationId: string,
+  key: string,
+  userId: string
+): Promise<void> {
+  try {
+    await db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key } },
+      create: { organizationId, userId, key, value: new Date().toISOString() },
+      update: {},
+    })
+  } catch (err) {
+    if (!isUniqueViolation(err)) throw err
+  }
+}

+ 2 - 10
src/features/integrations/Lib/ai.ts

@@ -13,6 +13,7 @@
  */
 
 import { db } from '@/lib/db'
+import { isUniqueViolation, writeAdoptionMarker } from './adoption-marker'
 import { AI_KEYS } from '@/features/ai/Schema/aiSettingsSchema'
 import { openCredentials, sealCredentials } from './vault'
 
@@ -68,11 +69,7 @@ export async function retireOtherAiProviders(
 
 /** Record that the connections table decides AI from now on. */
 export async function markAiAdopted(organizationId: string, userId: string): Promise<void> {
-  await db.appSetting.upsert({
-    where: { organizationId_key: { organizationId, key: AI_ADOPTED_KEY } },
-    create: { organizationId, userId, key: AI_ADOPTED_KEY, value: new Date().toISOString() },
-    update: {},
-  })
+  await writeAdoptionMarker(organizationId, AI_ADOPTED_KEY, userId)
 }
 
 export interface LegacyAiSetup {
@@ -115,11 +112,6 @@ export async function legacyAiSetup(
   }
 }
 
-/** Prisma's code for a unique constraint the row already satisfies. */
-function isUniqueViolation(err: unknown): boolean {
-  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
-}
-
 /**
  * Turn an old setup into a live connection. Runs once per organization: the
  * marker is written whether or not the row survives, and a vendor row the

+ 2 - 11
src/features/integrations/Lib/messaging.ts

@@ -23,6 +23,7 @@ import {
   providersForChannel,
 } from '@/integrations/messaging/catalog'
 import { db } from '@/lib/db'
+import { isUniqueViolation, writeAdoptionMarker } from './adoption-marker'
 import type { PaymentWebhook } from './payments'
 import { openCredentials, sealCredentials } from './vault'
 
@@ -163,12 +164,7 @@ export async function markChannelAdopted(
   channel: MessagingChannel,
   userId: string
 ): Promise<void> {
-  const key = adoptedMarkerKey(channel)
-  await db.appSetting.upsert({
-    where: { organizationId_key: { organizationId, key } },
-    create: { organizationId, userId, key, value: new Date().toISOString() },
-    update: {},
-  })
+  await writeAdoptionMarker(organizationId, adoptedMarkerKey(channel), userId)
 }
 
 function splitLegacy(
@@ -293,11 +289,6 @@ export async function legacyProviderNamed(
   return named && providerForLegacyId(channel, named) ? named : null
 }
 
-/** Prisma's code for a unique constraint the row already satisfies. */
-function isUniqueViolation(err: unknown): boolean {
-  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
-}
-
 async function adoptLegacySetup(
   organizationId: string,
   channel: MessagingChannel

+ 2 - 10
src/features/integrations/Lib/payments.ts

@@ -28,6 +28,7 @@ import {
 } from '@/integrations/payments/catalog'
 import { db } from '@/lib/db'
 import { type PaymentProvider, buildPaymentProvider } from '@/lib/payment-providers'
+import { isUniqueViolation, writeAdoptionMarker } from './adoption-marker'
 import { openCredentials, sealCredentials } from './vault'
 
 export { PAYMENT_CONNECTOR_IDS, isPaymentConnector }
@@ -78,11 +79,7 @@ function inVendorOrder<T extends { connectorId: string }>(setups: T[]): T[] {
 
 /** Record that the connections table decides online payments from now on. */
 export async function markPaymentsAdopted(organizationId: string, userId: string): Promise<void> {
-  await db.appSetting.upsert({
-    where: { organizationId_key: { organizationId, key: PAYMENTS_ADOPTED_KEY } },
-    create: { organizationId, userId, key: PAYMENTS_ADOPTED_KEY, value: new Date().toISOString() },
-    update: {},
-  })
+  await writeAdoptionMarker(organizationId, PAYMENTS_ADOPTED_KEY, userId)
 }
 
 export interface LegacyPaymentSetup {
@@ -145,11 +142,6 @@ export async function legacyPaymentSetups(
   return { setups, adopted }
 }
 
-/** Prisma's code for a unique constraint the row already satisfies. */
-function isUniqueViolation(err: unknown): boolean {
-  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
-}
-
 /** What the connection page shows as the account, for a setup that never went through identify. */
 function accountNameOf(setup: LegacyPaymentSetup): string | null {
   const msn = setup.credentials.merchantSerialNumber