From 293fb3f3a9cbc00c6038535d80da2891ec824ffe Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 15 Jul 2026 20:03:53 +0200 Subject: [PATCH 1/2] chore(deps): update @codaco packages to fresco-ui 4.0 / interview 3.0 Bump @codaco/fresco-ui 3.0.1->4.0.0, @codaco/interview 2.0.1->3.0.0, @codaco/protocol-validation 11.8.1->11.9.0, @codaco/protocol-utilities 2.1.1->2.2.0, @codaco/shared-consts 5.4.0->5.5.0, @codaco/tailwind-config 1.0.2->1.1.0, @codaco/network-exporters 1.1.2->1.1.3, and tailwindcss 4.3.1->4.3.2 (new peer minimum). Downstream changes required by the upgrade: - UpdateSettingsValue: field onFocus is now typed as a generic React.FocusEvent, so narrow event.target before calling select(). - SettingsCard/SettingsNavigation: Heading margin="none" now emits m-0! (important), which defeats consumer mb-* utilities; use padding for the title gap instead. - migrate-protocols-to-v8: protocol-validation 11.9.0 tightened the v8 schema, so stored v8 protocols imported under 11.8.x can newly fail isConformantV8 and be re-run through the v7->v8 migration, which is not idempotent on genuine v8 bodies (it resets node shape configs and adds required:true next to min* validators). Snapshot the stored codebook before migrating, restore authored shape/required state onto the migration output, and persist the restored body only when it still passes the strict schema. --- .../_components/UpdateSettingsValue.tsx | 6 +- components/settings/SettingsCard.tsx | 3 +- components/settings/SettingsNavigation.tsx | 3 +- package.json | 16 +- pnpm-lock.yaml | 245 ++++++++++-------- .../__tests__/migrate-protocols-to-v8.test.ts | 118 +++++++++ scripts/migrate-protocols-to-v8.ts | 123 ++++++++- 7 files changed, 385 insertions(+), 129 deletions(-) diff --git a/app/dashboard/_components/UpdateSettingsValue.tsx b/app/dashboard/_components/UpdateSettingsValue.tsx index 8cc1a8233..86d8d322f 100644 --- a/app/dashboard/_components/UpdateSettingsValue.tsx +++ b/app/dashboard/_components/UpdateSettingsValue.tsx @@ -69,7 +69,11 @@ export default function UpdateSettingsValue({ event.target.select()} + onFocus={(event) => { + if (event.target instanceof HTMLInputElement) { + event.target.select(); + } + }} type="text" className="w-full" placeholder={placeholder} diff --git a/components/settings/SettingsCard.tsx b/components/settings/SettingsCard.tsx index 5d4923c5e..87f80f61d 100644 --- a/components/settings/SettingsCard.tsx +++ b/components/settings/SettingsCard.tsx @@ -43,7 +43,8 @@ export default function SettingsCard({ level="h4" variant="all-caps" margin="none" - className={cx(divideChildren ? 'mb-4' : 'mb-2')} + // margin="none" applies m-0! (important), so spacing must use padding + className={cx(divideChildren ? 'pb-4' : 'pb-2')} > {title} diff --git a/components/settings/SettingsNavigation.tsx b/components/settings/SettingsNavigation.tsx index e7417c6b2..ab4b7d353 100644 --- a/components/settings/SettingsNavigation.tsx +++ b/components/settings/SettingsNavigation.tsx @@ -42,7 +42,8 @@ export default function SettingsNavigation({ )} noContainer > - + {/* margin="none" applies m-0! (important), so spacing must use padding */} + On this page
    diff --git a/package.json b/package.json index 07e7b2ba9..2302fe6af 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,13 @@ "@aws-sdk/client-s3": "^3.1068.0", "@aws-sdk/s3-request-presigner": "^3.1068.0", "@base-ui/react": "^1.5.0", - "@codaco/fresco-ui": "^3.0.1", - "@codaco/interview": "^2.0.1", - "@codaco/network-exporters": "^1.1.2", - "@codaco/protocol-utilities": "^2.1.1", - "@codaco/protocol-validation": "^11.8.1", - "@codaco/shared-consts": "5.4.0", - "@codaco/tailwind-config": "^1.0.2", + "@codaco/fresco-ui": "^4.0.0", + "@codaco/interview": "^3.0.0", + "@codaco/network-exporters": "^1.1.3", + "@codaco/protocol-utilities": "^2.2.0", + "@codaco/protocol-validation": "^11.9.0", + "@codaco/shared-consts": "5.5.0", + "@codaco/tailwind-config": "^1.1.0", "@paralleldrive/cuid2": "^3.3.0", "@posthog/nextjs-config": "^1.9.68", "@prisma/adapter-neon": "^7.8.0", @@ -123,7 +123,7 @@ "sass": "^1.101.0", "sass-embedded": "^1.100.0", "storybook": "^10.4.4", - "tailwindcss": "4.3.1", + "tailwindcss": "4.3.2", "tailwindcss-animate": "^1.0.7", "tsx": "^4.22.4", "typescript": "6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7842dee7d..544f2f2cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,26 +24,26 @@ importers: specifier: ^1.5.0 version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@codaco/fresco-ui': - specifier: ^3.0.1 - version: 3.0.1(@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@codaco/shared-consts@5.4.0)(@codaco/tailwind-config@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1))(@floating-ui/dom@1.7.6)(@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))) + specifier: ^4.0.0 + version: 4.0.0(@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@codaco/shared-consts@5.5.0)(@codaco/tailwind-config@1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2))(@floating-ui/dom@1.7.6)(@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@tiptap/extensions@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2)(typescript@6.0.3)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))) '@codaco/interview': - specifier: ^2.0.1 - version: 2.0.1(a81bf49c747984e2c7ef6be5d53be250) + specifier: ^3.0.0 + version: 3.0.0(8ef844fe2bc6798fb744dabbfb4411e1) '@codaco/network-exporters': - specifier: ^1.1.2 - version: 1.1.2 + specifier: ^1.1.3 + version: 1.1.3 '@codaco/protocol-utilities': - specifier: ^2.1.1 - version: 2.1.1 + specifier: ^2.2.0 + version: 2.2.0 '@codaco/protocol-validation': - specifier: ^11.8.1 - version: 11.8.1 + specifier: ^11.9.0 + version: 11.9.0 '@codaco/shared-consts': - specifier: 5.4.0 - version: 5.4.0 + specifier: 5.5.0 + version: 5.5.0 '@codaco/tailwind-config': - specifier: ^1.0.2 - version: 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1) + specifier: ^1.1.0 + version: 1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) '@paralleldrive/cuid2': specifier: ^3.3.0 version: 3.3.0 @@ -148,7 +148,7 @@ importers: version: 2.2.6 uploadthing: specifier: ^7.7.4 - version: 7.7.4(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(tailwindcss@4.3.1) + version: 7.7.4(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(tailwindcss@4.3.2) usehooks-ts: specifier: ^3.1.1 version: 3.1.1(react@19.2.7) @@ -188,7 +188,7 @@ importers: version: 0.13.11(typescript@6.0.3)(valibot@1.4.1(typescript@6.0.3))(zod@4.4.3) '@tailwindcss/forms': specifier: ^0.5.11 - version: 0.5.11(tailwindcss@4.3.1) + version: 0.5.11(tailwindcss@4.3.2) '@tailwindcss/postcss': specifier: 4.3.1 version: 4.3.1 @@ -257,7 +257,7 @@ importers: version: 4.4.5(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-better-tailwindcss: specifier: ^4.6.0 - version: 4.6.0(eslint@9.39.4(jiti@2.7.0))(tailwindcss@4.3.1)(typescript@6.0.3) + version: 4.6.0(eslint@9.39.4(jiti@2.7.0))(tailwindcss@4.3.2)(typescript@6.0.3) eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@9.39.4(jiti@2.7.0)) @@ -304,11 +304,11 @@ importers: specifier: ^10.4.4 version: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwindcss: - specifier: 4.3.1 - version: 4.3.1 + specifier: 4.3.2 + version: 4.3.2 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@4.3.1) + version: 1.0.7(tailwindcss@4.3.2) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -574,53 +574,53 @@ packages: peerDependencies: storybook: ^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0 - '@codaco/fresco-ui@3.0.1': - resolution: {integrity: sha512-ZmAk3VXlskACFwDO6OTvF8pUIM95cqmNCuobxC3q2jtL6avw+shCMJTsOYHHb1n80vgWmA2TWQG0EsznUX+kVA==} + '@codaco/fresco-ui@4.0.0': + resolution: {integrity: sha512-a2EM+PDuXNZfA59zm0jg5WwvNj2Hfx9DotuygWjOjVpLuZB+q53LqyJfTV1wlisWBzfkcvoe/XSEAj3t3mqfOQ==} peerDependencies: '@base-ui/react': ^1.4.0 - '@codaco/shared-consts': ^5.4.0 - '@codaco/tailwind-config': ^1.0.2 + '@codaco/shared-consts': 5.5.0 + '@codaco/tailwind-config': 1.1.0 '@tanstack/react-table': ^8.21.3 motion: ^12.40.0 - react: ^19.2.6 - react-dom: ^19.2.6 - tailwindcss: ^4.3.0 + react: ^19.2.7 + react-dom: ^19.2.7 + tailwindcss: ^4.3.2 zod: ^4.4.3 zustand: ^5.0.14 - '@codaco/interview@2.0.1': - resolution: {integrity: sha512-ln8az1MxtbJKkpaiMNSIVY7iED0uveDYAVXqtzY5DIS/icMbiKaKZ9afSSIcST50qkx+2xNwVkNPNxFTwnG7RQ==} + '@codaco/interview@3.0.0': + resolution: {integrity: sha512-whvbubscPb2o9fJL6rmDm4mRQzKc0ZY11D1PitftDCeQ/Djq9vnlpw9DLguofyGqLosJuJAiN+fXBS8EeHbmMQ==} peerDependencies: - '@codaco/fresco-ui': ^3.0.1 - '@codaco/protocol-validation': ^11.8.1 - '@codaco/shared-consts': ^5.4.0 - '@codaco/tailwind-config': ^1.0.2 + '@codaco/fresco-ui': 4.0.0 + '@codaco/protocol-validation': 11.9.0 + '@codaco/shared-consts': 5.5.0 + '@codaco/tailwind-config': 1.1.0 motion: ^12.40.0 - react: ^19.2.6 - react-dom: ^19.2.6 - tailwindcss: ^4.3.0 + react: ^19.2.7 + react-dom: ^19.2.7 + tailwindcss: ^4.3.2 - '@codaco/network-exporters@1.1.2': - resolution: {integrity: sha512-4qP+1mAy+pxpPHpWRvPcrfUlmPJRHhIr/f4SijiYAm+B3DmjL8XfpKu489/PpOl8XZyx2AnjatkoQJHt1xYXhA==} + '@codaco/network-exporters@1.1.3': + resolution: {integrity: sha512-f7ar+UFPkT9BDN9N+tlIir2Hvbu4uEjmMNrjE7uEbtfvieCdfuIJmlx1joHE/960QZYu96+NEOSNpt9L/sAfBA==} - '@codaco/network-query@1.1.2': - resolution: {integrity: sha512-QbxxHHA1a/1yOjaMmVinHei/TG8K3AhDgLlKxX9VyWzV4x5OsooNDU6ot6BhFaoKu1m9Uduci+XyB+Y3NaDS7A==} + '@codaco/network-query@1.2.0': + resolution: {integrity: sha512-1r7hQVTBma3/TYG6wPLtztuSbu7oJXbmm7e8BEvBhsUGHAjB3RsSWZw5DbyfXxagiz3vSiCe6Oq1uBydA5GGrQ==} - '@codaco/protocol-utilities@2.1.1': - resolution: {integrity: sha512-bmq48EPGFol+07gzFttJACdELOU27ssf4eZDEYApbS03iiDKgmadmZxC2oHctPJCL6DhOw4+QbEZjKUd0M+9cw==} + '@codaco/protocol-utilities@2.2.0': + resolution: {integrity: sha512-nnqZ+YUYTpMBCy5nigS6EAO7XWRVsqJAFqL69Nd23DQA78diRdlZMnVuteHBCI9y/wCUEMFr8g/amTJ33BXfkg==} - '@codaco/protocol-validation@11.8.1': - resolution: {integrity: sha512-/RFzS6QZfDUPOXJSY6/3YK0UE6iu+5jndtp/Fn3YrMgHyr02j2/pvnBK/h6++7B3UP/EQAjSc1rQ0svE6S/lkg==} + '@codaco/protocol-validation@11.9.0': + resolution: {integrity: sha512-AMSU9nf3Bf8bxvPNW0BdxGVmE5t0vNpnGc5XuWr6OldCUxMUqDponO/YiY+x/AYnE/6KdQDQHvYgYk8nk502Iw==} hasBin: true - '@codaco/shared-consts@5.4.0': - resolution: {integrity: sha512-9hkmhvYooSIC2RrAKl5uq+9PbvuXV9ZWIWBxyAxg08Oresu3EP1o0t/liKskewLrsgwRxU5fXlDPuvgpNiybsg==} + '@codaco/shared-consts@5.5.0': + resolution: {integrity: sha512-n2GJiGcZm8X+ZAtU27RY07zKL2l5nLakdoT+3kX/jnDw+E8JGSOAB+/RdqkAzbihFgi2272eWBwvHpsho6v4dg==} engines: {node: '>=20.0.0'} - '@codaco/tailwind-config@1.0.2': - resolution: {integrity: sha512-NynBL9n5rENcxb//7PeudvxvugvdhuGqLIyvF8q35/SHyHEy1U6+HeXpNewBBY8ryUJ6QYiqcfUDOcQwd7Ug7A==} + '@codaco/tailwind-config@1.1.0': + resolution: {integrity: sha512-w682gp5R+xB8jGOAKiUhgrHlFh3pw9AYtvRGCPc5rexepUNaH38sesA1i6YDzVVycF+duzxBjGHUbxHmVdJy0Q==} peerDependencies: - tailwindcss: ^4.3.0 + tailwindcss: ^4.3.2 '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} @@ -2947,11 +2947,11 @@ packages: peerDependencies: '@tiptap/core': 3.24.0 - '@tiptap/extension-bubble-menu@3.24.0': - resolution: {integrity: sha512-jRXD+JPu9ayvq78g8hsCxx4q/qUFtrdfIYirRSf5YUseuuUbtfrq83AsGabcygpUTefjJkMQoXNITkh6294Ggw==} + '@tiptap/extension-bubble-menu@3.27.4': + resolution: {integrity: sha512-Poy7xwcD3POG5ew/TW7mYXv7m++vCchvHxPUqIfnTxBxvvvqDZkPYFWZS1lvPrSBtm1DcfUTQAgVutM5NDZ99Q==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.27.4 + '@tiptap/pm': 3.27.4 '@tiptap/extension-bullet-list@3.24.0': resolution: {integrity: sha512-IOpAm5c4XVVVvkOef+V9XYMVpea+3MgBpCQgn83UQRlwO9eIMwmcyxOznu7gQPQVShTEpkt4T6uK+ZN9o8meIA==} @@ -2979,12 +2979,12 @@ packages: peerDependencies: '@tiptap/extensions': 3.24.0 - '@tiptap/extension-floating-menu@3.24.0': - resolution: {integrity: sha512-7QEbf3mUzFAkejjQGX9f0L507oMtnOBRwHt2skUTR+9yXgudsN8zaDBSSRHLeMWGk9b7L293ZMA6zCRrZaHrfA==} + '@tiptap/extension-floating-menu@3.27.4': + resolution: {integrity: sha512-tnZywwoNDuEcUZmYYIztXl3PpIKUq+gKeaYPuZhpYEVTThU44tzK3ZuFOmd+qf2aAa1MQwxKWqUuLpNK77bwNw==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.27.4 + '@tiptap/pm': 3.27.4 '@tiptap/extension-gapcursor@3.24.0': resolution: {integrity: sha512-CzCP5/jni5RFwW9jCfBO6auh83GbaioMTpSk6tyR3sd+CbwlBcUdsJFGJkbaRdiSS9dgIyi+6hRbhjpYdHcp+w==} @@ -3044,6 +3044,11 @@ packages: peerDependencies: '@tiptap/core': 3.24.0 + '@tiptap/extension-placeholder@3.27.4': + resolution: {integrity: sha512-7hBoFLeddCv1WzkqB0x3coZ1Hp9WZ9wLoRXIUtUhRKMpzFq2IlTtW1iw88g9pTJnL98bCCElN4DZ4mYtaQvmgA==} + peerDependencies: + '@tiptap/extensions': 3.27.4 + '@tiptap/extension-strike@3.24.0': resolution: {integrity: sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ==} peerDependencies: @@ -3065,14 +3070,20 @@ packages: '@tiptap/core': 3.24.0 '@tiptap/pm': 3.24.0 + '@tiptap/extensions@3.27.4': + resolution: {integrity: sha512-d8opkg2iGtVwJmNGIqv0blfRxnvWOJp1brz+Z8CsP4ojSS2ZtaE46d6JSQ5OeJ7nMpjhT+9wh4UQcA7OSEO59w==} + peerDependencies: + '@tiptap/core': 3.27.4 + '@tiptap/pm': 3.27.4 + '@tiptap/pm@3.24.0': resolution: {integrity: sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg==} - '@tiptap/react@3.24.0': - resolution: {integrity: sha512-KxnrlQbzOgA02EMsfuGGHtNhfkJQGqVlQttmQctI9DOl/F3gcaRqg+wNTBY1Fof8yDaZ8Z1LL1F0C05W0o3vUw==} + '@tiptap/react@3.27.4': + resolution: {integrity: sha512-rTY1V9Y1jzwmo5ItRi3v2Og/mbcYsr9AjUvGoqpXzR9Z31WhXYphw0y05aYzryh0MHXYzkiE+gbGvrbg+cjwEg==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.27.4 + '@tiptap/pm': 3.27.4 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4567,8 +4578,8 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - fuse.js@7.4.0: - resolution: {integrity: sha512-3UqmoSFwzX1sNB1YSk+Co0EdH29XCW2p9g48OAiy93cjKqzuABsqw2VIgSN3CmsT/wo6pIJ3F0Jxeiiby8rhIQ==} + fuse.js@7.5.0: + resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} engines: {node: '>=10'} gemoji@8.1.0: @@ -5419,8 +5430,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.11: - resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} engines: {node: ^18 || >=20} hasBin: true @@ -6583,6 +6594,9 @@ packages: tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -7606,11 +7620,11 @@ snapshots: - '@chromatic-com/playwright' - '@chromatic-com/vitest' - '@codaco/fresco-ui@3.0.1(@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@codaco/shared-consts@5.4.0)(@codaco/tailwind-config@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1))(@floating-ui/dom@1.7.6)(@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)))': + '@codaco/fresco-ui@4.0.0(@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@codaco/shared-consts@5.5.0)(@codaco/tailwind-config@1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2))(@floating-ui/dom@1.7.6)(@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@tiptap/extensions@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2)(typescript@6.0.3)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)))': dependencies: '@base-ui/react': 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@codaco/shared-consts': 5.4.0 - '@codaco/tailwind-config': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1) + '@codaco/shared-consts': 5.5.0 + '@codaco/tailwind-config': 1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.17)(react@19.2.7) '@tanstack/react-table': 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) @@ -7618,17 +7632,18 @@ snapshots: '@tiptap/extension-heading': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) '@tiptap/extension-ordered-list': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) '@tiptap/extension-paragraph': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-placeholder': 3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) '@tiptap/pm': 3.24.0 - '@tiptap/react': 3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tiptap/react': 3.27.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tiptap/starter-kit': 3.24.0 comlink: 4.4.2 cva: 1.0.0-beta.4(typescript@6.0.3) es-toolkit: 1.49.0 - fuse.js: 7.4.0 + fuse.js: 7.5.0 immer: 11.1.8 lucide-react: 1.23.0(react@19.2.7) motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - nanoid: 5.1.11 + nanoid: 5.1.16 react: 19.2.7 react-aria-components: 1.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-best-merge-refs: 1.0.2(patch_hash=469dfbaa03c158d70d3588a72c8d8cd96e54c22d37f6fe62535c0b33fd21adc8)(react@19.2.7) @@ -7639,26 +7654,27 @@ snapshots: remark-gemoji: 8.0.0 remark-gfm: 4.0.1 tailwind-merge: 3.6.0 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 usehooks-ts: 3.1.1(react@19.2.7) zod: 4.4.3 zustand: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/extension-list' + - '@tiptap/extensions' - '@types/react' - '@types/react-dom' - supports-color - typescript - '@codaco/interview@2.0.1(a81bf49c747984e2c7ef6be5d53be250)': + '@codaco/interview@3.0.0(8ef844fe2bc6798fb744dabbfb4411e1)': dependencies: '@base-ui/react': 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@codaco/fresco-ui': 3.0.1(@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@codaco/shared-consts@5.4.0)(@codaco/tailwind-config@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1))(@floating-ui/dom@1.7.6)(@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)(typescript@6.0.3)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))) - '@codaco/network-query': 1.1.2 - '@codaco/protocol-validation': 11.8.1 - '@codaco/shared-consts': 5.4.0 - '@codaco/tailwind-config': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1) + '@codaco/fresco-ui': 4.0.0(@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@codaco/shared-consts@5.5.0)(@codaco/tailwind-config@1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2))(@floating-ui/dom@1.7.6)(@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@tiptap/extensions@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2)(typescript@6.0.3)(zod@4.4.3)(zustand@5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))) + '@codaco/network-query': 1.2.0 + '@codaco/protocol-validation': 11.9.0 + '@codaco/shared-consts': 5.5.0 + '@codaco/tailwind-config': 1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) '@faker-js/faker': 10.4.0 '@mapbox/search-js-react': 1.5.1(mapbox-gl@3.25.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) @@ -7678,7 +7694,7 @@ snapshots: react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) redux-logger: 3.0.6 redux-thunk: 3.1.0(redux@5.0.1) - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 uuid: 14.0.1 zustand: 5.0.14(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) transitivePeerDependencies: @@ -7688,10 +7704,10 @@ snapshots: - redux - use-sync-external-store - '@codaco/network-exporters@1.1.2': + '@codaco/network-exporters@1.1.3': dependencies: - '@codaco/protocol-validation': 11.8.1 - '@codaco/shared-consts': 5.4.0 + '@codaco/protocol-validation': 11.9.0 + '@codaco/shared-consts': 5.5.0 '@xmldom/xmldom': 0.9.10 effect: 3.21.4 es-toolkit: 1.49.0 @@ -7700,37 +7716,37 @@ snapshots: sanitize-filename: 1.6.4 zod: 4.4.3 - '@codaco/network-query@1.1.2': + '@codaco/network-query@1.2.0': dependencies: - '@codaco/protocol-validation': 11.8.1 - '@codaco/shared-consts': 5.4.0 + '@codaco/protocol-validation': 11.9.0 + '@codaco/shared-consts': 5.5.0 es-toolkit: 1.49.0 - '@codaco/protocol-utilities@2.1.1': + '@codaco/protocol-utilities@2.2.0': dependencies: - '@codaco/network-query': 1.1.2 - '@codaco/protocol-validation': 11.8.1 - '@codaco/shared-consts': 5.4.0 + '@codaco/network-query': 1.2.0 + '@codaco/protocol-validation': 11.9.0 + '@codaco/shared-consts': 5.5.0 '@faker-js/faker': 10.4.0 es-toolkit: 1.49.0 uuid: 14.0.1 - '@codaco/protocol-validation@11.8.1': + '@codaco/protocol-validation@11.9.0': dependencies: - '@codaco/shared-consts': 5.4.0 + '@codaco/shared-consts': 5.5.0 ohash: 2.0.11 zod: 4.4.3 - '@codaco/shared-consts@5.4.0': + '@codaco/shared-consts@5.5.0': dependencies: zod: 4.4.3 - '@codaco/tailwind-config@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)': + '@codaco/tailwind-config@1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2)': dependencies: '@fontsource-variable/inclusive-sans': 5.2.8 '@fontsource-variable/nunito': 5.2.7 motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 transitivePeerDependencies: - '@emotion/is-prop-valid' - react @@ -9421,10 +9437,10 @@ snapshots: valibot: 1.4.1(typescript@6.0.3) zod: 4.4.3 - '@tailwindcss/forms@0.5.11(tailwindcss@4.3.1)': + '@tailwindcss/forms@0.5.11(tailwindcss@4.3.2)': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 '@tailwindcss/node@4.3.1': dependencies: @@ -9549,7 +9565,7 @@ snapshots: dependencies: '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/extension-bubble-menu@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-bubble-menu@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': dependencies: '@floating-ui/dom': 1.7.6 '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) @@ -9577,7 +9593,7 @@ snapshots: dependencies: '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-floating-menu@3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-floating-menu@3.27.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': dependencies: '@floating-ui/dom': 1.7.6 '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) @@ -9632,6 +9648,10 @@ snapshots: dependencies: '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/extension-placeholder@3.27.4(@tiptap/extensions@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + dependencies: + '@tiptap/extensions': 3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-strike@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': dependencies: '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) @@ -9649,6 +9669,11 @@ snapshots: '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) '@tiptap/pm': 3.24.0 + '@tiptap/extensions@3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + dependencies: + '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/pm': 3.24.0 + '@tiptap/pm@3.24.0': dependencies: prosemirror-changeset: 2.4.1 @@ -9665,7 +9690,7 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.8 - '@tiptap/react@3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tiptap/react@3.27.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) '@tiptap/pm': 3.24.0 @@ -9677,8 +9702,8 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-floating-menu': 3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-bubble-menu': 3.27.4(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-floating-menu': 3.27.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) transitivePeerDependencies: - '@floating-ui/dom' @@ -11057,7 +11082,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-better-tailwindcss@4.6.0(eslint@9.39.4(jiti@2.7.0))(tailwindcss@4.3.1)(typescript@6.0.3): + eslint-plugin-better-tailwindcss@4.6.0(eslint@9.39.4(jiti@2.7.0))(tailwindcss@4.3.2)(typescript@6.0.3): dependencies: '@eslint/css-tree': 4.0.4 '@valibot/to-json-schema': 1.7.1(valibot@1.4.1(typescript@6.0.3)) @@ -11065,7 +11090,7 @@ snapshots: jiti: 2.7.0 synckit: 0.11.13 tailwind-csstree: 0.3.3 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 tsconfig-paths-webpack-plugin: 4.2.0 valibot: 1.4.1(typescript@6.0.3) optionalDependencies: @@ -11385,7 +11410,7 @@ snapshots: functions-have-names@1.2.3: {} - fuse.js@7.4.0: {} + fuse.js@7.5.0: {} gemoji@8.1.0: {} @@ -12454,7 +12479,7 @@ snapshots: nanoid@3.3.12: {} - nanoid@5.1.11: {} + nanoid@5.1.16: {} napi-postinstall@0.3.4: {} @@ -13790,12 +13815,14 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss-animate@1.0.7(tailwindcss@4.3.1): + tailwindcss-animate@1.0.7(tailwindcss@4.3.2): dependencies: - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 tailwindcss@4.3.1: {} + tailwindcss@4.3.2: {} + tapable@2.3.3: {} terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.15)(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)): @@ -14055,7 +14082,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uploadthing@7.7.4(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(tailwindcss@4.3.1): + uploadthing@7.7.4(next@16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(tailwindcss@4.3.2): dependencies: '@effect/platform': 0.90.3(effect@3.21.4) '@standard-schema/spec': 1.0.0-beta.4 @@ -14064,7 +14091,7 @@ snapshots: effect: 3.21.4 optionalDependencies: next: 16.2.9(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 uri-js@4.4.1: dependencies: diff --git a/scripts/__tests__/migrate-protocols-to-v8.test.ts b/scripts/__tests__/migrate-protocols-to-v8.test.ts index 15f812539..5bfd5a08f 100644 --- a/scripts/__tests__/migrate-protocols-to-v8.test.ts +++ b/scripts/__tests__/migrate-protocols-to-v8.test.ts @@ -257,6 +257,124 @@ describe('migrateProtocolsToV8', () => { expect(call.data.codebook.node.person).not.toHaveProperty('iconVariant'); }); + it('preserves authored v8 state (node shape, optional min-validated fields) when normalizing', async () => { + // A mislabelled-v8 body: legacy pre-v8 field shapes (iconVariant, Toggle + // options) force re-normalization, but the row ALSO carries authored v8 + // state the v7→v8 migration would destroy — a custom node shape config + // and a min-validated variable without an explicit `required`. Since + // protocol-validation 11.9.0 tightened CurrentProtocolSchema, rows like + // this can newly fail conformance, and normalization must not corrupt the + // authored values. + const mixed = makeV7Protocol(); + const personNode = mixed.codebook.node.person as Record; + personNode.shape = { default: 'square' }; + (personNode.variables as Record).nickname = { + name: 'nickname', + type: 'text', + component: 'Text', + validation: { minLength: 2 }, + }; + + const prisma = makeMockPrisma(); + prisma.protocol.findMany.mockResolvedValue([ + { + id: 'cm-authored-v8', + assets: [], + name: 'Authored.netcanvas', + schemaVersion: 8, + stages: mixed.stages, + codebook: mixed.codebook, + experiments: null, + description: mixed.description, + lastModified: new Date(mixed.lastModified), + }, + ]); + + await migrateProtocolsToV8( + prisma as unknown as Parameters[0], + ); + + expect(prisma.protocol.update).toHaveBeenCalledTimes(1); + + type UpdateCallArg = { + data: { + codebook: { + node: { + person: { + icon: string; + shape: Record; + variables: Record< + string, + { validation?: Record } + >; + }; + }; + }; + }; + }; + const call = prisma.protocol.update.mock.calls[0]?.[0] as UpdateCallArg; + const person = call.data.codebook.node.person; + + // Legacy shapes were still normalized... + expect(person.icon).toBe('add-a-person'); + expect(person).not.toHaveProperty('iconVariant'); + // ...but the authored shape config survives instead of being reset to + // { default: 'circle' }... + expect(person.shape).toEqual({ default: 'square' }); + // ...and the min-validated variable stays optional instead of gaining + // required: true. + expect(person.variables.nickname?.validation).toEqual({ minLength: 2 }); + }); + + it('falls back to the plain re-migration output when authored state cannot be restored', async () => { + const warnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + // The authored shape config is invalid v8, so restoring it would produce + // a body that fails the strict schema; normalization must persist the + // valid migration output instead of writing a broken body back. + const mixed = makeV7Protocol(); + (mixed.codebook.node.person as Record).shape = { + default: 'not-a-real-shape', + }; + + const prisma = makeMockPrisma(); + prisma.protocol.findMany.mockResolvedValue([ + { + id: 'cm-bad-authored', + assets: [], + name: 'BadAuthored.netcanvas', + schemaVersion: 8, + stages: mixed.stages, + codebook: mixed.codebook, + experiments: null, + description: mixed.description, + lastModified: new Date(mixed.lastModified), + }, + ]); + + await migrateProtocolsToV8( + prisma as unknown as Parameters[0], + ); + + expect(prisma.protocol.update).toHaveBeenCalledTimes(1); + + type UpdateCallArg = { + data: { + codebook: { node: { person: { shape: Record } } }; + }; + }; + const call = prisma.protocol.update.mock.calls[0]?.[0] as UpdateCallArg; + expect(call.data.codebook.node.person.shape).toEqual({ + default: 'circle', + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('BadAuthored.netcanvas'), + ); + warnSpy.mockRestore(); + }); + it('conformant schemaVersion-8 protocols are left untouched', async () => { // Start from a fully-migrated v8 protocol so it already satisfies the strict // schema; the migration must skip it (no re-write, no hash churn). diff --git a/scripts/migrate-protocols-to-v8.ts b/scripts/migrate-protocols-to-v8.ts index ed2e758af..a3bb80a80 100644 --- a/scripts/migrate-protocols-to-v8.ts +++ b/scripts/migrate-protocols-to-v8.ts @@ -147,13 +147,84 @@ async function migrateOneProtocol( ); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Copy the original `validation.required` state (including its absence) back + * onto migrated variables. The v7→v8 migration adds `required: true` to any + * variable carrying a min* rule without an explicit `required`, which is a + * v8-legal authored configuration that must survive re-normalization. + */ +function restoreRequiredFlags(origVars: unknown, candVars: unknown): void { + if (!isRecord(origVars) || !isRecord(candVars)) return; + + for (const [varId, candVar] of Object.entries(candVars)) { + const origVar = origVars[varId]; + if (!isRecord(origVar) || !isRecord(candVar)) continue; + + const origValidation = origVar.validation; + const candValidation = candVar.validation; + if (!isRecord(origValidation) || !isRecord(candValidation)) continue; + + if ('required' in origValidation) { + candValidation.required = origValidation.required; + } else { + delete candValidation.required; + } + } +} + +/** + * Re-running the v7→v8 migration on a body that is already v8-authored is not + * idempotent: it unconditionally resets every node type's `shape` config to + * `{ default: 'circle' }` and adds `required: true` next to min* validators. + * On a mislabelled-v8 row those are authored v8 values, not legacy shapes, so + * copy them back from the stored codebook. Original values are only restored + * where they are structurally plausible (object shape configs, existing + * validation objects); the caller re-parses the result against the strict + * schema before persisting, so a bad restoration can never be written. + */ +function restoreAuthoredV8State( + origCodebook: Record, + candCodebook: Record, +): void { + for (const entity of ['node', 'edge'] as const) { + const origEntity = origCodebook[entity]; + const candEntity = candCodebook[entity]; + if (!isRecord(origEntity) || !isRecord(candEntity)) continue; + + for (const [typeId, candType] of Object.entries(candEntity)) { + const origType = origEntity[typeId]; + if (!isRecord(origType) || !isRecord(candType)) continue; + + if (entity === 'node' && isRecord(origType.shape)) { + candType.shape = origType.shape; + } + + restoreRequiredFlags(origType.variables, candType.variables); + } + } + + const origEgo = origCodebook.ego; + const candEgo = candCodebook.ego; + if (isRecord(origEgo) && isRecord(candEgo)) { + restoreRequiredFlags(origEgo.variables, candEgo.variables); + } +} + /** - * Normalize a protocol stored as schemaVersion 8 whose body still carries - * pre-v8 field shapes (e.g. object-form `automaticLayout`), which fail the - * strict read-time schema. Re-running the v7→v8 migration rewrites those - * shapes; the protocol's existing v8-only `experiments` are preserved rather - * than reset to the migration default. Throws if the content is genuinely - * invalid and cannot be migrated. + * Normalize a protocol stored as schemaVersion 8 whose body fails the strict + * read-time schema — either because it still carries pre-v8 field shapes + * (e.g. object-form `automaticLayout`) or because it was imported under a + * laxer historical validator (e.g. out-of-palette OrdinalBin colors or an + * orphaned CategoricalBin `otherOptionLabel`, both tightened in + * protocol-validation 11.9.0). Re-running the v7→v8 migration rewrites those + * shapes; authored v8-only state the re-migration would destroy (node shape + * configs, optional-field semantics, `experiments`) is preserved from the + * stored row. Throws if the content is genuinely invalid and cannot be + * migrated. */ async function normalizeMislabelledV8( prisma: Prisma.TransactionClient, @@ -161,6 +232,11 @@ async function normalizeMislabelledV8( ): Promise { const cleanName = row.name.replace(/\.netcanvas$/i, ''); + // migrateProtocol shares nested objects with its input and mutates some of + // them in place (e.g. setting `required` on a variable's validation), so + // snapshot the stored codebook before migrating to keep a true original. + const origCodebook: unknown = structuredClone(row.codebook); + const asV7 = { name: cleanName, schemaVersion: 7, @@ -171,17 +247,46 @@ async function normalizeMislabelledV8( const migrated = migrateProtocol(asV7, 8, { name: cleanName }); + // Restore authored v8 state onto a clone of the migration output, then keep + // the restored body only if it still satisfies the strict schema. + let finalProtocol: Pick = migrated; + const candidate: unknown = structuredClone(migrated); + if ( + isRecord(candidate) && + isRecord(candidate.codebook) && + isRecord(origCodebook) + ) { + restoreAuthoredV8State(origCodebook, candidate.codebook); + + const reparsed = CurrentProtocolSchema.safeParse({ + name: cleanName, + schemaVersion: 8, + stages: candidate.stages, + codebook: candidate.codebook, + experiments: row.experiments ?? {}, + }); + + if (reparsed.success) { + finalProtocol = reparsed.data; + } else { + console.warn( + `Could not preserve authored v8 state while normalizing "${row.name}" ` + + `(id=${row.id}); persisting the plain re-migration output instead.`, + ); + } + } + // The hash is derived from stages + codebook only, so re-normalizing to v8 // gives the same hash the import flow would now compute for this protocol. - const newHash = hashProtocol(migrated); + const newHash = hashProtocol({ ...migrated, ...finalProtocol }); await writeMigratedProtocol( prisma, row, { schemaVersion: 8, - stages: migrated.stages as Prisma.InputJsonValue, - codebook: migrated.codebook, + stages: finalProtocol.stages as Prisma.InputJsonValue, + codebook: finalProtocol.codebook, // Preserve the protocol's existing v8-only experiments; a v7→v8 migration // has no knowledge of them and would otherwise reset them to its default. experiments: row.experiments ?? Prisma.JsonNull, From 878b22225cc4b42120f37ebe2cc09d23b5bc1a15 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 15 Jul 2026 20:23:22 +0200 Subject: [PATCH 2/2] fix(scripts): address protocol normalization review feedback - Reconstruct the asset manifest in both isConformantV8 and the restored-body re-parse: the whole-protocol schema cross-references roster/geospatial dataSource ids against the manifest, so without it every asset-referencing protocol failed conformance, re-normalized on each deploy, and lost its authored state to the fallback path. - Only remove the migration-added required:true for minValue-only variables. Fresco's legacy form engine failed empty values for minLength/minSelected (effectively required) but passed them for minValue, so this preserves the validation behaviour participants actually observed. Explicit required values are restored verbatim. --- .../__tests__/migrate-protocols-to-v8.test.ts | 174 ++++++++++++++++-- scripts/migrate-protocols-to-v8.ts | 27 ++- 2 files changed, 181 insertions(+), 20 deletions(-) diff --git a/scripts/__tests__/migrate-protocols-to-v8.test.ts b/scripts/__tests__/migrate-protocols-to-v8.test.ts index 5bfd5a08f..53e6bd8a4 100644 --- a/scripts/__tests__/migrate-protocols-to-v8.test.ts +++ b/scripts/__tests__/migrate-protocols-to-v8.test.ts @@ -257,23 +257,42 @@ describe('migrateProtocolsToV8', () => { expect(call.data.codebook.node.person).not.toHaveProperty('iconVariant'); }); - it('preserves authored v8 state (node shape, optional min-validated fields) when normalizing', async () => { + it('preserves authored v8 state (node shape, required semantics) when normalizing', async () => { // A mislabelled-v8 body: legacy pre-v8 field shapes (iconVariant, Toggle // options) force re-normalization, but the row ALSO carries authored v8 - // state the v7→v8 migration would destroy — a custom node shape config - // and a min-validated variable without an explicit `required`. Since - // protocol-validation 11.9.0 tightened CurrentProtocolSchema, rows like - // this can newly fail conformance, and normalization must not corrupt the - // authored values. + // state the v7→v8 migration would destroy. Since protocol-validation + // 11.9.0 tightened CurrentProtocolSchema, rows like this can newly fail + // conformance, and normalization must not corrupt the authored values. + // + // Required-flag semantics mirror what Fresco's legacy form engine + // actually did: minLength/minSelected failed empty values (so the + // migration's required:true preserves observed behaviour and must stay), + // while minValue passed empty values (so a min-valued variable without + // `required` was optional and must remain so). Explicit `required` values + // are always restored. const mixed = makeV7Protocol(); const personNode = mixed.codebook.node.person as Record; personNode.shape = { default: 'square' }; - (personNode.variables as Record).nickname = { - name: 'nickname', - type: 'text', - component: 'Text', - validation: { minLength: 2 }, - }; + Object.assign(personNode.variables as Record, { + nickname: { + name: 'nickname', + type: 'text', + component: 'Text', + validation: { minLength: 2 }, + }, + age: { + name: 'age', + type: 'number', + component: 'Number', + validation: { minValue: 1 }, + }, + bio: { + name: 'bio', + type: 'text', + component: 'TextArea', + validation: { minLength: 5, required: false }, + }, + }); const prisma = makeMockPrisma(); prisma.protocol.findMany.mockResolvedValue([ @@ -321,9 +340,134 @@ describe('migrateProtocolsToV8', () => { // ...but the authored shape config survives instead of being reset to // { default: 'circle' }... expect(person.shape).toEqual({ default: 'square' }); - // ...and the min-validated variable stays optional instead of gaining - // required: true. - expect(person.variables.nickname?.validation).toEqual({ minLength: 2 }); + // ...minLength keeps the migration's required:true (legacy engine failed + // empty values, so the field was effectively required)... + expect(person.variables.nickname?.validation).toEqual({ + minLength: 2, + required: true, + }); + // ...minValue-only stays optional (legacy engine passed empty values)... + expect(person.variables.age?.validation).toEqual({ minValue: 1 }); + // ...and an explicit required value is restored verbatim. + expect(person.variables.bio?.validation).toEqual({ + minLength: 5, + required: false, + }); + }); + + it('normalizes asset-referencing protocols without discarding authored state', async () => { + // The whole-protocol schema cross-references roster/geospatial dataSource + // ids against the asset manifest. Both the conformance check and the + // restored-body re-parse must reconstruct the manifest from the Asset + // rows, or every asset-referencing protocol would re-normalize on each + // deploy and lose its authored state to the fallback path. + const mixed = makeV7Protocol(); + const personNode = mixed.codebook.node.person as Record; + personNode.shape = { default: 'square' }; + mixed.stages.push({ + id: 'stage-roster', + type: 'NameGeneratorRoster', + label: 'Roster', + subject: { entity: 'node', type: 'person' }, + dataSource: 'asset-roster-1', + prompts: [{ id: 'p1', text: 'Who?' }], + } as unknown as (typeof mixed.stages)[number]); + + const prisma = makeMockPrisma(); + prisma.protocol.findMany.mockResolvedValue([ + { + id: 'cm-asset-v8', + assets: [ + { + assetId: 'asset-roster-1', + name: 'roster.csv', + type: 'network', + value: null, + }, + ], + name: 'AssetProtocol.netcanvas', + schemaVersion: 8, + stages: mixed.stages, + codebook: mixed.codebook, + experiments: null, + description: mixed.description, + lastModified: new Date(mixed.lastModified), + }, + ]); + + await migrateProtocolsToV8( + prisma as unknown as Parameters[0], + ); + + expect(prisma.protocol.update).toHaveBeenCalledTimes(1); + + type UpdateCallArg = { + data: { + codebook: { node: { person: { shape: Record } } }; + }; + }; + const call = prisma.protocol.update.mock.calls[0]?.[0] as UpdateCallArg; + // Restoration succeeded (no fallback to the shape-resetting plain output) + // even though the protocol references a stored asset. + expect(call.data.codebook.node.person.shape).toEqual({ + default: 'square', + }); + }); + + it('skips conformant asset-referencing protocols instead of re-normalizing them each deploy', async () => { + const v7 = makeV7Protocol(); + v7.stages.push({ + id: 'stage-roster', + type: 'NameGeneratorRoster', + label: 'Roster', + subject: { entity: 'node', type: 'person' }, + dataSource: 'asset-roster-1', + prompts: [{ id: 'p1', text: 'Who?' }], + } as unknown as (typeof v7.stages)[number]); + const conformant = migrateProtocol( + { + ...v7, + name: 'CleanAssets', + assetManifest: { + 'asset-roster-1': { + id: 'asset-roster-1', + name: 'roster.csv', + type: 'network', + source: 'roster.csv', + }, + }, + }, + 8, + { name: 'CleanAssets' }, + ); + + const prisma = makeMockPrisma(); + prisma.protocol.findMany.mockResolvedValue([ + { + id: 'cm-clean-assets', + assets: [ + { + assetId: 'asset-roster-1', + name: 'roster.csv', + type: 'network', + value: null, + }, + ], + name: 'CleanAssets.netcanvas', + schemaVersion: 8, + stages: conformant.stages, + codebook: conformant.codebook, + experiments: conformant.experiments ?? null, + description: null, + lastModified: new Date('2024-01-01T00:00:00.000Z'), + }, + ]); + + await migrateProtocolsToV8( + prisma as unknown as Parameters[0], + ); + + expect(prisma.protocol.update).not.toHaveBeenCalled(); }); it('falls back to the plain re-migration output when authored state cannot be restored', async () => { diff --git a/scripts/migrate-protocols-to-v8.ts b/scripts/migrate-protocols-to-v8.ts index a3bb80a80..5d3473e8d 100644 --- a/scripts/migrate-protocols-to-v8.ts +++ b/scripts/migrate-protocols-to-v8.ts @@ -69,6 +69,11 @@ function isConformantV8(row: ProtocolRow): boolean { stages: row.stages, codebook: row.codebook, experiments: row.experiments ?? {}, + // The whole-protocol schema cross-references stage asset ids (roster, + // geospatial) against the manifest, so it must be reconstructed here or + // every asset-referencing protocol would fail and be re-normalized on + // each deploy. + assetManifest: buildAssetManifest(row.assets), }).success; } @@ -152,10 +157,16 @@ function isRecord(value: unknown): value is Record { } /** - * Copy the original `validation.required` state (including its absence) back - * onto migrated variables. The v7→v8 migration adds `required: true` to any - * variable carrying a min* rule without an explicit `required`, which is a - * v8-legal authored configuration that must survive re-normalization. + * Copy the original `validation.required` state back onto migrated variables. + * The v7→v8 migration adds `required: true` to any variable carrying a min* + * rule without an explicit `required`, because pre-v8 runtimes coupled the + * two. Fresco's legacy form engine only actually coupled `minLength` and + * `minSelected` (an empty value failed those validators); `minValue` passed + * empty values, so a min-valued variable without `required` behaved as + * optional. Restore the stored state exactly where it matches the behaviour + * participants observed: explicit `required` values are always restored, and + * the migration-added `required: true` is removed only when the variable's + * min validators are limited to `minValue`. */ function restoreRequiredFlags(origVars: unknown, candVars: unknown): void { if (!isRecord(origVars) || !isRecord(candVars)) return; @@ -170,7 +181,10 @@ function restoreRequiredFlags(origVars: unknown, candVars: unknown): void { if ('required' in origValidation) { candValidation.required = origValidation.required; - } else { + } else if ( + !('minLength' in origValidation) && + !('minSelected' in origValidation) + ) { delete candValidation.required; } } @@ -264,6 +278,9 @@ async function normalizeMislabelledV8( stages: candidate.stages, codebook: candidate.codebook, experiments: row.experiments ?? {}, + // Required for asset-referencing stages; without it the restored body + // would always fail this parse and needlessly discard authored state. + assetManifest: buildAssetManifest(row.assets), }); if (reparsed.success) {