upgrading/app/upgrades/0.16-to-0.17/instructions.md
upgrading/app/upgrades/0.16-to-0.17/instructions.mdBrowse 76 files
55,482 bytes
Token encoding: o200k_base
Snapshot fac8604
from: "0.16" to: "0.17" changes:
-
id: one-prisma-package-per-application summary: | Do this first — nothing else in this upgrade can run until the project installs. From 0.17 the
@prisma-next/*scope is retired: nothing publishes under it again, so a manifest that still names it fails at install. The public surface is the@prismascope, and an application depends on exactly ONE database facade —@prisma/orm-postgres,@prisma/orm-sqlite, or@prisma/orm-mongo— plus any extension packs it uses, which remain direct dependencies. Everything else that used to be a separate dependency (framework, family, target, adapter, driver, CLI) arrives transitively as the facade's exact-pinned dependencies. Inpackage.json: delete EVERY@prisma-next/*entry across dependencies/devDependencies/peerDependencies, add the one facade for your database, and keep your extension packs — renamed (@prisma-next/extension-<x>→@prisma/orm-extension-<x>; the middleware cache is@prisma/orm-extension-middleware-cache). Drop theprisma-nextdevDependency if you have one — the facade provides theprisma-nextbin; the standaloneprisma-nextpackage remains only as the bootstrap path for projects with no Prisma dependencies yet. Reinstall, then regenerate your contract artefacts (prisma-next contract emit): generated files now import facade entrypoints (e.g.@prisma/orm-postgres/components) instead of old-scope package names. ThecontractHashis unchanged by regeneration —prisma-next db verifypasses with no database work. Finally rewrite hand-written imports: same-package entrypoints keep their subpath under the facade (@prisma-next/postgres/config→@prisma/orm-postgres/config, likewise/runtime,/target,/family,/migration,/control, …); programmatic tooling imports (@prisma-next/cli/*,@prisma-next/config-loader,@prisma-next/migration-tools/*,@prisma-next/emitter) move to the matching@prisma/orm-toolchain/*subpath (@prisma/orm-toolchain/cli/config-types,/cli/control-api,/config-loader,/migration-tools/<subpath>,/emitter). The rule for every rewrite: import only from packages your manifest names directly — the facade, your extension packs, and (for tooling authors)@prisma/orm-toolchain. detection: glob: "**/*.{json,ts,tsx,mts,cts,js,mjs,cjs}" contains: - '@prisma-next/' anyMatch: true -
id: strip-sha256-hash-prefixes summary: | Content hashes are bare lowercase hex from 0.17 — the
sha256:prefix is gone from every surface (emittedcontract.json/contract.d.ts, migration manifests, refs, CLI output, and the database marker/ledger), and loaders reject the legacy prefixed form. Contract hash VALUES are unchanged (only the prefix drops;pnpm emitregenerates live artefacts), butmigrationHashVALUES change because the hashed manifest bytes embed the now-barefrom/tostrings. Run the colocated codemod over your checked-inmigrations/trees FIRST, before the snapshot-layout migrator in the entries below — the 0.17 layout migrator accepts only bare-hex trees. The codemod handles both layouts: it strips the prefix from every hash literal (manifests,ops.json, pre-store sibling contract snapshots, store entries undermigrations/snapshots/,.d.tsbranded literals), maps the empty-tree sentinelsha256:emptytoempty, recomputes eachmigrationHash, and repointsrefs/*.json. Store directory names are the hash's hex, which does not change. Databases whose marker/ledger still hold prefixed values report a hash mismatch on verify — there is no compatibility shim; re-sign against the regenerated contract (prisma-next db sign). detection: glob: "**/*.{json,ts,tsx}" contains: - 'sha256:' anyMatch: true script: ./strip-sha256-hash-prefixes.ts -
id: migration-contract-snapshots-moved-to-content-addressed-store summary: | Committed migration contract snapshots move from per-package sibling files (
start-contract.json/start-contract.d.ts/end-contract.json/end-contract.d.ts) and per-space head copies (migrations/<space-id>/contract.json/contract.d.ts) into a single content-addressed store per migrations root, atmigrations/snapshots/<hex>/contract.json+contract.d.ts, where<hex>is the contract's 64-hex storage hash (bare hex after thestrip-sha256-hash-prefixesentry above, which must run first). Every distinct contract is stored once, however many migrations reference it. Every emittedmigration.tsnow imports its bookend contracts from the store (../../snapshots/<hex>/contract.json,../../snapshots/<hex>/contract.d.ts) instead of from sibling files in its own directory. This is a clean break: there is no fallback reader for the old sibling-file layout, so a committed migrations tree that has not been converted fails to load once you upgrade —migration plan/migration new/migrate/migration checkall read contract snapshots through the store only, and a missing store entry fails withMIGRATION.CONTRACT_SNAPSHOT_MISSINGnaming the expected hash and path.migration.json/ops.json/migrationHashare unaffected — the contract snapshot was never part of migration identity, so converting the layout changes no migration's hash. To convert an existing project, run the migrator once per migrations root from a checkout of theprisma/prismarepository at (or above) the version you're upgrading to:node scripts/migrate-migrations-layout.mjs [migrationsRoot...](with no arguments it auto-discovers every migrations root under the current directory). Per migration package, it readsmigration.json, write-if-absents the destination contract (and the source contract, when present) into the store under the matching hash, rewrites the committedmigration.tsimport specifiers, and deletes the four sibling files. Per contract space, it store-writes any remaining per-spacecontract.json/contract.d.tskeyed by that space'srefs/head.jsonhash, then deletes it. It asserts every contract's innerstorage.storageHashagainst the hash it's filed under before writing anything (mismatch aborts the whole run, nothing is deleted), and re-verifies everymigrationHashis unchanged after conversion. Run it, review the diff, thenpnpm typecheck(or your project's equivalent) to confirm every rewrittenmigration.tsimport resolves. detection: glob: "**/migration.ts" contains: - "./start-contract.json" - "./end-contract.json" - "./start-contract'" - "./end-contract'" anyMatch: true -
id: ref-paired-snapshots-moved-to-content-addressed-store summary: | Ref-paired contract snapshot files (
refs/<name>.contract.json/refs/<name>.contract.d.ts, written byref setand--advance-ref) are no longer written or read. A ref is now only its pointer file,refs/<name>.json({ hash, invariants }); the contract it names resolves through the same content-addressed store as every migration graph node,migrations/snapshots/<hex>/contract.json+contract.d.ts, by that hash. This is a clean break: a pointer whose store entry is missing now fails withMIGRATION.CONTRACT_SNAPSHOT_MISSINGnaming the expected hash and path, rather than silently falling back to the migration graph. The same one-shot migrator that folds per-package and per-space sibling snapshots (see the entry above) also folds any existingrefs/<name>.contract.json/refs/<name>.contract.d.tspairs: it write-if-absents the pair into the store under the sibling pointer'shash, then deletes the pair — the pointer file itself is read but never written, so it stays byte-identical. A.contract.jsonwith no sibling pointer, or whose innerstorage.storageHashdisagrees with the pointer'shash, aborts the whole run before anything is written or deleted. Runnode scripts/migrate-migrations-layout.mjs [migrationsRoot...](same invocation as above; one run folds both migration-package and ref-paired snapshots), review the diff, then re-runprisma-next ref listto confirm your refs are unaffected. detection: glob: "**/refs/*.contract.json" anyMatch: true -
id: extension-packs-config-key-renamed-to-extensions summary: | The
extensionPackskey is renamed toextensionseverywhere: the low-leveldefineConfiginprisma.config.ts, the TS builder'sdefineContract(record form), runtime/control client options, and the top-level key of the emittedcontract.json/contract.d.ts. The old config key now fails loudly with "Config.extensionPacks is no longer supported; rename it to Config.extensions" — it is never silently ignored. Rename the key inprisma.config.ts(andcontract.ts/db.tsif they passextensionPacksto client factories). The target façades'defineConfigalready usedextensions; only projects on the low-level config change. Because the key sits in the canonicalized bytes of every contract hash, all three hashes (storageHash,executionHash,profileHash) change for every contract: re-emit withprisma-next contract emit, then re-anchor migrations — regeneratemigrations/snapshots/<hex>/store entries and refs for the new hashes (a schema-unchanged project needs a hash-advance migration or a re-baseline; the database schema itself does not change). detection: glob: "**/{prisma.config.ts,contract.ts,db.ts}" contains: - "extensionPacks" -
id: contract-source-format-key-renamed summary: | The contract source provider field
sourceFormatis renamed toformat(contract.source.formatin the low-level config; provider objects fromprismaContract()/typescriptContract()emit the new field automatically once upgraded). Rename any literalsourceFormat:in hand-written provider objects or config assertions. detection: glob: "**/prisma.config.ts" contains: - "sourceFormat" -
id: sugar-output-path-key-renamed-to-output summary: | The target façades'
defineConfigoptionoutputPathis renamed tooutput. Semantics are unchanged (a directory;contract.jsonis written inside it). Rename the key inprisma.config.ts. detection: glob: "**/prisma.config.ts" contains: - "outputPath" -
id: orm-count-only-mutation-terminals-renamed summary: Replace
createCount(...),updateCount(...), anddeleteCount()withcreateAndCount(...),updateAndCount(...), anddeleteAndCount()in ORM call sites; arguments, guards, behavior, andPromise<number>results are unchanged, and no compatibility aliases remain. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - ".createCount(" - ".updateCount(" - ".deleteCount(" anyMatch: true -
id: psl-format-error-class-removed summary: | The
PslFormatErrorclass is deleted from@internal/psl-parser.format()on source with parse errors now throws a structured envelope with codePSL.PARSE_FAILED; the diagnostics previously onerror.diagnosticsare aterror.meta.diagnostics. Replaceerror instanceof PslFormatErrorwithisStructuredError(error) && error.code === 'PSL.PARSE_FAILED'(isStructuredErrorfrom@internal/utils/structured-error). The message text is unchanged. detection: glob: "**/*.{ts,mts,cts}" contains: - "PslFormatError" -
id: scalar-type-descriptors-channel-removed summary: | The scalar-type descriptor channel is retired in favour of the unified authoring type namespace. Projects with custom control-stack setups that import
createPostgresScalarTypeDescriptors/createSqliteScalarTypeDescriptors, or that readscalarTypeDescriptorsfrom a control stack or contract-source context, must migrate: those exports are deleted, and scalar types are now zero-arg type-constructor contributions in the component'sauthoring.typenamespace — e.g.String: { kind: 'typeConstructor', output: { codecId: 'pg/text@1', nativeType: 'text' } }. Read the scalar type names viastack.scalarTypes, or the full name ->{ codecId, nativeType }map viacollectScalarTypeConstructors(stack.authoringContributions.type)from@internal/framework-components/authoring. Standard target setups (@internal/postgres,@internal/sqlite) supply the contributions themselves. detection: glob: "**/*.{ts,mts,cts}" contains: - "createPostgresScalarTypeDescriptors" - "createSqliteScalarTypeDescriptors" - "scalarTypeDescriptors" anyMatch: true -
id: postgres-native-types-move-to-type-position summary: | PostgreSQL native storage types are authored directly in PSL type position, and the legacy
@db.*attribute channel is removed. RewriteBaseType @db.TypeasTypeandBaseType @db.Type(args)asType(args)in bothtypes {}aliases and model fields, then re-runprisma-next contract emit. Any remaining@db.X(args)fails with@db.X(args) is no longer supported; use X(args) in type position, preserving the constructor name and arguments in the suggested replacement. The supported translations are@db.Char→Char,@db.VarChar→VarChar,@db.Numeric→Numeric,@db.Uuid→Uuid,@db.Inet→Inet,@db.SmallInt→SmallInt,@db.Real→Real,@db.Timestamp→Timestamp,@db.Timestamptz→Timestamptz,@db.Date→Date,@db.Time→Time, and@db.Timetz→Timetz; preserve constructor arguments. Rewrite the old native-json spellingJson @db.Jsonas bareJson. This source migration preserves native types and supplied type parameters. It also preserves codec ids except for@db.Date→Date, which rebindspg/timestamptz@1topg/date@1, changes the contract storage hash, and requires re-emission plus re-signing; see thepostgres-date-rebound-to-pg-dateentry below. Separately, apply thepostgres-json-rebound-to-native-jsonentry below to old bareJsonfields that meant jsonb storage. detection: glob: "**/*.prisma" contains: - "@db." anyMatch: true -
id: postgres-json-rebound-to-native-json summary: | On the postgres target the PSL
Jsonscalar re-binds frompg/jsonb@1/jsonbtopg/json@1/json; a new bareJsonbscalar carriespg/jsonb@1/jsonb. Postgres schemas that useJsonand mean jsonb storage (which every pre-0.16Jsonfield did) must switch those fields — andtypes {}aliases — toJsonb, then re-runprisma-next contract emit; withJsonbthe emittedcontract.jsonis byte-identical to the pre-0.16 output. A field left asJsonnow emits a nativejsoncolumn and a new storage hash, which against an existing jsonb database is a schema change. The removed@db.Jsonspelling must be rewritten fromJson @db.Jsonto bareJson; any remaining use fails with migration guidance to useJsonin type position. SQLite and MongoJsonbindings are untouched. The TS builder surface (field.json(),jsonbColumn) is unchanged and stays jsonb. detection: glob: "**/*.prisma" contains: - "Json" anyMatch: true -
id: default-generators-no-longer-set-storage summary: |
@default(<generator>)no longer influences a column's storage — the type position is the only storage decider. Pre-0.16, a generator default on a bareStringfield re-picked the column's storage to a sized char:String @default(uuid())/@default(uuid(7))emittedsql/char@1/character(36),@default(cuid(2))character(24),@default(nanoid())character(21)(orcharacter(<size>)fornanoid(<size>)), and@default(ulid())character(26). From 0.16 such fields emit the target'sStringstorage (postgres:pg/text@1/text) with the same execution-time generator, so a re-emit produces a new storage hash — against an existing database created with the char storage this is a schema change. To keep the prior storage byte-identical, name it in the type position:Char(36) @default(uuid()),Char(24) @default(cuid(2)),Char(21) @default(nanoid())(orChar(<size>)for a sized nanoid),Char(26) @default(ulid())— or adopt nativeUuidforuuid()if auuid-typed column is preferred (that is a schema change too). Then re-runprisma-next contract emitand, if you accepted a storage change, plan/apply the matching migration. Generator applicability validation is unchanged (uuid()onIntstill fails withPSL_INVALID_DEFAULT_APPLICABILITY), and the TS builder presets (field.id.uuidv4String(),field.generated(uuidv4()), …) are untouched — they bundle theirchar(N)storage explicitly. detection: glob: "**/*.prisma" contains: - "@default(uuid(" - "@default(cuid(" - "@default(nanoid(" - "@default(ulid(" anyMatch: true -
id: postgres-date-rebound-to-pg-date summary: | On the postgres target, the bare
Datetype constructor re-binds frompg/timestamptz@1to the dedicatedpg/date@1codec. Rewrite the removedDateTime @db.Datespelling asDate; leaving it unchanged now fails with migration guidance to useDatein type position. The stored native type is unchanged (date), so no schema migration is needed, but a re-emit changes the column's codec ref and therefore the contract's storage hash: runprisma-next contract emit, then re-sign any signed database against the regenerated contract (prisma-next db sign) — verify reports a hash mismatch until you do. Contracts emitted before the upgrade keep working (pg/timestamptz@1still exists). Runtime behavior changes on re-emit: date columns decode as aDateat UTC midnight (previously the driver's local-midnightDatepassed through, so the instant depended on the process timezone — code reading local getters near midnight in negative-UTC-offset zones saw the neighboring day), encode formatsYYYY-MM-DDfrom UTC getters, and relation.include()over a date column now decodes instead of failing withRUNTIME.DECODE_FAILED. Update tests or application code that pinned the old local-midnight instants to expectnew Date(Date.UTC(y, m, d)). detection: glob: "**/*.prisma" regex: - '@db.Date' - '\sDate(\s|?|[|$)' anyMatch: true -
id: sql-escape-error-class-removed summary: | The
SqlEscapeErrorclass is deleted from@internal/target-postgresand@internal/target-sqlite(including its re-export from the postgres/sqlite adaptercontrolentrypoints). Identifier/literal escaping failures now throw a structured envelope with codeCONTRACT.IDENTIFIER_INVALID. Replaceerror instanceof SqlEscapeErrorwithisStructuredError(error) && error.code === 'CONTRACT.IDENTIFIER_INVALID'(isStructuredErrorfrom@internal/utils/structured-error). Message text is unchanged. detection: glob: "**/*.{ts,mts,cts}" contains: - "SqlEscapeError" -
id: supabase-error-classes-removed summary: | The
SupabaseConfigErrorandInvalidJwtErrorclasses are deleted from@internal/extension-supabase/runtime. The same failures now throw structured envelopes with codesSUPABASE.CONFIG_INVALIDandSUPABASE.JWT_INVALID. Replaceerror instanceof SupabaseConfigErrorwithisStructuredError(error) && error.code === 'SUPABASE.CONFIG_INVALID'anderror instanceof InvalidJwtErrorwithisStructuredError(error) && error.code === 'SUPABASE.JWT_INVALID'(isStructuredErrorfrom@internal/utils/structured-error). Message text is unchanged. detection: glob: "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" contains: - "SupabaseConfigError" - "InvalidJwtError" anyMatch: true -
id: indexes-are-name-identified summary: | Secondary indexes are name-identified from 0.17. Every index entry in the emitted
contract.json/contract.d.tsnow carriesname(the full physical name) andunique, plusprefixwhen the name is toolchain-owned;columnsbecame optional (an index carries eithercolumnsor an opaqueexpression— never both). Contracts emitted by 0.16 fail validation on load (the error message contains "indexes[0].name must be a string (was missing)"), and storage hashes move for every contract that declares indexes — re-emit withprisma-next contract emit. Physical names change for wire-named indexes: an unnamed PSL@@index([a, b])/ TSconstraints.index([a, b])and every FK-backing index now CREATE as<default-prefix>_<8hex>content-hash wire names (e.g.user_email_idx_46df9cad), and a TSconstraints.index([...], { name: "x" })name is now a wire prefix — the physical name becomesx_<8hex>. PSL@@index([...], map: "x")is now an exact physical name whose identity is verified against the live catalog. Existing databases converge without rebuilds: after re-emitting, the first plan that allows thewideningclass (db update, ormigration plan+migrate) isALTER INDEX … RENAME TOops only — renames happen only when a widening plan runs FIRST. Under an additive-only policy the rename pairing does not run: the new wire-named index is created beside the old one, and once both exist a later plan can no longer pair them — the old index is removed only by a destructive-allowed plan dropping it. Update any code or tests that hard-code the old physical index names. detection: glob: "**/*.{prisma,ts,json}" contains: - "@@index" - "constraints.index" - '"indexes":' anyMatch: true -
id: rls-policy-migration-literal-carries-the-naming-union summary: | A generated migration's
this.createRlsPolicy({ policy: … })literal spells the policy's name differently from 0.17. The flatname/prefixpair is replaced by a singlenamingfield carrying one of two shapes:{ kind: "exact", name: "<physical name>" }for a policy whose name the author owns, or{ kind: "wire", prefix: "<prefix>", hash: "<8hex>" }for a toolchain-named one (0.16'sname: "<prefix>_<8hex>"plusprefix: "<prefix>"). Every other key is unchanged. A migration file emitted by 0.16 that callscreateRlsPolicytherefore stops compiling — TypeScript reportsProperty 'naming' is missing. Regenerate the affected migrations withprisma-next migration plan, or edit the literal by hand: the shape is mechanical, and the migration's identity (migrationHash, the SQL it executes) does not depend on the literal's spelling. detection: glob: "/migrations//migration.ts" contains: - "createRlsPolicy" -
id: framework-error-classes-removed summary: | Three exported framework error classes are deleted:
ConfigFileNotFoundError(from@internal/config-loader),ConfigValidationError(from@internal/config/config-validation), andDomainNamespaceResolutionError(from@internal/contract/types). The same failures now throw structured envelopes with codesCONFIG.FILE_NOT_FOUND,CONFIG.VALIDATION_FAILED, andCONTRACT.NAMESPACE_INVALIDrespectively. Replace eacherror instanceof <Class>withisStructuredError(error) && error.code === '<CODE>'(isStructuredErrorfrom@internal/utils/structured-error). Message text is unchanged. detection: glob: "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" contains: - "ConfigFileNotFoundError" - "ConfigValidationError" - "DomainNamespaceResolutionError" anyMatch: true -
id: pg-int8-application-values-are-bigint summary: |
pg/int8@1carriesbigintapplication values where it carriednumber. A JSnumbercannot represent the whole signed 64-bit range, so any value past 2^53 was already being silently rounded. Every read of anint8column now yields abigint, and every value compared against one must be abigintliteral.count()is the widest instance: it resolves topg/int8@1, so a counted column's row type isbigintand ahavingcomparison readsfns.gt(fns.count(), 5n). Update row-type annotations, comparison literals, and any arithmetic that mixes a counted value with anumber— TypeScript will not implicitly convert between the two, sopnpm typechecklocates every site. A contract'sint8literal defaults are also emitted as decimal strings rather than JSON numbers; re-emit to pick that up. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "fns.count(" - "pg/int8@1" - "int8Column" anyMatch: true -
id: pg-interval-values-are-structured-durations summary: | Reading a
pg/interval@1column returns{ months, days, micros }— the three fields PostgreSQL stores — where it returned aJSON.stringifyof the driver's component object such as{"days":1}.monthsanddaysarenumber;microsisbigint, because PostgreSQL stores it as a 64-bit integer. Writing takes the same object. Replace any parsing of the old string with field access, and replace interval literals with the object ({ months: 0, days: 1, micros: 0n }for one day). The three fields stay independent because a month has no fixed length: one month and thirty days are different values and neither converts to the other. Serialized form is unchanged in kind but not in spelling — a contract holds the ISO-8601 duration string (P1M,P1Y2M3DT4H5M6S,PT0S), so re-emit;microspast microsecond resolution rounds as PostgreSQL rounds. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "pg/interval@1" - "intervalColumn" anyMatch: true -
id: codec-json-forms-are-canonical summary: | Several codecs' JSON representation changed so a value survives the round trip through a contract.
pg/numeric@1andsqlite/bigint@1are decimal text where they were JSON numbers —9007199254740993reached JSON as…992before, and arbitrary-precision decimals lost their tail.pg/bytea@1is base64 where it was PostgreSQL's\x-prefixed hex.sqlite/blob@1is uppercase hexadecimal where it was base64.sqlite/bigint@1additionally accepts values it previously refused outright: half of SQLite's INTEGER range had no JSON representation at all. This now reaches reads, not only contract literals. A query that returns JSON — an.include()'s nested rows, an aggregated child row set — projects each column through its codec, so these codecs' values arrive in the forms above where they previously arrived in whatever the database's own JSON conversion produced. Nine codecs project non-identically:pg/numeric@1,pg/int8@1,pg/bytea@1,pg/interval@1,pg/timestamptz@1,pg/vector@1,sqlite/bigint@1,sqlite/blob@1andsqlite/json@1. If you read such a column out of an include and parse or compare its raw JSON yourself — rather than letting the ORM decode it — update that code to the new form. Runprisma-next contract emitto regeneratecontract.json/contract.d.ts; any literal default on one of these codecs changes spelling, and with it thestorageHash. Code that reads such a default out of a contract, or that hand-writes one, must use the new form. detection: glob: "**/*.{ts,tsx,mts,cts,json,d.ts}" contains: - "pg/numeric@1" - "pg/bytea@1" - "pg/int8@1" - "pg/interval@1" - "pg/timestamptz@1" - "pg/vector@1" - "sqlite/bigint@1" - "sqlite/blob@1" - "sqlite/json@1" anyMatch: true -
id: float-json-requires-extra-float-digits-at-least-one summary: | The canonical JSON of
pg/float4@1,pg/float8@1,pg/float@1andpg/vector@1holds only where the PostgreSQL session'sextra_float_digitsis 1 or above. That is the default from PostgreSQL 12 onward, so most deployments already satisfy it — but a connection that sets the GUC to 0 or below reverts to a fixed digit count and truncates:1/3reads back as0.333333333333333rather than0.3333333333333333, and the value no longer round-trips. Check any connection string, pooloptions, server config or proxy that setsextra_float_digitsand remove settings of 0 or below. detection: glob: "**/*.{ts,tsx,mts,cts,js,mjs,cjs,json,toml,yaml,yml,env}" contains: - "extra_float_digits" anyMatch: true -
id: sqlite-real-rejects-non-finite-values summary: |
sqlite/real@1rejects infinities andNaNon both the encode and decode sides. JSON has no spelling for either, and SQLite renders an infinity as9.0e+999, which reads back asInfinityrather than failing — so a non-finite value used to pass through and corrupt the value silently. Guard any computation that can produce a non-finite float before writing it to aREALcolumn, or store it in a column whose codec admits it. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "sqlite/real@1" - "realColumn" anyMatch: true -
id: pg-timestamptz-json-is-utc-iso summary: |
pg/timestamptz@1's canonical JSON is a UTC ISO-8601 timestamp with an explicit+00:00offset, constructed by the projection rather than inherited from the session. The form previously followed the connection'sDateStyleandTimeZone, so the same stored instant read back differently on two connections, and under a non-ISODateStylecould fail to parse at all. Nothing to change if you decode through the ORM. If you read a timestamptz out of database-produced JSON yourself it is now alwaysYYYY-MM-DDTHH:MM:SS.mmm+00:00: drop any session-dependent parsing, and drop anySET DateStyle/SET TimeZoneyou added to stabilise it. detection: glob: "**/*.{ts,tsx,mts,cts,sql}" contains: - "pg/timestamptz@1" - "timestamptzColumn" - "DateStyle" anyMatch: true -
id: sqlite-json-documents-survive-nesting summary: | A
sqlite/json@1column read through a nested.include()arrives as a parsed document where it previously arrived as a string containing JSON. SQLite carries "this text is JSON" as a subtype on the value, and that subtype does not survive a derived table — which every include's child row set passes through — so a document came back double-encoded. The projection retags it at the boundary that consumes it. Asqlite/text@1column whose characters happen to look like JSON is unaffected and still arrives as a string: the retag follows the column's codec, not its content. Remove anyJSON.parseyou added to compensate for the double encoding. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "sqlite/json@1" - "jsonColumn" anyMatch: true -
id: sqlite-blob-null-is-distinct-from-empty summary: | A
NULLsqlite/blob@1column read through database-produced JSON isnull, where it previously became an emptyUint8Array. SQLite'shex(NULL)is the empty string, which is also the hex of a zero-length blob, so absence and emptiness were the same value and nothing raised. If your code distinguishes "no blob" from "empty blob" — and especially if it worked around the old behaviour by treating a zero-length blob as absent — that check now needs to test fornull. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "sqlite/blob@1" - "blobColumn" anyMatch: true -
id: sql-float-rejects-non-finite-values summary: |
sql/float@1rejects infinities andNaNin both JSON directions, matchingsqlite/real@1. ItsdecodeJsonpreviously performed no check at all, and a database can hold a non-finite float and spells it as a string in JSON — PostgreSQL emits"NaN"— so the codec handed back a string typed asnumber, silently. Guard any computation that can produce a non-finite float before writing it to asql/float@1column, or usepg/numeric@1, whose application value is text and which admits all three. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "sql/float@1" - "sqlFloatColumn" anyMatch: true -
id: explicit-codec-refs-need-readable-type-params summary: | A codec ref supplied explicitly —
sql.value(v, { codec: { codecId: 'pg/enum@1' } })and the other surfaces that take a barecodecId— must carrytypeParamsthe codec's schema accepts when that codec is parameterized. Forpg/enum@1that meanstypeParams: { typeName: '<enum type>' }. Such a ref never passes contract validation, so the omission used to surface as a statictextnative type — correct only because PostgreSQL implicitly casts text to an enum, and wrong for any parameterized codec whose type is not text-compatible. It now fails at lowering instead. The failure currently surfaces as a params-validation error rather than a message naming the surface that produced it; that diagnostic is tracked as TML-3114. Add thetypeParamsyour column declares, or drop the explicit codec and let the column's own codec resolve. detection: glob: "**/*.{ts,tsx,mts,cts}" contains: - "codec: { codecId" - "pg/enum@1" anyMatch: true -
id: sql-timestamp-json-is-utc-not-local summary: |
sql/timestamp@1now reads a zone-less timestamp as UTC where it read it in the running process's local zone. This is an interpretation change, not a formatting one, and it is the dangerous half:new Date('2026-01-02T03:04:05')resolves in the local zone, so the same stored value used to decode to a different instant on a machine inEurope/Berlinthan on one inUTC— shifted by the offset, silently. It now resolves as UTC on every machine. If you compensated for the old shift anywhere downstream — adding the offset back, forcingTZ=UTCon the process, normalising after decode — remove that compensation, or it now double-corrects and the instant is wrong by twice the offset. Nothing raises: the value is plausible, just wrong. If you ran withTZ=UTCthere was no shift to compensate for and nothing to change. The JSON form changes with it:encodeJsonemits2026-01-02T03:04:05.678where it emitted2026-01-02T03:04:05.678Z. Atimestampcarries no zone, so the trailingZclaimed one it did not have;decodeJsonnow rejects an offset-bearing string outright rather than reinterpreting it, since this codec cannot reproduce an offset it was handed. Update any hand-written JSON, fixture or comparison that spells the old form.pg/timestamp@1is unaffected — it already read as UTC and already emitted the zone-less form. detection: glob: "**/*.{ts,tsx,mts,cts,json}" contains: - "sql/timestamp@1" - "sqlTimestampColumn" anyMatch: true
0.16 → 0.17 — User upgrade instructions
strip-sha256-hash-prefixes
Starting at the 0.17 release, every content hash Prisma 8 mints or accepts is bare lowercase hex — the sha256: prefix is removed across the board: emitted contract.json / contract.d.ts (including the StorageHashBase<'…'> / ProfileHashBase<'…'> branded type literals), migration manifests, refs, CLI output, and the marker/ledger bookkeeping tables in your database. The prefix carried no information (the algorithm never varied per hash), and the hash value — not an in-band tag — signals a format change. Loaders and validators now reject the legacy prefixed form outright.
Two distinct effects on your checked-in artefacts:
- Contract hashes keep their value.
storageHash/profileHashare computed over contract content, which never embedded its own hash — only the textual prefix drops. - Migration hash values change.
migrationHashis computed over the manifest bytes, which embed thefrom/tocontract-hash strings; with those now bare, every recomputedmigrationHashdiffers from the stored one.
Migrate checked-in migrations/ trees — before the layout migrator
Run the colocated codemod from your project root, before scripts/migrate-migrations-layout.mjs (the snapshot-layout entries below) — the 0.17 layout migrator accepts only bare-hex trees:
pnpm exec tsx ./strip-sha256-hash-prefixes.ts
For every on-disk migration package (a migration.json with a sibling ops.json) it strips the prefix from the manifest's from / to, from hash literals inside ops.json, in pre-store sibling contract snapshots (*-contract.json, *.d.ts, migration.ts), and in content-addressed store entries (migrations/snapshots/<hex>/contract.json + contract.d.ts — the directory name is the hash's hex and does not change), recomputes migrationHash over the bare-hex content, and rewrites refs/*.json — repointing refs that held old migration hashes at the recomputed ones, and mapping the empty-tree sentinel sha256:empty to empty. The edit is format-preserving (only hash literals and the recomputed hash value change) and idempotent: re-running over an already-bare tree makes no further changes.
Use --check for a dry run that lists files still needing the fix and exits non-zero if any remain:
pnpm exec tsx ./strip-sha256-hash-prefixes.ts --check
Re-emit live contract artefacts
Regenerate your emitted artefacts so contract.json / contract.d.ts pick up the bare-hex form:
pnpm emit
# (runs `prisma-next contract emit` under the hood)
The regenerated files differ only in hash representation — the hash values themselves are unchanged.
Update hash literals your own code carries
If your application or tests hard-code hash strings (asserting a migrationHash, comparing a storageHash, matching CLI output), drop the sha256: prefix — and for migration hashes, read the new value from the regenerated manifest, since the value itself changed.
Database marker/ledger
There is no compatibility shim: a database whose marker/ledger rows still hold prefixed values reports a hash mismatch on prisma-next db verify. Re-sign the database against your regenerated contract:
prisma-next db sign
Validation
After the codemod and re-emit, run pnpm typecheck && pnpm test (or your application's equivalent), and exercise any command that loads your migrations (deploy or migration-status step) — the loader recomputes and verifies each manifest's migrationHash on read, so a stale or still-prefixed manifest fails immediately. git grep -n "sha256:" over your project should return no hits in committed artefacts.
Also in this release, the ORM client's internal throw new Error(...) sites
were converted to a structured-error scheme (ORM.* codes via structuredError,
or InternalError for invariants). These are internal throw sites: the errors
are still Error instances with unchanged message text, so application code
that catches them by message or by instanceof Error is unaffected. No action
required beyond the migration contract-snapshot layout change above.
indexes-are-name-identified
Secondary indexes are name-identified: the contract stores every index's full physical name, and schema verification and migration planning pair indexes by that name instead of by column tuple.
What changed in the emitted contract
Each entry in a table's indexes array in contract.json / contract.d.ts now always carries:
name— the full physical name of the index in the database.unique— always present (falsefor everything authored today).prefix— present when the name is toolchain-owned: the physical name is then<prefix>_<8hex>, where the suffix is a content hash of the index definition.columns— now optional; an index carries eithercolumnsor an opaqueexpressionstring, never both.
Newly available in 0.17 (additive — no migration needed): contract infer captures the full index matrix (expression, partial where:, unique non-constraint, type:/options:) and the RLS surface (@@rls, every policy as a policy_<operation> block with @@map and verbatim reprinted bodies, permissive = false for RESTRICTIVE rows), so infer → emit → db verify is zero-issue on databases carrying those objects. Re-running contract infer therefore rewrites contract.prisma with more entries than 0.16 emitted; an index whose live name is wire-shaped (<prefix>_<8hex>, created by this toolchain) and whose hash recomputes now re-infers as wire-named name: instead of exact map: — both spellings verify clean, the wire-named one keeps renames first-class. permissive is an authorable policy-block property (default true; wire names for permissive = true policies are byte-unchanged). Contracts may also now carry two content-identical exact-named (map:) indexes under different names — legal twins a signed database can have. RLS policy blocks (policy_select etc.) accept @@map("physical name") to adopt an existing live policy under its exact name — no wire-name hash, drift detection byte-compares the body against Postgres's reprint (hand-authoring the text warns with PN_EXACT_NAME_BODY_COMPARISON), and replacing @@map with the plain head later converges via a single ALTER POLICY … RENAME. Also newly available: both authoring surfaces accept the full index parameter matrix. PSL @@index and TS constraints.index take expression: (instead of a fields list; requires name: or map:), where: (partial-index predicate), unique:, type:/options: (target-registered access method), and name: xor map:. Combining map: with a SQL body emits the PN_EXACT_NAME_BODY_COMPARISON warning at build time — drift detection byte-compares hand-authored text against Postgres's reprint, so prefer name: unless the text was captured by contract infer. SQLite contracts reject expression:/where: with CONTRACT.ARGUMENT_INVALID (the target does not support them).
A contract emitted by 0.16 fails validation when a 0.17 toolchain loads it — a Contract structural validation failed: storage.namespaces.<ns> … error whose message contains indexes[0].name must be a string (was missing) and indexes[0].unique must be boolean (was missing) — and the storage hash moves for every contract that declares indexes. Re-emit:
prisma-next contract emit
What changed about physical index names
| Authoring input | 0.16 physical name | 0.17 physical name |
|---|---|---|
PSL @@index([a, b]) / TS constraints.index([cols.a, cols.b]) (unnamed) | <table>_<a>_<b>_idx | <table>_<a>_<b>_idx_<8hex> (wire-named) |
| FK-backing index (derived from a relation) | <table>_<col>_idx | <table>_<col>_idx_<8hex> (wire-named) |
TS constraints.index([...], { name: "x" }) | x | x_<8hex> — the name is now a wire prefix |
PSL @@index([...], map: "x") | x | x — an exact physical name, now verified against the live catalog |
The <8hex> suffix is a content hash over the index definition (element list, predicate, uniqueness, access method, options), so an unchanged definition always produces the same name.
Converging an existing database
No index is rebuilt. After re-emitting the contract, the first plan that allows the widening operation class converges the live names with ALTER INDEX … RENAME TO ops only:
prisma-next db update(its default policy includes widening), orprisma-next migration plan --name converge-index-namesfollowed byprisma-next migrate.
Inspect the plan before applying — for a schema whose only drift is the index naming, it contains nothing but renames.
Under an additive-only policy (e.g. db init's class set) the rename pairing is skipped: the plan creates the new wire-named index beside the old one. Once both indexes exist, a later widening plan has nothing left to pair — the new name is already present, and the rename op's own precheck requires its target name to be absent — so after the additive create the old index is removed only by a destructive-allowed plan dropping it. A rename happens only when a widening-allowed plan is the first convergence, before any create. This degradation is deliberate — an additive-only run never emits an op class it is not allowed to execute; if you want renames instead of create-then-drop, run the widening plan first.
Hard-coded names
If application code, tests, or operational scripts hard-code physical index names (e.g. user_email_idx), read the new names from the regenerated contract.json — wire names now carry the hash suffix. PSL schemas that must keep a byte-exact legacy name can pin it with @@index([...], map: "<exact name>").
rls-policy-migration-literal-carries-the-naming-union
Generated migrations that create an RLS policy carry the policy as a literal. Where 0.16 spelled its name as two flat fields, 0.17 spells it as one naming field with two shapes:
// 0.16
this.createRlsPolicy({ schema: "public", table: "post", policy: {
name: "post_owner_a1b2c3d4",
prefix: "post_owner",
// …
} })
// 0.17
this.createRlsPolicy({ schema: "public", table: "post", policy: {
naming: { kind: "wire", prefix: "post_owner", hash: "a1b2c3d4" },
// …
} })
A policy whose name the author owns (adopted through @@map) carries naming: { kind: "exact", name: "Tenant members can read" } instead. Every other key of the literal is unchanged.
The two fields could disagree — a prefix that is not what name ends with was representable and had to be checked at runtime — while the union cannot be written wrong. A 0.16 migration file that calls createRlsPolicy stops compiling against 0.17 with Property 'naming' is missing. Either regenerate the migration (prisma-next migration plan) or rewrite the two fields as the one union field by hand; the migration's identity and the SQL it runs do not depend on the literal's spelling, so a hand edit needs no re-hashing.
Incidental dependency and lint-config bumps
Routine dev-dependency bumps and biome $schema version alignment in examples/ (dependabot dev-deps group, PR #1058) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
Incidental dependency bumps in examples
Routine runtime dependency bumps in examples/ (dependabot runtime-deps group, PR #1065) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
Incidental runtime dependency bumps in examples (August 2026)
Routine runtime dependency bumps in examples/ (dependabot runtime-deps group, PR #29872) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
Incidental dev-dependency bumps in examples (August 2026)
Routine dev-dependency bumps in examples/ (dependabot dev-deps group, PR #29877) require no Prisma 8-specific upgrade action; review and test the affected examples as with any routine dependency update.
Incidental example dependency bumps (react-router 8)
The react-router-demo example moves its react-router, @react-router/dev, @react-router/node, and @react-router/serve dependencies from 7.x to 8.x. This is an example-local framework upgrade and requires no Prisma 8-specific upgrade action; the Prisma 8 surfaces the example uses are unchanged.
pg-int8-application-values-are-bigint
An int8 is a signed 64-bit integer; a JS number holds integers exactly only to 2^53. The codec previously handed you a number, so anything larger was already wrong by the time your code saw it. It now hands you a bigint.
TypeScript does not implicitly convert between number and bigint, so pnpm typecheck finds every affected site. Three shapes recur:
- Row-type annotations. A counted column is
bigint:SqlQueryPlan<{ name: string; postCount: bigint }>. - Comparison literals.
fns.gt(fns.count(), 5)becomesfns.gt(fns.count(), 5n). - Values read from a driver. A raw
pgquery returns anint8as a decimal string; convert withBigInt(row.id)rather than annotating itnumber.
Arithmetic mixing the two throws at runtime rather than coercing, so a site that typechecks after a cast is worth reading again.
pg-interval-values-are-structured-durations
An interval is not a duration. PostgreSQL stores three independent fields — months, days and microseconds — because a month has no fixed length, so { months: 1 } and { days: 30 } are different intervals and neither can be converted into the other. The application value is now those three fields, so reading an interval hands you numbers to compute with rather than a string to parse.
// before
const gap: string = row.gap; // "{\"days\":1}"
// after
const gap = row.gap; // { months: 0, days: 1, micros: 0n }
const totalDays = gap.days + gap.months * 30; // your calendar rule, not ours
The representation is separate from the value, as it is for pg/bytea@1 (a Uint8Array carried as base64) and pg/int8@1 (a bigint carried as decimal text). A contract holds the ISO-8601 duration string, so re-emit to pick up the spelling — P1M, P1Y2M3DT4H5M6S, PT0S for zero, each component carrying its own sign.
Two details worth knowing:
- The ISO rendering normalises where the value does not. Thirteen months render as
P1Y1Mand read back as{ months: 13 }. The value keeps what you gave it. - Fractional seconds round. PostgreSQL rounds past microsecond resolution rather than truncating —
1.1234567seconds is1.123457— and both paths into the value now agree with it.
codec-json-forms-are-canonical
The rule these follow is that a value written through a codec and read back must be the same value. Where a codec's JSON form could not carry its own range, the form changed rather than the range being quietly clipped.
Re-emit first (prisma-next contract emit), then reconcile any code that reads or writes one of these forms directly. Literal defaults are where this most often surfaces: an int8 default of 0 is now "0" in contract.json, and the storageHash moves with it.
The second place it surfaces is reads. A query that returns JSON projects each column through its codec, so a column whose codec is one of the nine listed above arrives in that codec's canonical form rather than in whatever the database's own JSON conversion produced. Decoding through the ORM needs no change — the codec's decodeJson is the other half of the same pair, and the two moved together. What needs checking is code that bypasses the ORM's decoding: a raw query that reads an aggregated JSON column and parses it itself, a comparison against a hand-written JSON string, a snapshot of database-produced JSON.
Where a form is a strict improvement in range, nothing downstream breaks by widening. Where a form changes spelling — bytea from \x-hex to base64, sqlite/blob@1 from base64 to uppercase hex — a hand-written comparison is the thing that breaks, and it breaks loudly rather than silently.
sql-timestamp-json-is-utc-not-local
The formatting change is easy to see and easy to fix. The interpretation change is neither, so take it first.
A timestamp column carries no time zone. Its JSON form is therefore a zone-less string, and something has to decide which instant that string denotes. sql/timestamp@1 used to hand the string to new Date(...), which resolves a zone-less form in the zone the process happens to be running in. The same stored value decoded to a different instant depending on where the code ran, and it decoded silently — a Date is a Date, whichever instant it holds.
It now resolves as UTC, unconditionally, and encodeJson writes UTC. The pair round-trips on any machine.
The migration hazard is compensation you may already have in place:
- If you added an offset back after decoding, remove it. It now double-corrects, and the result is wrong by twice your offset.
- If you set
TZ=UTCon the process specifically to stabilise these values, you can drop that — though leaving it costs nothing, since UTC was already the case it produced. - If you normalised timestamps after reading them, check whether the normalisation is still doing anything.
None of these fail loudly. A doubled offset produces a timestamp that parses, compares and serialises perfectly well and denotes the wrong moment, which is why this entry leads with the interpretation rather than the dropped Z.
Two smaller consequences follow:
encodeJsonemits2026-01-02T03:04:05.678rather than2026-01-02T03:04:05.678Z. Update fixtures, snapshots and hand-written comparisons.decodeJsonrejects an offset-bearing string instead of reinterpreting it. The codec cannot reproduce an offset, so accepting one would decode a value it could never encode back.
pg/timestamp@1 needs no attention: it already read as UTC and already emitted the zone-less form, and this change brings the generic codec into line with it.
Incidental release version bump to 0.17.0
The chore(release): bump to 0.17.0 commit rewrites every workspace manifest's version field and workspace: pins, which touches examples/ manifests. No user action beyond the entries above.