Repository: specklesystems/speckle-server
Pull request #4333: Gergo/web 2930 workspace migration script
Author: login: gjedlicska
Created: 2025-04-06T13:29:17Z | Merged: 2025-04-08T14:00:39Z

add migration logic from old workspace plans to new plans.

Do not merge this before https://github.com/specklesystems/speckle-server/pull/4323


Diff:
diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts
index eaea922553c..37979f66b16 100644
--- a/packages/server/modules/gatekeeper/index.ts
+++ b/packages/server/modules/gatekeeper/index.ts
@@ -53,6 +53,7 @@ import {
   manageSubscriptionDownscaleFactoryOld
 } from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale'
 import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
+import { migrateOldWorkspacePlans } from '@/modules/gatekeeper/services/planMigration'
 
 const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
   getFeatureFlags()
@@ -110,6 +111,21 @@ const scheduleWorkspaceSubscriptionDownscale = ({
   )
 }
 
+const scheduleWorkspacePlanMigrations = (scheduleExecution: ScheduleExecution) => {
+  let isMigrationComplete = false
+  const cronExpression = '*/5 * * * * *' // every 5 seconds
+  return scheduleExecution(
+    cronExpression,
+    'WorkspaceNewPlanMigration',
+    async (_scheduledTime, { logger }) => {
+      if (isMigrationComplete) return
+      await migrateOldWorkspacePlans({ db, stripe: getStripeClient(), logger })()
+
+      isMigrationComplete = true
+    }
+  )
+}
+
 const scheduleWorkspaceTrialEmails = ({
   scheduleExecution
 }: {
@@ -231,7 +247,8 @@ const gatekeeperModule: SpeckleModule = {
         scheduledTasks = [
           scheduleWorkspaceSubscriptionDownscale({ scheduleExecution }),
           scheduleWorkspaceTrialEmails({ scheduleExecution }),
-          scheduleWorkspaceTrialExpiry({ scheduleExecution, emit: eventBus.emit })
+          scheduleWorkspaceTrialExpiry({ scheduleExecution, emit: eventBus.emit }),
+          scheduleWorkspacePlanMigrations(scheduleExecution)
         ]
 
         quitListeners = initializeEventListenersFactory({
diff --git a/packages/server/modules/gatekeeper/services/planMigration.ts b/packages/server/modules/gatekeeper/services/planMigration.ts
new file mode 100644
index 00000000000..101ffcb9151
--- /dev/null
+++ b/packages/server/modules/gatekeeper/services/planMigration.ts
@@ -0,0 +1,249 @@
+import {
+  getWorkspacePlanFactory,
+  getWorkspaceSubscriptionFactory,
+  upsertWorkspacePlanFactory
+} from '@/modules/gatekeeper/repositories/billing'
+import {
+  throwUncoveredError,
+  WorkspacePlan,
+  WorkspacePlans,
+  WorkspacePlanStatuses
+} from '@speckle/shared'
+import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces'
+import {
+  SubscriptionDataInput,
+  WorkspaceSeat
+} from '@/modules/gatekeeper/domain/billing'
+import { Knex } from 'knex'
+import {
+  getWorkspacePlanPriceId,
+  getWorkspacePlanProductId
+} from '@/modules/gatekeeper/stripe'
+import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
+import Stripe from 'stripe'
+import { cloneDeep } from 'lodash'
+import { Logger } from '@/observability/logging'
+
+// get all workspace plan from the DB
+// foreach workspace:
+
+export const migrateOldWorkspacePlans =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async () => {
+    const oldPlanWorkspaces = await db<WorkspacePlan & { workspaceId: string }>(
+      'workspace_plans'
+    )
+      .select('*')
+      .whereIn('name', [
+        'business',
+        'businessInvoiced',
+        'plus',
+        'plusInvoiced',
+        'starter',
+        'starterInvoiced',
+        'academia',
+        'unlimited'
+      ])
+
+    for (const oldPlan of oldPlanWorkspaces) {
+      try {
+        await migrateWorkspacePlan({ db, stripe, logger })({
+          workspaceId: oldPlan.workspaceId
+        })
+      } catch (err) {
+        logger.error(
+          { err, workspaceId: oldPlan.workspaceId, oldPlan },
+          'Failed to migrate workspace plan'
+        )
+      }
+    }
+  }
+
+export const migrateWorkspacePlan =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async ({ workspaceId }: { workspaceId: string }) => {
+    let log = logger.child({ workspaceId })
+    log.info('Starting workspace plan migration')
+    const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
+    if (!workspacePlan)
+      throw new Error(`Workspace ${workspaceId} has no workspace plan`)
+
+    log = log.child({ workspacePlan })
+
+    let newTargetPlan: WorkspacePlans | null = null
+    let newPlanStatus: WorkspacePlanStatuses | null = null
+    let isStripeMigrationNeeded = false
+    switch (workspacePlan.name) {
+      case 'team':
+      case 'teamUnlimited':
+      case 'pro':
+      case 'proUnlimited':
+      case 'teamUnlimitedInvoiced':
+      case 'proUnlimitedInvoiced':
+        // these are new plans already, no upgrades
+        break
+      case 'starter':
+        switch (workspacePlan.status) {
+          case 'trial':
+          case 'expired':
+            newPlanStatus = 'valid'
+            newTargetPlan = 'free'
+            break
+          case 'paymentFailed':
+            throw new Error('Cant migrate workspace, its currently failed in payment')
+          case 'canceled':
+            // just switch the plan, no need to change stripe
+            newTargetPlan = 'teamUnlimited'
+            newPlanStatus = workspacePlan.status
+            break
+          case 'cancelationScheduled':
+          case 'valid':
+            newTargetPlan = 'teamUnlimited'
+            newPlanStatus = workspacePlan.status
+            isStripeMigrationNeeded = true
+            break
+          default:
+            throwUncoveredError(workspacePlan)
+        }
+        break
+      case 'plus':
+      case 'business':
+        switch (workspacePlan.status) {
+          case 'paymentFailed':
+            throw new Error('Cant migrate workspace, its currently failed in payment')
+          case 'canceled':
+            newTargetPlan = 'proUnlimited'
+            isStripeMigrationNeeded = false
+            newPlanStatus = workspacePlan.status
+            break
+          case 'cancelationScheduled':
+          case 'valid':
+            newTargetPlan = 'proUnlimited'
+            isStripeMigrationNeeded = true
+            newPlanStatus = workspacePlan.status
+            break
+          default:
+            throwUncoveredError(workspacePlan)
+        }
+        break
+      case 'starterInvoiced':
+        newTargetPlan = 'teamUnlimitedInvoiced'
+        newPlanStatus = workspacePlan.status
+        break
+      case 'plusInvoiced':
+      case 'businessInvoiced':
+        newTargetPlan = 'proUnlimitedInvoiced'
+        newPlanStatus = workspacePlan.status
+        break
+      case 'unlimited':
+      case 'academia':
+        newTargetPlan = workspacePlan.name
+        newPlanStatus = workspacePlan.status
+        break
+      case 'free':
+        break
+
+      default:
+        throwUncoveredError(workspacePlan)
+    }
+
+    if (!newTargetPlan) {
+      log.info('No migration needed for this plan')
+      return
+    }
+
+    log.info(
+      { newTargetPlan, newPlanStatus, isStripeMigrationNeeded },
+      'Migrating to new plan'
+    )
+
+    const trx = await db.transaction()
+    // add editor seats to everyone
+
+    const workspaceMembers = await getWorkspaceRolesFactory({ db: trx })({
+      workspaceId
+    })
+    await trx<WorkspaceSeat>('workspace_seats')
+      .insert(
+        workspaceMembers.map((m) => ({
+          workspaceId,
+          userId: m.userId,
+          type: 'editor',
+          createdAt: new Date(),
+          updatedAt: new Date()
+        }))
+      )
+      .onConflict(['workspaceId', 'userId'])
+      .merge()
+    await upsertWorkspacePlanFactory({ db: trx })({
+      //@ts-expect-error the switch above makes sure things are ok
+      workspacePlan: {
+        workspaceId,
+        name: newTargetPlan,
+        status: newPlanStatus ?? workspacePlan.status,
+        createdAt: workspacePlan.createdAt
+      }
+    })
+    if (isStripeMigrationNeeded) {
+      log.info('Migrating stripe subscription data')
+      switch (newTargetPlan) {
+        case 'academia':
+        case 'free':
+        case 'proUnlimitedInvoiced':
+        case 'teamUnlimitedInvoiced':
+        case 'unlimited':
+          // this is just double checking that everythin is right
+          // the switch above sets things up properly
+          throw new Error('Cannot upgrade stripe for a non paid plan')
+      }
+      // if stripe paid plan, convert the stripe sub to use all editor seats
+      const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db: trx })({
+        workspaceId
+      })
+      if (!workspaceSubscription)
+        throw new Error('Subscription data not found, cant do stripe migration')
+
+      let memberAndGuestSeatCount = workspaceSubscription.subscriptionData.products
+        .map((p) => p.quantity)
+        // we're just summing all the seats
+        .reduce((acc, curr) => acc + curr, 0)
+
+      const workspaceTeamCount = workspaceMembers.length
+      if (memberAndGuestSeatCount < workspaceTeamCount) {
+        logger.warn(
+          { workspaceId, memberAndGuestSeatCount, workspaceTeamCount },
+          'Workspace has less paid member and guest seats, than people in the workspace. Reconciling'
+        )
+        memberAndGuestSeatCount = workspaceTeamCount
+      }
+      const productId = getWorkspacePlanProductId({ workspacePlan: newTargetPlan })
+      const priceId = getWorkspacePlanPriceId({
+        workspacePlan: newTargetPlan,
+        billingInterval: workspaceSubscription.billingInterval
+      })
+
+      const subscriptionData: SubscriptionDataInput = cloneDeep(
+        workspaceSubscription.subscriptionData
+      )
+      subscriptionData.products = []
+
+      subscriptionData.products.push({
+        productId,
+        priceId,
+        quantity: memberAndGuestSeatCount
+      })
+
+      await reconcileWorkspaceSubscriptionFactory({ stripe })({
+        subscriptionData,
+        prorationBehavior: 'create_prorations'
+      })
+    }
+    await trx.commit()
+    log.info('Workspace plan migration completed')
+
+    // add and editor seat to all workspace members
+    // convert current plan to the new plan
+    // if plan in cancelled, still convert to the new plan
+    // if cancellation scheduled, skip migration, we'll deal with that manually
+    //
+  }


Reviews:
user: login: gjedlicska
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:30:38Z
user: login: gjedlicska
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:33:59Z
user: login: gjedlicska
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:36:25Z
user: login: alemagio
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:59:29Z

Review comments:
diff_hunk: @@ -0,0 +1,240 @@
+import {
+  getWorkspacePlanFactory,
+  getWorkspaceSubscriptionFactory,
+  upsertWorkspacePlanFactory
+} from '@/modules/gatekeeper/repositories/billing'
+import {
+  throwUncoveredError,
+  WorkspacePlan,
+  WorkspacePlans,
+  WorkspacePlanStatuses
+} from '@speckle/shared'
+import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces'
+import {
+  SubscriptionDataInput,
+  WorkspaceSeat
+} from '@/modules/gatekeeper/domain/billing'
+import { Knex } from 'knex'
+import {
+  getWorkspacePlanPriceId,
+  getWorkspacePlanProductId
+} from '@/modules/gatekeeper/stripe'
+import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
+import Stripe from 'stripe'
+import { cloneDeep } from 'lodash'
+import { Logger } from '@/observability/logging'
+
+// get all workspace plan from the DB
+// foreach workspace:
+
+export const migrateOldWorkspacePlans =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async () => {
+    const oldPlanWorkspaces = await db<WorkspacePlan & { workspaceId: string }>(
+      'workspace_plans'
+    )
+      .select('*')
+      .whereIn('name', [
+        'business',
+        'businessInvoiced',
+        'plus',
+        'plusInvoiced',
+        'starter',
+        'starterInvoiced',
+        'academia',
+        'unlimited'
+      ])
+
+    for (const oldPlan of oldPlanWorkspaces) {
+      await migrateWorkspacePlan({ db, stripe, logger })({
path: packages/server/modules/gatekeeper/services/planMigration.ts
user: login: gjedlicska
body: try catch to make the loop continue and not fail the migration.
created_at: 2025-04-08T13:30:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,240 @@
+import {
+  getWorkspacePlanFactory,
+  getWorkspaceSubscriptionFactory,
+  upsertWorkspacePlanFactory
+} from '@/modules/gatekeeper/repositories/billing'
+import {
+  throwUncoveredError,
+  WorkspacePlan,
+  WorkspacePlans,
+  WorkspacePlanStatuses
+} from '@speckle/shared'
+import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces'
+import {
+  SubscriptionDataInput,
+  WorkspaceSeat
+} from '@/modules/gatekeeper/domain/billing'
+import { Knex } from 'knex'
+import {
+  getWorkspacePlanPriceId,
+  getWorkspacePlanProductId
+} from '@/modules/gatekeeper/stripe'
+import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
+import Stripe from 'stripe'
+import { cloneDeep } from 'lodash'
+import { Logger } from '@/observability/logging'
+
+// get all workspace plan from the DB
+// foreach workspace:
+
+export const migrateOldWorkspacePlans =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async () => {
+    const oldPlanWorkspaces = await db<WorkspacePlan & { workspaceId: string }>(
+      'workspace_plans'
+    )
+      .select('*')
+      .whereIn('name', [
+        'business',
+        'businessInvoiced',
+        'plus',
+        'plusInvoiced',
+        'starter',
+        'starterInvoiced',
+        'academia',
+        'unlimited'
+      ])
+
+    for (const oldPlan of oldPlanWorkspaces) {
+      await migrateWorkspacePlan({ db, stripe, logger })({
+        workspaceId: oldPlan.workspaceId
+      })
+    }
+  }
+
+export const migrateWorkspacePlan =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async ({ workspaceId }: { workspaceId: string }) => {
+    let log = logger.child({ workspaceId })
+    log.info('Starting workspace plan migration')
+    const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
+    if (!workspacePlan)
+      throw new Error(`Workspace ${workspaceId} has no workspace plan`)
+
+    log = log.child({ workspacePlan })
+
+    let newTargetPlan: WorkspacePlans | null = null
+    let newPlanStatus: WorkspacePlanStatuses | null = null
+    let isStripeMigrationNeeded = false
+    switch (workspacePlan.name) {
+      case 'team':
+      case 'teamUnlimited':
+      case 'pro':
+      case 'proUnlimited':
+      case 'teamUnlimitedInvoiced':
+      case 'proUnlimitedInvoiced':
+        // these are new plans already, no upgrades
+        break
+      case 'starter':
+        switch (workspacePlan.status) {
+          case 'trial':
+          case 'expired':
+            newPlanStatus = 'valid'
+            newTargetPlan = 'free'
+            break
+          case 'paymentFailed':
+            throw new Error('Cant migrate workspace, its currently failed in payment')
+          case 'cancelationScheduled':
path: packages/server/modules/gatekeeper/services/planMigration.ts
user: login: gjedlicska
body: this needs stripe migration too
created_at: 2025-04-08T13:33:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,240 @@
+import {
+  getWorkspacePlanFactory,
+  getWorkspaceSubscriptionFactory,
+  upsertWorkspacePlanFactory
+} from '@/modules/gatekeeper/repositories/billing'
+import {
+  throwUncoveredError,
+  WorkspacePlan,
+  WorkspacePlans,
+  WorkspacePlanStatuses
+} from '@speckle/shared'
+import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces'
+import {
+  SubscriptionDataInput,
+  WorkspaceSeat
+} from '@/modules/gatekeeper/domain/billing'
+import { Knex } from 'knex'
+import {
+  getWorkspacePlanPriceId,
+  getWorkspacePlanProductId
+} from '@/modules/gatekeeper/stripe'
+import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
+import Stripe from 'stripe'
+import { cloneDeep } from 'lodash'
+import { Logger } from '@/observability/logging'
+
+// get all workspace plan from the DB
+// foreach workspace:
+
+export const migrateOldWorkspacePlans =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async () => {
+    const oldPlanWorkspaces = await db<WorkspacePlan & { workspaceId: string }>(
+      'workspace_plans'
+    )
+      .select('*')
+      .whereIn('name', [
+        'business',
+        'businessInvoiced',
+        'plus',
+        'plusInvoiced',
+        'starter',
+        'starterInvoiced',
+        'academia',
+        'unlimited'
+      ])
+
+    for (const oldPlan of oldPlanWorkspaces) {
+      await migrateWorkspacePlan({ db, stripe, logger })({
+        workspaceId: oldPlan.workspaceId
+      })
+    }
+  }
+
+export const migrateWorkspacePlan =
+  ({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
+  async ({ workspaceId }: { workspaceId: string }) => {
+    let log = logger.child({ workspaceId })
+    log.info('Starting workspace plan migration')
+    const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
+    if (!workspacePlan)
+      throw new Error(`Workspace ${workspaceId} has no workspace plan`)
+
+    log = log.child({ workspacePlan })
+
+    let newTargetPlan: WorkspacePlans | null = null
+    let newPlanStatus: WorkspacePlanStatuses | null = null
+    let isStripeMigrationNeeded = false
+    switch (workspacePlan.name) {
+      case 'team':
+      case 'teamUnlimited':
+      case 'pro':
+      case 'proUnlimited':
+      case 'teamUnlimitedInvoiced':
+      case 'proUnlimitedInvoiced':
+        // these are new plans already, no upgrades
+        break
+      case 'starter':
+        switch (workspacePlan.status) {
+          case 'trial':
+          case 'expired':
+            newPlanStatus = 'valid'
+            newTargetPlan = 'free'
+            break
+          case 'paymentFailed':
+            throw new Error('Cant migrate workspace, its currently failed in payment')
+          case 'cancelationScheduled':
+          case 'canceled':
+            // just switch the plan, no need to change stripe
+            newTargetPlan = 'teamUnlimited'
+            newPlanStatus = workspacePlan.status
+            break
+          case 'valid':
+            newTargetPlan = 'teamUnlimited'
+            newPlanStatus = workspacePlan.status
+            isStripeMigrationNeeded = true
+            break
+          default:
+            throwUncoveredError(workspacePlan)
+        }
+        break
+      case 'plus':
+      case 'business':
+        switch (workspacePlan.status) {
+          case 'paymentFailed':
+            throw new Error('Cant migrate workspace, its currently failed in payment')
+          case 'cancelationScheduled':
path: packages/server/modules/gatekeeper/services/planMigration.ts
user: login: gjedlicska
body: this is also like valid
created_at: 2025-04-08T13:36:25Z
author_association: MEMBER

PR comments:
user: login: linear[bot]
created_at: 2025-04-06T13:29:20Z
body: <p><a href="https://linear.app/speckle/issue/WEB-2930/workspace-migration-script">WEB-2930 workspace migration script</a></p>
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-06T13:36:45Z
body: ## [Codecov](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) Report
Attention: Patch coverage is `10.52632%` with `85 lines` in your changes missing coverage. Please review.
> Project coverage is 61.26%. Comparing base [(`a3644a5`)](https://app.codecov.io/gh/specklesystems/speckle-server/commit/a3644a50c511f95a2e677d57ac421ce6996dd5c3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) to head [(`2c82595`)](https://app.codecov.io/gh/specklesystems/speckle-server/commit/2c82595279d364460c445d4708384d0f798e7a74?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) | Patch % | Lines |
|---|---|---|
| [...erver/modules/gatekeeper/services/planMigration.ts](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?src=pr&el=tree&filepath=packages%2Fserver%2Fmodules%2Fgatekeeper%2Fservices%2FplanMigration.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems#diff-cGFja2FnZXMvc2VydmVyL21vZHVsZXMvZ2F0ZWtlZXBlci9zZXJ2aWNlcy9wbGFuTWlncmF0aW9uLnRz) | 9.19% | [79 Missing :warning: ](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) |
| [packages/server/modules/gatekeeper/index.ts](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?src=pr&el=tree&filepath=packages%2Fserver%2Fmodules%2Fgatekeeper%2Findex.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems#diff-cGFja2FnZXMvc2VydmVyL21vZHVsZXMvZ2F0ZWtlZXBlci9pbmRleC50cw==) | 25.00% | [6 Missing :warning: ](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main    #4333       +/-   ##
===========================================
- Coverage   71.83%   61.26%   -10.57%     
===========================================
  Files         440      418       -22     
  Lines       19627    18511     -1116     
  Branches     3157     2992      -165     
===========================================
- Hits        14099    11341     -2758     
- Misses       4444     6294     +1850     
+ Partials     1084      876      -208     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/specklesystems/speckle-server/pull/4333?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=specklesystems).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE