upgrading/extension/upgrades/0.14-to-0.15/instructions.md
upgrading/extension/upgrades/0.14-to-0.15/instructions.mdBrowse 76 files
11,587 tokens
48,937 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
from: "0.14" to: "0.15" changes:
- id: sql-contract-createnamespace-required
summary: |
The SQL family no longer materialises a placeholder namespace, so authoring a SQL contract now
requires a target namespace factory. If your extension builds a contract via
prismaContract(...)ordefineContract(...)from@internal/sql-contract-psl/@internal/sql-contract-ts(rather than through a target pack's owndefineContractwrapper, which already supplies it), pass the now-requiredcreateNamespaceoption:postgresCreateNamespacefrom@internal/target-postgres/types, orsqliteCreateNamespacefrom@internal/target-sqlite/control. Without it,contract emit/ build fails at runtime with "createNamespace is not a function". detection: glob: "**/*.{ts,mts,cts}" contains: - "prismaContract(" - "defineContract(" anyMatch: true - id: sql-namespace-types-renamed-and-removed
summary: |
SqlNamespaceis now an abstract class and the family placeholder concretion is gone. Rename the factory-input typeSqlNamespaceTablesInput->SqlNamespaceInput(it is thecreateNamespacefactory input, not a tables-only type). The removed symbolsbuildSqlNamespace,buildSqlNamespaceMap,SqlBoundNamespace, andSqlUnboundNamespacehave no drop-in replacement: construct SQL namespaces only through a targetcreateNamespacefactory (postgresCreateNamespace/sqliteCreateNamespace). Any hand-written SQL namespace type literal or fixture must carry the targetkind(e.g.'postgres-schema') instead of the removed'sql-namespace'discriminator. detection: glob: "**/*.{ts,mts,cts,tsx}" contains: - "SqlNamespaceTablesInput" - "buildSqlNamespace" - "SqlBoundNamespace" - "SqlUnboundNamespace" - "'sql-namespace'" anyMatch: true - id: codec-render-value-literal-for-restricted-columns
summary: |
A field/column restricted to a value set (e.g. an enum) now derives its narrowed TS literal
union through the codec, not the framework's (now-deleted) domain-enum override. If your
extension authors a custom codec descriptor (
extends CodecDescriptorImpl) used by a restricted/enum column, implementrenderValueLiteral(value, side)on it so the column narrows to its value union; without it the column widens to the codec's output type (CodecTypes[id][side]). If your extension builds aCodecLookupby hand and drives the framework emitter (generateContractDts), exposerenderValueLiteralForso the emit path can reach your descriptor's renderer. Framework-built lookups (via the CLI/build / control-stack) already supply it — no action there. (side:output= the read/SELECT type,input= the create/update type.) detection: glob: "**/*.{ts,mts,cts}" contains: - "CodecDescriptorImpl" - "renderValueTypeFor" - "renderOutputType" anyMatch: true - id: sql-codec-json-result-decoding
summary: |
SQL
encodeJson/decodeJsonnow use the exact scalar shape produced by the corresponding database inside JSON values. SQL include decoding callsdecodeJson; ordinary column decoding continues to calldecode. Update custom SQL codecs whose database JSON representation differs from their normal driver wire representation, then re-emit committed contracts and defaults. Built-in representation changes are:pg/bytea@1base64 ->\\x-prefixed hex,pg/numeric@1string -> JSON number,pg/timestamp@1UTCZsuffix -> no timezone suffix,pg/timestamptz@1UTCZsuffix ->+00:00,sqlite/bigint@1string -> JSON number,pg/vector@1JSON array -> Postgres vector text, andpg/geometry@1GeoJSON object -> HEXEWKB text. SQLite cannot represent BLOB values inside its native JSON values; such queries still fail at the database boundary rather than receiving a synthetic codec representation. detection: glob: "**/*.{ts,mts,cts}" contains: - "encodeJson" - "decodeJson" anyMatch: true - id: mongo-derive-json-schema-value-sets-param
summary: |
deriveJsonSchema/derivePolymorphicJsonSchema(from@internal/mongo-contract-psl) now source a value-set field's$jsonSchemaenumkeyword from a value-set map, not the domain enum. Their fourth argument changed from a domain-enum map (Record<string, ContractEnum>, read asmembers.map(m => m.value)) to a value-set map (FieldValueSets=Record<string, { values: readonly JsonValue[] }>, keyed by the field'svalueSetentityName). If your extension calls either function directly, pass the storage value sets (contract.storage.namespaces[<ns>].entries.valueSet) instead ofdomain.enum; the values are identical for enums, so the rendered validator is unchanged. Most extensions author Mongo contracts throughmongoContract(...)/defineContract(...), which call these internally — those need no change. detection: glob: "**/*.{ts,mts,cts}" contains: - "deriveJsonSchema" - "derivePolymorphicJsonSchema" anyMatch: true - id: sql-migration-planner-keep-diff-issue-to-ownership-oracle
summary: |
MigrationPlanner.plan()(and the SQL family'sSqlMigrationPlannerPlanOptions) drops thekeepDiffIssueoption — a caller-supplied(issue: DiffIssue) => booleanpredicate the planner applied to its schema diff for multi-space ownership scoping. It is replaced byownership?: SchemaOwnership— an ownership oracle ({ declaresEntity(entityName): boolean }, exported from@internal/framework-components/control) that theContractSpaceAggregatesatisfies. The planner asks it, per live extra node, whether any contract space declares that entity: a node another space owns is left untouched, a node no space owns is a genuine extra it may drop under a destructive policy. If your extension callsplanner.plan(...)directly withkeepDiffIssue(rather than through the aggregate'sdb init/db update/migrateorchestration, which passes the aggregate as the oracle for you), drop the predicate and pass the aggregate (or any object implementingSchemaOwnership) asownership. There is no names-set and no filter function — ownership lives in the aggregate; the planner only asks. detection: glob: "**/*.{ts,mts,cts}" contains: - "keepDiffIssue" anyMatch: true - id: family-sql-collect-sql-schema-issues-removed
summary: |
collectSqlSchemaIssues,collectSqlSchemaIssuesPerNamespace, and theirCollectSqlSchemaIssuesOptionsoptions type are removed from@internal/family-sql/diff. They implemented the coordinate-based relational schema diff the migration planner used before it moved onto the generic node differ (plan(start, end)). There is no drop-in replacement — if your extension called either function directly to compare a contract against a live/derived schema, use the generic node differ instead:diffSchemas(from@internal/framework-components/control) over two schema-IR trees, or a target's ownbuildXPlanDiff(e.g.buildPostgresPlanDifffrom@internal/target-postgres/diff-database-schema,buildSqlitePlanDifffrom the sqlite target) for the same op-render-stamped comparison the planner itself runs. detection: glob: "**/*.{ts,mts,cts}" contains: - "collectSqlSchemaIssues" - "collectSqlSchemaIssuesPerNamespace" - "CollectSqlSchemaIssuesOptions" anyMatch: true - id: sql-control-target-descriptor-diff-database-schema-removed
summary: |
SqlControlTargetDescriptor(from@internal/family-sql/control) drops thediffDatabaseSchemafield — the per-targetSchemaDifferhook that used to back the coordinate-based relational diff. If your extension implements a custom SQL target descriptor and supplied this field, remove it; the migration planner reaches the one differ directly via the target's own diff-tree builder now (see thefamily-sql-collect-sql-schema-issues-removedentry above).diffSchemaForVerdict(the full-tree node diff the verify verdict derives from) is unaffected and still required. detection: glob: "**/*.{ts,mts,cts}" contains: - "diffDatabaseSchema" anyMatch: true - id: migration-tools-aggregate-strategy-rename
summary: |
@internal/migration-tools/aggregaterenames its exported graph-walk strategy to say what it does, not how it's implemented:graphWalkStrategy->resolveRecordedPath,GraphWalkOutcome->ResolveRecordedPathOutcome,GraphWalkStrategyInputs->ResolveRecordedPathInputs. The function's behaviour, inputs, and outcome shape are unchanged — only the names. If your extension imports any of these symbols directly (rather than going throughplanMigration, which handles this internally), update the import names. detection: glob: "**/*.{ts,mts,cts}" contains: - "graphWalkStrategy" - "GraphWalkOutcome" - "GraphWalkStrategyInputs" anyMatch: true - id: target-postgres-diff-postgres-database-schema-removed
summary: |
diffPostgresDatabaseSchemais removed from@internal/target-postgres/planner— the Postgres-specific coordinate-basedSchemaDifferimplementation, retired alongsideSqlControlTargetDescriptor.diffDatabaseSchema(see the entry above). If your extension imported it directly, usebuildPostgresPlanDifffrom@internal/target-postgres/diff-database-schemainstead — it runs the same one-differ comparison the planner itself uses (relational + RLS-policy issues in one node-typed list, filter to the subset you need) and additionally stamps the op-render payload the planner reads. detection: glob: "**/*.{ts,mts,cts}" contains: - "diffPostgresDatabaseSchema" anyMatch: true - id: schema-issue-vocabulary-retired
summary: |
The coordinate-based issue vocabulary is gone:
BaseSchemaIssue,SchemaIssue,EnumValuesChangedIssue, and theDiffIssueunion are removed from@internal/framework-components/control.SchemaDiffIssue({ path, reason, message, expected?, actual? }) is the only issue shape everywhere now — verify results, the codecverifyTypehook, andSchemaVerifier.issuesall report it. Itsoutcomefield is also gone; usereason('not-found'|'not-expected'|'not-equal') instead —outcome's'missing'/'extra'/'mismatch'map onto those three respectively. If your extension imports any of the removed types, constructs a{ kind, table, message }-shaped issue by hand, or reads.outcomeoff aSchemaDiffIssue, switch to the node-typed shape andreason. detection: glob: "**/*.{ts,mts,cts}" contains: - "BaseSchemaIssue" - "EnumValuesChangedIssue" - "SchemaDiffOutcome" - ".outcome === 'missing'" - ".outcome === 'extra'" - ".outcome === 'mismatch'" anyMatch: true - id: schema-finding-lists-single-list
summary: |
SchemaFindingLists(and thereforeVerifyDatabaseSchemaResult.schema/.schema.warnings) collapses from two lists (issues: SchemaIssue[],schemaDiffIssues: SchemaDiffIssue[]) to one:{ issues: SchemaDiffIssue[] }. The frameworkSchemaDiffclass follows the same collapse — its constructor now takes one issue array instead of two (new SchemaDiff(issues), notnew SchemaDiff(issues, schemaDiffIssues)), and.filter()narrows the single list. If your extension readsresult.schema.schemaDiffIssues(or.schema.warnings.schemaDiffIssues) directly, or constructs aSchemaDiffby hand, update both call sites to the single-list shape — concatenate the old two lists into one, in the same order, if you need to reproduce prior combined output. detection: glob: "**/*.{ts,mts,cts}" contains: - "schemaDiffIssues" - "new SchemaDiff(" anyMatch: true - id: codec-verify-type-hook-returns-schema-diff-issue
summary: |
CodecControlHooks.verifyType(the storage-type verification hook,@internal/family-sql/control) now returnsreadonly SchemaDiffIssue[]instead ofreadonly SchemaIssue[]— no morekindstring; classify byreasoninstead. A storage type (e.g. a native enum) only ever diverges in its value set, so every pairednot-equalfinding grades as value drift (suppressed under anexternalcontrol policy, same as before);not-foundis a missing type,not-expectedan extra one. If your extension implements a custom codec'sverifyTypehook, return{ path, reason, message, expected?, actual? }issues instead of the old{ kind, table, message }shape. detection: glob: "**/*.{ts,mts,cts}" contains: - "verifyType:" - "verifyType(" anyMatch: true - id: policy-target-models-require-rls-attribute
summary: |
If your extension's contract space authors
policy_selectblocks (PSL), each block'stargetmodel must now declare@@rls;contract emit/build:contract-spacefails withPSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTEotherwise. Add@@rlsto the policy-bearing models and re-emit; the contract gains anrlsmarker entity (entries.rls[tableName]) and a new storage hash. detection: glob: "**/*.prisma" contains: - "policy_select" anyMatch: true - id: postgres-table-schema-node-rls-enabled-required
summary: |
PostgresTableSchemaNodeInput.rlsEnabled(from@internal/target-postgres/types) is now a required boolean, andisEqualTocompares it alongside the table name. Everynew PostgresTableSchemaNode({ ... })construction in your extension (planner tests, diff-tree fixtures, tooling) must supply it explicitly -falsefor a table that is not RLS-controlled. The expected side derives the value from the contract'sentries.rlsmarker; the actual side frompg_class.relrowsecurityat introspection. detection: glob: "**/*.{ts,mts,cts}" contains: - "new PostgresTableSchemaNode(" anyMatch: true - id: authoring-contributions-model-attributes-slot
summary: |
AuthoringContributionsgains amodelAttributesslot and the assembled control-stack shape (AssembledAuthoringContributions) is now five fields - code that constructs the assembled shape literally (e.g. a stubbedContractSourceContext.authoringContributionsin tests) must addmodelAttributes: {}. New SPI for pack authors: a target/extension pack can contribute declarative@@model attributes viaAuthoringContributions.modelAttributes(anAuthoringModelAttributeDescriptorcarries the bare attribute name, an ADR-231modelAttribute()spec, and a lowering that files an entity into the namespace'sentries[attribute][key]), and a PSL block descriptor can declarerequiresModelAttribute: { parameter, attribute }to demand that the model named by a ref parameter carries a bare@@attribute. detection: glob: "**/*.{ts,mts,cts}" contains: - "AssembledAuthoringContributions" - "authoringContributions: {" anyMatch: true - id: native-enum-serialized-in-contract-json
summary: |
native_enumentities now serialize into an extension's emittedcontract.json(previously they were authoring-time-only — stripped on emit, leaving only the derivedvalueSet). If your extension declares native Postgres enums —native_enumblocks in a.prismacontract, orpg.enum(...)/nativeEnum(...)columns in the TypeScript DSL — re-emit your bundled contract (prisma-next contract emit) and commit the result, so theentries.native_enummaps and the recomputedstorageHashland in your checked-incontract.{json,d.ts}. Re-emitting is what makes your pack's enum type names visible in the published contract: a consumer runningcontract inferwith your pack in the stack subtracts your pack-owned enum types by matching those serialized type names, so an un-re-emitted contract leaves the consumer re-declaring types your pack already owns. The change is backward compatible (a pre-existing contract still hydrates), so re-emit at your next release rather than urgently. detection: glob: "**/*.{prisma,ts,mts,cts}" contains: - "native_enum" - "pg.enum(" - "nativeEnum(" anyMatch: true - id: native-enum-entry-keyed-by-physical-type-name
summary: |
A serialized
native_enumentry is now keyed by its physical Postgres type name — the@@mapvalue, or the declared type name when unmapped — not the TS-facing PascalCase name it previously used (entries.native_enum.aal_level, notentries.native_enum.AalLevel). This aligns thenative_enumkey with every other storage entry (a table keys by its physical name) per ADR 221. If your extension declares native Postgres enums, re-emit your bundled contract (prisma-next contract emit) and commit the result so the re-keyedentries.native_enummap and the recomputedstorageHashland in your checked-incontract.{json,d.ts}. If your extension code addresses anative_enumentry by key (contract.storage.namespaces[<ns>].entries.native_enum[<name>]), switch that key from the PascalCase type name to the physical type name. detection: glob: "**/*.{prisma,ts,mts,cts}" contains: - "native_enum" - "pg.enum(" - "nativeEnum(" anyMatch: true - id: scalar-field-state-descriptor-generic
summary: |
ScalarFieldState(from@internal/sql-contract-ts/contract-builder) changes its first type parameter from the codec-id string (CodecId extends string = string) to the full column descriptor type (Descriptor extends ColumnTypeDescriptor = ColumnTypeDescriptor), so field states preserve the whole descriptor type — including a native-enum entity's member literal tuple — instead of only the codec id. If your extension namesScalarFieldState<...>with positional generics, wrap the codec id in the descriptor type:ScalarFieldState<'pg/text@1', ...>becomesScalarFieldState<ColumnTypeDescriptor<'pg/text@1'>, ...>(importColumnTypeDescriptorfrom@internal/framework-components/codec); the remaining six parameters are unchanged. Two narrowing ride-alongs can surface in exact-type test assertions: built contract types now keep a descriptor's literalnativeType/typeParams(previously widened tostring), andpg.enum(handle)(from@internal/postgres) returns a descriptor whoseentityRefis non-optional and whoseentityRef.entityisPostgresNativeEnum<Members>instead ofunknown. Both remain assignable everywhere the old types were accepted — updateexpectTypeOf-style equality assertions to the narrowed types; do not re-widen production types to satisfy them. detection: glob: "**/*.{ts,mts,cts}" contains: - "ScalarFieldState" anyMatch: true - id: schema-ir-fk-unbound-referenced-schema-absent
summary: |
The family's
contractToSchemaIR(from@internal/family-sql/control) no longer stampsreferencedSchemaon a derivedSqlForeignKeyIRwhose target is the unbound namespace — the field is now absent for that case (it previously carried the__unbound__sentinel). Namespace identity is answered by the namespace node's newisUnboundgetter (onNamespaceBase/SqlNamespace), never by comparing an id against the sentinel. If your extension rebuilds a target schema-IR tree from acontractToSchemaIR-derived one and reconstructs eachSqlForeignKeyIR(as the Postgres target does incontractToPostgresDatabaseSchemaNode), default the absent value back to the target's own coordinate for the unbound slot:referencedSchema: fk.referencedSchema ?? UNBOUND_NAMESPACE_ID. Extensions that readreferencedSchemaonly for bound (named-schema) FK targets need no change — absence already meant "unbound" downstream. detection: glob: "**/*.{ts,mts,cts}" contains: - "SqlForeignKeyIR" - "referencedSchema" anyMatch: true - id: supabase-pack-contract-complete
summary: |
The
@internal/extension-supabaseshipped contract is now the complete, introspection-generated description of everything Supabase owns — everyauth(23) andstorage(10) table of the reference platform version (supabase/postgres:17.6.1.106), all 10 native enum types, and the three roles — up from the previous 5-table minimum. All additive and stillexternal: composing apps re-emit and pick up the new pack storageHash;db.asServiceRole().supabase.{sql,orm}now exposes the full owned table set; extension-awarecontract inferomits correspondingly more.db verifynow requires the full owned set to exist in the live database — real Supabase projects have them; a local or CI stand-in database should restore the pack's reference fixture:bootstrapSupabaseShimfrom@internal/extension-supabase/test/utilsnow does exactly that (it restores the complete reference schema — all Supabase schemas and roles — instead of a hand-authored 5-table subset), so shim users need no change beyond re-running. The curated/contractmodel handles (AuthUser, AuthIdentity, AuthSession, StorageBucket, StorageObject) are unchanged. detection: glob: "**/*.{ts,mts,cts,tsx,prisma,json}" contains: - "@internal/extension-supabase" anyMatch: true - id: psl-relation-index-argument
summary: |
PSL's
@relation(...)gained an optional booleanindexargument that lowers onto the foreign key's existing IRindexflag:@relation(fields: [x], references: [y], index: false)declares the FK without the derived backing-index expectation, for databases whose FK columns genuinely have no physical index (previously unexpressible in PSL — verify would report the synthesized indexnot-found).contract infernow emitsindex: falseautomatically for FKs it introspects without a live backing index, using the same column-key predicate verify uses (shared helperbackingIndexColumnKeys/isBackedByColumnKeysin@internal/family-sql). Purely additive — omittedindexkeeps the defaulttrue; existing contracts re-emit byte-identically. detection: glob: "**/*.prisma" contains: - "@relation" anyMatch: true - id: contract-canonicalization-preserves-false
summary: |
The contract canonicalizer no longer strips
value: falsefrom resolved default-value objects (barefalsewas treated as an omittable empty value, so a@default(false)column lost its default in the emittedcontract.jsonand never round-tripped against live introspection). Re-emitting a contract that has boolean-falsecolumn defaults changes its emitted JSON (the default is now present) and therefore its storageHash. No authoring-surface change; re-emit and commit the refreshed artifacts. detection: glob: "**/*.prisma" contains: - "@default(false)" anyMatch: true - id: sql-array-columns-round-trip
summary: |
Fixes for scalar-list (array) columns and introspection fidelity that can change emitted/derived
artifacts for affected schemas: (1) the family's
contractToSchemaIRnow keeps an array column'snativeTypeas the bare element type withmany: true(previously it baked"text[]"intonativeType, so every list column verifiednot-equalagainst live introspection); (2) Postgres introspection now excludes expression-keyed indexes (e.g. onlower(email)) and no longer collides a unique and non-unique index over identical columns; (3)contract infercarries a non-default index access method through as@@index(..., type: "<method>")— note the type must be registered in the stack's IndexTypeRegistry to emit. Extensions that snapshot introspection output or assert on derived schema-IR for array/expression-indexed tables should re-run and refresh expectations. detection: glob: "**/*.{ts,mts,cts}" contains: - "contractToSchemaIR" - "introspect" anyMatch: true - id: postgres-inet-codec
summary: |
The postgres target gains a
pg/inet@1codec (transparent string carrier, likepg/uuid@1):inetcolumns are now authorable asString @db.Inetin PSL and representable in contracts, andcontract infermaps an introspectedinetcolumn toString @db.Inetinstead ofUnsupported("inet"). Purely additive — no existing contract changes; re-runningcontract inferagainst a database with inet columns now includes them in the output. detection: glob: "**/*.{prisma,ts,mts,cts}" contains: - "inet" - "db.Inet" anyMatch: true - id: psl-role-block
summary: |
PSL gains a standalone
roleblock on the postgres target, authored inside the explicit unbound namespace:namespace unbound { role anon {} }(name-only, no parameters) lowers to a first-classPostgresRoleentity in the contract's__unbound__storage slot (control: 'external'— roles are referenced, never owned; the planner emits no role DDL anddb verifychecks existence viapg_roles). The unbound namespace's purpose is late binding (search_path-resolved tables); roles are declared there because they are cluster-scoped and belong to no schema. To make this authorable, the "nonamespace unbound { }alongside named namespaces" restriction is narrowed to models: a blocks-only unbound namespace is legal next to named namespaces, while one containing models next to named namespaces stays rejected (PSL_RESERVED_NAMESPACE_NAME). Aroleblock anywhere else — a named namespace or the document top level — is rejected withPSL_ROLE_BLOCK_OUTSIDE_UNBOUND_NAMESPACE. Purely additive for existing contracts. detection: glob: "**/*.{prisma,ts,mts,cts}" contains: - "role " - "AuthoringPslBlockDescriptor" anyMatch: true - id: supabase-pack-contract-declares-roles
summary: |
The
@internal/extension-supabaseshipped contract now declares Supabase's three standard Postgres roles (anon,authenticated,service_role) as first-classroleentities withcontrol: 'external'.db verifyon a project composing the pack now fails with anot-foundschema issue naming each declared role the live database lacks. Real Supabase databases always have these roles, so hosted projects need no change; a local or CI database that stands in for Supabase must create them —bootstrapSupabaseShimfrom@internal/extension-supabase/test/utilsalready does. The publicSupabaseRoleBinding['role']type is unchanged ('anon' | 'authenticated' | 'service_role'); it is now derived from theSupabaseRolePrisma 8 enum handle's values; the contract declares the roles via the new PSLroleblocks insidenamespace unbound { }(see thepsl-role-blockentry). detection: glob: "**/*.{ts,mts,cts,tsx,prisma,json}" contains: - "@internal/extension-supabase" anyMatch: true