Skip to content

TML-2727: move SQL/Mongo canonicalizer hooks to family packs#631

Merged
wmadden merged 18 commits into
mainfrom
tml-2727-s1d-canonicalizer-hook
May 30, 2026
Merged

TML-2727: move SQL/Mongo canonicalizer hooks to family packs#631
wmadden merged 18 commits into
mainfrom
tml-2727-s1d-canonicalizer-hook

Conversation

@wmadden
Copy link
Copy Markdown
Contributor

@wmadden wmadden commented May 29, 2026

Linked issue

Refs TML-2727 — closes TML-2579.

Spec: projects/contract-ir-planes/slices/canonicalizer-family-hook/spec.md

Parallel to S1.D-1/-3 + S1.E — disjoint files.

At a glance

Family-specific canonicalization rules now live in family packs and are threaded into the framework canonicalizer as optional hooks:

// @prisma-next/sql-contract/canonicalization-hooks
export const sqlContractCanonicalizationHooks = {
  shouldPreserveEmpty, // storage.namespaces.*.tables.* preserve-empty guards
  sortStorage,         // deterministic indexes/uniques ordering
} as const;

// emit / computeStorageHash call site
canonicalizeContractToObject(contract, {
  serializeContract,
  ...sqlContractCanonicalizationHooks,
});

Before this change, the framework canonicalizer hardcoded SQL-shaped storage.namespaces.*.tables.* path knowledge inline.

What changed

  • Framework (canonicalization.ts): removed inline SQL/Mongo storage path guards and sortIndexesAndUniques; added optional shouldPreserveEmpty and sortStorage on CanonicalizeContractOptions. computeStorageHash accepts the same hooks so hash computation stays aligned with emit.
  • Family packs: sqlContractCanonicalizationHooks and mongoContractCanonicalizationHooks exported from @prisma-next/sql-contract/canonicalization-hooks and @prisma-next/mongo-contract/canonicalization-hooks; wired through ContractSerializer, SqlContractSerializerBase, MongoContractSerializerBase, contract-ts builders, and contract-emit.
  • Tests: framework grep gate confirms no tables/indexes/uniques/foreignKeys guards remain in the canonicalizer; SQL/Mongo asymmetry (empty collections vs tables) preserved via family hooks.

Why

Completes PDoD6: the last SQL-specific framework canonicalizer path becomes a family contribution. The framework keeps only family-agnostic rules (top-level order, _generated stripping, noAction omission, key sort). Families pass hooks in — no framework→family import cycle.

Byte-stability evidence

pnpm fixtures:check   # exit 0 — zero diff on contract.json / contract.d.ts fixtures after re-emit

Testing performed

Gate Command Result
Contract unit tests pnpm --filter @prisma-next/contract test pass (125 tests)
Emitter canonicalization pnpm --filter @prisma-next/emitter test test/canonicalization.test.ts pass (17 tests)
SQL/Mongo family packs pnpm --filter @prisma-next/family-sql --filter @prisma-next/family-mongo test pass
Fixtures pnpm fixtures:check pass (zero diff)
Deps pnpm lint:deps pass
Typecheck pnpm typecheck pass (after full build; some turbo parallel flakes on first pass)

Checklist

  • Move-don't-change refactor — canonical bytes unchanged (fixtures:check zero diff)
  • No framework→family import cycle
  • Mongo empty-collection vs SQL-never-empty asymmetry preserved
  • Signed-off commit

Summary by CodeRabbit

  • New Features

    • Families can provide hooks to preserve empty structures and deterministically sort storage during canonicalization.
    • New public utilities and entry points expose path-pattern matching, preserve-empty predicate builders, and storage-sorting helpers.
    • SQL and Mongo families publish and consume canonicalization hook bundles for emit and hashing flows.
  • Refactor

    • Core canonicalization no longer applies built-in storage sorting; sorting runs only when a hook is supplied.
    • Contract serialization and emit paths now forward canonicalization hooks through tooling.
  • Tests

    • Added and updated tests covering pattern matching, storage sorting, hooks, and hashing.
  • Chores

    • Package entrypoints updated to expose canonicalization hooks and hashing utilities.

Review Change Stack

wmadden added 3 commits May 29, 2026 15:21
The granular S1.D ticket (TML-2625) was canceled and archived in the
2026-05-20 ticket cleanup; recreated as TML-2727 (the single live S1.D
ticket). Also: the subsumed cleanup tickets (TML-2579/2580/2582/2545/
2563) are already Canceled, so PDoD10 needs no close-out dispatch \u2014 mark
it satisfied in the coverage map.

Signed-off-by: Will Madden <madden@prisma.io>
…efer structural items

A 2026-05-29 inventory falsified S1.D as one deletions-only slice: three of
the eight subsumed surfaces carry structural prerequisites (a contract.json-
shape coordinate change, a hash-computation change, a query-builder type
rewrite). Per the narrow-and-defer decision, S1.D ships the three clean deletes
now as independent parallel slices (construction-discipline shims, canonicalizer
family hook, migration -> elementCoordinates) and defers the three structural
items to follow-ups recorded in deferred.md.

Amends PDoD5/PDoD10 to scope the deferred items out; restructures the plan
composition into parallel groups (no stack); adds the three lean slice specs.

Signed-off-by: Will Madden <madden@prisma.io>
Move preserve-empty and storage-sort rules out of the framework
canonicalizer into family-contributed hooks on CanonicalizeContractOptions,
wired through ContractSerializer and the emit path. Byte-identical
canonical output is preserved; computeStorageHash accepts the same hooks.

Signed-off-by: Will Madden <madden@prisma.io>
@wmadden wmadden requested a review from a team as a code owner May 29, 2026 15:27
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Warning

Review limit reached

@wmadden, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 39 minutes and 8 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 0042abb1-b8a4-4b3c-8275-35bde594c022

📥 Commits

Reviewing files that changed from the base of the PR and between d885ed4 and 696d3e9.

📒 Files selected for processing (1)
  • packages/2-sql/9-family/src/core/control-instance.ts
📝 Walkthrough

Walkthrough

Adds path-pattern and storage-sorting utilities, exposes PreserveEmptyPredicate and StorageSort hooks, integrates those hooks into canonicalization and hashing, provides SQL/Mongo family hook implementations, wires hooks through serializer/emit/migration flows, and updates tests and exports accordingly.

Changes

Canonicalization hooks & utilities

Layer / File(s) Summary
Hook types and path matching
packages/1-framework/0-foundation/contract/src/canonicalization-path-match.ts, packages/1-framework/0-foundation/contract/src/canonicalization.ts
Adds PathSegment/PathPattern, matchesPathPattern, createPreserveEmptyPredicate, and exported hook types PreserveEmptyPredicate and StorageSort.
Storage sorting utilities
packages/1-framework/0-foundation/contract/src/canonicalization-storage-sort.ts
Implements NamedArraySortTarget, compareByNameProperty, walkAndSort, and createStorageSort for deterministic ordering of named arrays in serialized storage.
Hook integration into canonicalization
packages/1-framework/0-foundation/contract/src/canonicalization.ts
omitDefaults accepts and propagates shouldPreserveEmpty; built-in storage reordering removed; CanonicalizeContractOptions extended with shouldPreserveEmpty and sortStorage; canonicalizeContractToObject applies sortStorage only when provided.

Exports, hashing, and factories

Layer / File(s) Summary
Exports and hashing plumbing
packages/1-framework/0-foundation/contract/src/exports/hashing-utils.ts, .../exports/hashing.ts, packages/1-framework/0-foundation/contract/src/hashing.ts, packages/1-framework/0-foundation/contract/src/testing-factories.ts, package.json, tsdown.config.ts
Adds ./hashing-utils export, re-exports utilities/types, extends ComputeStorageHashArgs to accept hooks, and threads optional hooks through computeStorageHash and test factory helpers.

Serializer, emitter, migration wiring

Layer / File(s) Summary
ContractSerializer API
packages/1-framework/1-core/framework-components/src/control/contract-serializer.ts
ContractSerializer<TContract> gains optional shouldPreserveEmpty and sortStorage properties for family-provided canonicalization.
Emit, CLI, migration
packages/1-framework/3-tooling/emitter/src/emit-types.ts, packages/1-framework/3-tooling/emitter/src/emit.ts, packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts, packages/1-framework/3-tooling/migration/src/assert-descriptor-self-consistency.ts
Emit and migration types accept hook fields; emit and CLI forward hooks from contractSerializer; migration/migration assertions forward hooks into computeStorageHash when present.

Family-specific hooks and integration

Layer / File(s) Summary
SQL canonicalization hooks
packages/2-sql/1-core/contract/src/canonicalization-hooks.ts, .../exports/canonicalization-hooks.ts, package.json, tsdown.config.ts
Adds sqlContractCanonicalizationHooks with shouldPreserveEmpty path patterns and sortStorage targets for table arrays (indexes/uniques).
Mongo canonicalization hooks
packages/2-mongo-family/1-foundation/mongo-contract/src/canonicalization-hooks.ts, .../exports/canonicalization-hooks.ts, package.json, tsdown.config.ts
Adds mongoContractCanonicalizationHooks exposing shouldPreserveEmpty for Mongo collection paths.
Family wiring & integration
multiple packages/* and test/* files
Spreads family hook objects into computeStorageHash, contract builders, serializer base classes, control-instance validation, emit calls, and numerous tests so hashing/emission use family canonicalization rules.

Tests

Layer / File(s) Summary
Unit tests for utilities
packages/1-framework/0-foundation/contract/test/canonicalization-path-match.test.ts, packages/1-framework/0-foundation/contract/test/canonicalization-storage-sort.test.ts
New unit tests for path-pattern matching, preserve-empty predicate, storage sorting, and compare-by-name behavior.
Framework, emitter, integration tests
many packages/1-framework/*/test/*, packages/2-*/**/test/*, test/integration/*
Updated tests to pass SQL/Mongo hooks where needed; adds source-inspection test to ensure base canonicalizer contains no hardcoded SQL/Mongo path tokens.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • aqrln

Poem

🐰 I match the paths and mind the name,
I keep the empties when families claim,
I sort the indexes, tidy the trees,
Hop, sniff, and checksum — neat contracts, please!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the primary change: moving SQL/Mongo canonicalizer hooks from the framework to family packs, using a concise, single-sentence format that highlights the main refactoring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tml-2727-s1d-canonicalizer-hook

Comment @coderabbitai help to get the list of available commands and usage tips.

wmadden added 2 commits May 29, 2026 17:42
Avoid bundling @prisma-next/sql-contract into dist/test/utils.mjs; test
helpers mirror production hook behaviour without a cross-layer import.

Signed-off-by: Will Madden <madden@prisma.io>
…lizer-hook

Signed-off-by: Will Madden <madden@prisma.io>

# Conflicts:
#	test/integration/test/authoring/side-by-side-contracts.test.ts
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 29, 2026

Open in StackBlitz

@prisma-next/extension-author-tools

npm i https://pkg.pr.new/@prisma-next/extension-author-tools@631

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/@prisma-next/mongo-runtime@631

@prisma-next/family-mongo

npm i https://pkg.pr.new/@prisma-next/family-mongo@631

@prisma-next/sql-runtime

npm i https://pkg.pr.new/@prisma-next/sql-runtime@631

@prisma-next/family-sql

npm i https://pkg.pr.new/@prisma-next/family-sql@631

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/@prisma-next/extension-arktype-json@631

@prisma-next/extension-cipherstash

npm i https://pkg.pr.new/@prisma-next/extension-cipherstash@631

@prisma-next/middleware-cache

npm i https://pkg.pr.new/@prisma-next/middleware-cache@631

@prisma-next/mongo

npm i https://pkg.pr.new/@prisma-next/mongo@631

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/@prisma-next/extension-paradedb@631

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/@prisma-next/extension-pgvector@631

@prisma-next/extension-postgis

npm i https://pkg.pr.new/@prisma-next/extension-postgis@631

@prisma-next/postgres

npm i https://pkg.pr.new/@prisma-next/postgres@631

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/@prisma-next/sql-orm-client@631

@prisma-next/sqlite

npm i https://pkg.pr.new/@prisma-next/sqlite@631

@prisma-next/target-mongo

npm i https://pkg.pr.new/@prisma-next/target-mongo@631

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/@prisma-next/adapter-mongo@631

@prisma-next/driver-mongo

npm i https://pkg.pr.new/@prisma-next/driver-mongo@631

@prisma-next/contract

npm i https://pkg.pr.new/@prisma-next/contract@631

@prisma-next/utils

npm i https://pkg.pr.new/@prisma-next/utils@631

@prisma-next/config

npm i https://pkg.pr.new/@prisma-next/config@631

@prisma-next/errors

npm i https://pkg.pr.new/@prisma-next/errors@631

@prisma-next/framework-components

npm i https://pkg.pr.new/@prisma-next/framework-components@631

@prisma-next/operations

npm i https://pkg.pr.new/@prisma-next/operations@631

@prisma-next/ts-render

npm i https://pkg.pr.new/@prisma-next/ts-render@631

@prisma-next/contract-authoring

npm i https://pkg.pr.new/@prisma-next/contract-authoring@631

@prisma-next/ids

npm i https://pkg.pr.new/@prisma-next/ids@631

@prisma-next/psl-parser

npm i https://pkg.pr.new/@prisma-next/psl-parser@631

@prisma-next/psl-printer

npm i https://pkg.pr.new/@prisma-next/psl-printer@631

@prisma-next/cli

npm i https://pkg.pr.new/@prisma-next/cli@631

@prisma-next/cli-telemetry

npm i https://pkg.pr.new/@prisma-next/cli-telemetry@631

@prisma-next/emitter

npm i https://pkg.pr.new/@prisma-next/emitter@631

@prisma-next/migration-tools

npm i https://pkg.pr.new/@prisma-next/migration-tools@631

prisma-next

npm i https://pkg.pr.new/prisma-next@631

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/@prisma-next/vite-plugin-contract-emit@631

@prisma-next/mongo-codec

npm i https://pkg.pr.new/@prisma-next/mongo-codec@631

@prisma-next/mongo-contract

npm i https://pkg.pr.new/@prisma-next/mongo-contract@631

@prisma-next/mongo-value

npm i https://pkg.pr.new/@prisma-next/mongo-value@631

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/@prisma-next/mongo-contract-psl@631

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/@prisma-next/mongo-contract-ts@631

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/@prisma-next/mongo-emitter@631

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/@prisma-next/mongo-schema-ir@631

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/@prisma-next/mongo-query-ast@631

@prisma-next/mongo-orm

npm i https://pkg.pr.new/@prisma-next/mongo-orm@631

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/@prisma-next/mongo-query-builder@631

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/@prisma-next/mongo-lowering@631

@prisma-next/mongo-wire

npm i https://pkg.pr.new/@prisma-next/mongo-wire@631

@prisma-next/sql-contract

npm i https://pkg.pr.new/@prisma-next/sql-contract@631

@prisma-next/sql-errors

npm i https://pkg.pr.new/@prisma-next/sql-errors@631

@prisma-next/sql-operations

npm i https://pkg.pr.new/@prisma-next/sql-operations@631

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/@prisma-next/sql-schema-ir@631

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/@prisma-next/sql-contract-psl@631

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/@prisma-next/sql-contract-ts@631

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/@prisma-next/sql-contract-emitter@631

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/@prisma-next/sql-lane-query-builder@631

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/@prisma-next/sql-relational-core@631

@prisma-next/sql-builder

npm i https://pkg.pr.new/@prisma-next/sql-builder@631

@prisma-next/target-postgres

npm i https://pkg.pr.new/@prisma-next/target-postgres@631

@prisma-next/target-sqlite

npm i https://pkg.pr.new/@prisma-next/target-sqlite@631

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/@prisma-next/adapter-postgres@631

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/@prisma-next/adapter-sqlite@631

@prisma-next/driver-postgres

npm i https://pkg.pr.new/@prisma-next/driver-postgres@631

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/@prisma-next/driver-sqlite@631

commit: a0c214e

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 29, 2026

size-limit report 📦

Path Size
postgres / no-emit 135.35 KB (0%)
postgres / emit 125.15 KB (0%)
mongo / no-emit 73.85 KB (0%)
mongo / emit 68.85 KB (0%)

…it paths

Two test-helper gaps surfaced after moving preserve-empty/sort rules to
family hooks:

- assert-descriptor-self-consistency: the kind-stripping case computed its
  headRef hash with the SQL preserve-empty hook but recomputed without it,
  causing a spurious mismatch. Pass the hook on both sides.
- psl.pgvector-dbinit: the inline emit dropped empty uniques/indexes/
  foreignKeys arrays (the SQL contract validator requires them), so dbInit
  failed structural validation. Thread sqlContractCanonicalizationHooks
  through the emit, matching the side-by-side fixture test and production
  emit (which gets them from descriptor.contractSerializer).

Signed-off-by: Will Madden <madden@prisma.io>
Comment thread packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts Outdated
Comment thread packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts Outdated
Comment thread packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts Outdated
Comment thread packages/2-sql/1-core/contract/src/canonicalization-hooks.ts Outdated
wmadden and others added 3 commits May 30, 2026 08:24
Records the readability/de-duplication refactor (framework-level path
matcher + storage-sort helper, cast removal, kill the drifted test copy
of the SQL hook) as round-2 actions, with refusal triggers guarding the
foundation-layer no-family-path-knowledge invariant and byte stability.

Signed-off-by: Will Madden <madden@prisma.io>
Move preserve-empty path matching and storage-array sorting into generic
@prisma-next/contract/hashing-utils helpers so SQL and Mongo hooks compose
declarative pattern data instead of imperative walkers. Emitter tests use
the shared mechanism (lint:deps forbids importing sql-contract).

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
Explicit PreserveEmptyPredicate / StorageSort annotations avoid TS2742
when tsdown generates declarations for family hook re-exports.

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
packages/1-framework/3-tooling/emitter/test/hashing.test.ts (1)

11-18: ⚡ Quick win

Consider using the declarative builder pattern for consistency.

Similar to the contract/test/hashing.test.ts file, this could use createPreserveEmptyPredicate from @prisma-next/contract/hashing-utils:

import { createPreserveEmptyPredicate } from '`@prisma-next/contract/hashing-utils`';

const sqlPreserveEmptyPatterns = [
  ['storage', 'namespaces', '*', 'tables'],
] as const;

const SQL_HOOKS = { 
  shouldPreserveEmpty: createPreserveEmptyPredicate(sqlPreserveEmptyPatterns) 
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/3-tooling/emitter/test/hashing.test.ts` around lines 11
- 18, Replace the manual predicate function sqlPreserveEmpty with the
declarative builder: import createPreserveEmptyPredicate from
`@prisma-next/contract/hashing-utils`, define sqlPreserveEmptyPatterns =
[['storage','namespaces','*','tables']] as const, then set
SQL_HOOKS.shouldPreserveEmpty =
createPreserveEmptyPredicate(sqlPreserveEmptyPatterns); ensure you remove the
old sqlPreserveEmpty function and add the import for
createPreserveEmptyPredicate.
packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts (1)

441-446: 💤 Low value

Consider using pathe for consistency.

While node:path works here, the repo prefers pathe for cross-platform path operations. Since this test constructs file paths, using pathe would be more consistent:

const { readFile } = await import('node:fs/promises');
const { fileURLToPath } = await import('node:url');
const { dirname, join } = await import('pathe');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts` around
lines 441 - 446, Replace the use of node:path with pathe for cross-platform
consistency: keep fileURLToPath from 'node:url' and readFile from
'node:fs/promises', but import dirname and join from 'pathe' (the code around
the sourcePath constant that uses dirname(fileURLToPath(import.meta.url)) and
join should remain the same); update the import statement that currently imports
dirname and join from 'node:path' to import them from 'pathe' instead.
packages/1-framework/3-tooling/emitter/test/utils.ts (1)

15-89: ⚡ Quick win

Replace manual hook implementations with declarative builders.

The manual sqlPreserveEmpty and sqlSortStorage implementations could use the builder utilities from @prisma-next/contract/hashing-utils for better maintainability and consistency with canonicalization.test.ts:

♻️ Refactor using builders
+import {
+  createPreserveEmptyPredicate,
+  createStorageSort,
+  type NamedArraySortTarget,
+  type PathPattern,
+} from '`@prisma-next/contract/hashing-utils`';

-function sqlPreserveEmpty(path: readonly string[]): boolean {
-  const len = path.length;
-  if (len < 2 || path[0] !== 'storage') return false;
-  if (path[1] === 'namespaces') {
-    if (len === 4 && path[3] === 'tables') return true;
-    if (len === 5 && path[3] === 'tables') return true;
-    if (
-      len === 6 &&
-      path[3] === 'tables' &&
-      (path[5] === 'uniques' || path[5] === 'indexes' || path[5] === 'foreignKeys')
-    )
-      return true;
-    if (
-      len === 7 &&
-      path[3] === 'tables' &&
-      path[5] === 'foreignKeys' &&
-      (path[6] === 'constraint' || path[6] === 'index')
-    )
-      return true;
-  }
-  if (path[1] === 'types' && len === 4 && path[3] === 'typeParams') return true;
-  return false;
-}
+const sqlPreserveEmptyPatterns = [
+  ['storage', 'namespaces', '*', 'tables'],
+  ['storage', 'namespaces', '*', 'tables', '*'],
+  ['storage', 'namespaces', '*', 'tables', '*', ['uniques', 'indexes', 'foreignKeys']],
+  ['storage', 'namespaces', '*', 'tables', '*', 'foreignKeys', ['constraint', 'index']],
+  ['storage', 'types', '*', 'typeParams'],
+] as const satisfies readonly PathPattern[];

-const sqlSortStorage: StorageSort = (storage) => {
-  if (!storage || typeof storage !== 'object' || Array.isArray(storage)) return storage;
-  const s = storage as Record<string, unknown>;
-  const namespaces = s['namespaces'];
-  if (!namespaces || typeof namespaces !== 'object' || Array.isArray(namespaces)) return storage;
-  const ns = namespaces as Record<string, unknown>;
-  const sortedNs: Record<string, unknown> = {};
-  for (const nsId of Object.keys(ns)) {
-    const nsEntry = ns[nsId];
-    if (!nsEntry || typeof nsEntry !== 'object' || Array.isArray(nsEntry)) {
-      sortedNs[nsId] = nsEntry;
-      continue;
-    }
-    const tables = (nsEntry as Record<string, unknown>)['tables'];
-    if (!tables || typeof tables !== 'object' || Array.isArray(tables)) {
-      sortedNs[nsId] = nsEntry;
-      continue;
-    }
-    const sortedTables: Record<string, unknown> = {};
-    for (const tname of Object.keys(tables as Record<string, unknown>)) {
-      const t = (tables as Record<string, unknown>)[tname];
-      if (!t || typeof t !== 'object' || Array.isArray(t)) {
-        sortedTables[tname] = t;
-        continue;
-      }
-      const tableObj = t as Record<string, unknown>;
-      const sorted: Record<string, unknown> = { ...tableObj };
-      const byName = (a: unknown, b: unknown): number => {
-        const na =
-          a && typeof a === 'object' && 'name' in a && typeof a.name === 'string' ? a.name : '';
-        const nb =
-          b && typeof b === 'object' && 'name' in b && typeof b.name === 'string' ? b.name : '';
-        return na.localeCompare(nb);
-      };
-      if (Array.isArray(tableObj['indexes'])) {
-        sorted['indexes'] = [...tableObj['indexes']].sort(byName);
-      }
-      if (Array.isArray(tableObj['uniques'])) {
-        sorted['uniques'] = [...tableObj['uniques']].sort(byName);
-      }
-      sortedTables[tname] = sorted;
-    }
-    sortedNs[nsId] = { ...(nsEntry as Record<string, unknown>), tables: sortedTables };
-  }
-  return { ...s, namespaces: sortedNs };
-};
+const sqlSortTargets = [
+  { path: ['namespaces', '*', 'tables', '*'], arrayKeys: ['indexes', 'uniques'] },
+] as const satisfies readonly NamedArraySortTarget[];

 const SQL_EMIT_HOOKS = {
-  shouldPreserveEmpty: sqlPreserveEmpty satisfies PreserveEmptyPredicate,
-  sortStorage: sqlSortStorage,
+  shouldPreserveEmpty: createPreserveEmptyPredicate(sqlPreserveEmptyPatterns),
+  sortStorage: createStorageSort(sqlSortTargets),
 } satisfies Pick<CanonicalizeContractOptions, 'shouldPreserveEmpty' | 'sortStorage'>;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/3-tooling/emitter/test/utils.ts` around lines 15 - 89,
The current file contains manual implementations sqlPreserveEmpty and
sqlSortStorage and then composes SQL_EMIT_HOOKS; replace these with the
declarative builder utilities from `@prisma-next/contract/hashing-utils` (as used
in canonicalization.test.ts) to improve maintainability: remove the procedural
logic inside sqlPreserveEmpty and sqlSortStorage and instead construct
equivalent predicates and sorters via the provided builder functions, then
export SQL_EMIT_HOOKS using those builders (ensuring types still satisfy
PreserveEmptyPredicate and CanonicalizeContractOptions keys). Locate
sqlPreserveEmpty, sqlSortStorage, and SQL_EMIT_HOOKS in the diff and swap their
manual logic for the builder-based declarations, preserving behavior for
storage.namespaces.*.tables, indexes, uniques, foreignKeys, and
types.*.typeParams.
packages/1-framework/3-tooling/migration/test/assert-descriptor-self-consistency.test.ts (1)

24-40: ⚡ Quick win

Consider using the declarative builder pattern for maintainability.

The sqlPreserveEmpty predicate could use createPreserveEmptyPredicate from @prisma-next/contract/hashing-utils to make the path patterns more explicit and reduce duplication across test files:

import { createPreserveEmptyPredicate } from '`@prisma-next/contract/hashing-utils`';

const sqlPreserveEmptyPatterns = [
  ['storage', 'namespaces', '*', 'tables'],
  ['storage', 'namespaces', '*', 'tables', '*'],
  ['storage', 'namespaces', '*', 'tables', '*', ['uniques', 'indexes', 'foreignKeys']],
] as const;

const SQL_HOOKS = { 
  shouldPreserveEmpty: createPreserveEmptyPredicate(sqlPreserveEmptyPatterns) 
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/1-framework/3-tooling/migration/test/assert-descriptor-self-consistency.test.ts`
around lines 24 - 40, The current sqlPreserveEmpty predicate is implemented
imperatively and duplicates path pattern logic; replace it by using
createPreserveEmptyPredicate from `@prisma-next/contract/hashing-utils`: define an
array sqlPreserveEmptyPatterns that lists the three patterns
(['storage','namespaces','*','tables'],
['storage','namespaces','*','tables','*'],
['storage','namespaces','*','tables','*', ['uniques','indexes','foreignKeys']])
and then set SQL_HOOKS.shouldPreserveEmpty to
createPreserveEmptyPredicate(sqlPreserveEmptyPatterns); keep the SQL_HOOKS
symbol and remove the handwritten sqlPreserveEmpty function.
packages/1-framework/0-foundation/contract/test/hashing.test.ts (1)

6-18: ⚡ Quick win

Consider using the declarative builder pattern for maintainability.

The manual sqlPreserveEmpty implementation could use createPreserveEmptyPredicate from @prisma-next/contract/hashing-utils (same package). This would match the pattern in canonicalization.test.ts and make the preservation rules more declarative:

import { createPreserveEmptyPredicate } from '`@prisma-next/contract/hashing-utils`';

const sqlPreserveEmptyPatterns = [
  ['storage', 'namespaces', '*', 'tables'],
  ['storage', 'namespaces', '*', 'tables', '*'],
] as const;

const sqlPreserveEmpty = createPreserveEmptyPredicate(sqlPreserveEmptyPatterns);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/0-foundation/contract/test/hashing.test.ts` around lines
6 - 18, Replace the handwritten sqlPreserveEmpty predicate with a declarative
builder: define a const sqlPreserveEmptyPatterns array containing the patterns
['storage','namespaces','*','tables'] and
['storage','namespaces','*','tables','*'], import createPreserveEmptyPredicate
from '`@prisma-next/contract/hashing-utils`', then create sqlPreserveEmpty by
calling createPreserveEmptyPredicate(sqlPreserveEmptyPatterns) and keep
sqlSortStorage and SQL_HOOKS unchanged (ensure types PreserveEmptyPredicate and
StorageSort still match).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/1-framework/0-foundation/contract/src/canonicalization-storage-sort.ts`:
- Around line 14-18: compareByNameProperty relies on localeCompare which is
host/ICU dependent; replace it with a deterministic code‑unit comparison: keep
extracting nameA/nameB as currently done in compareByNameProperty, then if nameA
=== nameB return 0, otherwise return nameA < nameB ? -1 : 1 so ordering is
purely based on Unicode code units and stable across hosts (refer to function
name compareByNameProperty for where to change).

In `@packages/1-framework/0-foundation/contract/test/canonicalization.test.ts`:
- Around line 739-743: The forbidden-token checks currently match hardcoded
quoted strings like "'tables'", "'indexes'", "'uniques'", "'foreignKeys'", and
'sortIndexesAndUniques' which misses the same tokens if double quotes or
template literals are used; update the check to be quote-agnostic by normalizing
tokens before comparison (e.g., strip surrounding single/double/backtick quotes)
or use a regex that accepts ['"`] around the literal (for example matching
/['"`]tables['"`]/), and apply the same change to the other occurrences
referenced (the entries for "tables", "indexes", "uniques", "foreignKeys", and
"sortIndexesAndUniques" and the similar checks at the other location).

In
`@packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts`:
- Around line 259-267: The code uses redundant optional chaining on the required
ControlTargetDescriptor.contractSerializer: locate the emit call where
serializeContract is defined and the spreads using
ifDefined('shouldPreserveEmpty', contractSerializer?.shouldPreserveEmpty) and
ifDefined('sortStorage', contractSerializer?.sortStorage), and replace the
optional chains with direct property accesses
(contractSerializer.shouldPreserveEmpty and contractSerializer.sortStorage) so
the spreads pass the actual optional fields without the unnecessary `?.`.

---

Nitpick comments:
In `@packages/1-framework/0-foundation/contract/test/hashing.test.ts`:
- Around line 6-18: Replace the handwritten sqlPreserveEmpty predicate with a
declarative builder: define a const sqlPreserveEmptyPatterns array containing
the patterns ['storage','namespaces','*','tables'] and
['storage','namespaces','*','tables','*'], import createPreserveEmptyPredicate
from '`@prisma-next/contract/hashing-utils`', then create sqlPreserveEmpty by
calling createPreserveEmptyPredicate(sqlPreserveEmptyPatterns) and keep
sqlSortStorage and SQL_HOOKS unchanged (ensure types PreserveEmptyPredicate and
StorageSort still match).

In `@packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts`:
- Around line 441-446: Replace the use of node:path with pathe for
cross-platform consistency: keep fileURLToPath from 'node:url' and readFile from
'node:fs/promises', but import dirname and join from 'pathe' (the code around
the sourcePath constant that uses dirname(fileURLToPath(import.meta.url)) and
join should remain the same); update the import statement that currently imports
dirname and join from 'node:path' to import them from 'pathe' instead.

In `@packages/1-framework/3-tooling/emitter/test/hashing.test.ts`:
- Around line 11-18: Replace the manual predicate function sqlPreserveEmpty with
the declarative builder: import createPreserveEmptyPredicate from
`@prisma-next/contract/hashing-utils`, define sqlPreserveEmptyPatterns =
[['storage','namespaces','*','tables']] as const, then set
SQL_HOOKS.shouldPreserveEmpty =
createPreserveEmptyPredicate(sqlPreserveEmptyPatterns); ensure you remove the
old sqlPreserveEmpty function and add the import for
createPreserveEmptyPredicate.

In `@packages/1-framework/3-tooling/emitter/test/utils.ts`:
- Around line 15-89: The current file contains manual implementations
sqlPreserveEmpty and sqlSortStorage and then composes SQL_EMIT_HOOKS; replace
these with the declarative builder utilities from
`@prisma-next/contract/hashing-utils` (as used in canonicalization.test.ts) to
improve maintainability: remove the procedural logic inside sqlPreserveEmpty and
sqlSortStorage and instead construct equivalent predicates and sorters via the
provided builder functions, then export SQL_EMIT_HOOKS using those builders
(ensuring types still satisfy PreserveEmptyPredicate and
CanonicalizeContractOptions keys). Locate sqlPreserveEmpty, sqlSortStorage, and
SQL_EMIT_HOOKS in the diff and swap their manual logic for the builder-based
declarations, preserving behavior for storage.namespaces.*.tables, indexes,
uniques, foreignKeys, and types.*.typeParams.

In
`@packages/1-framework/3-tooling/migration/test/assert-descriptor-self-consistency.test.ts`:
- Around line 24-40: The current sqlPreserveEmpty predicate is implemented
imperatively and duplicates path pattern logic; replace it by using
createPreserveEmptyPredicate from `@prisma-next/contract/hashing-utils`: define an
array sqlPreserveEmptyPatterns that lists the three patterns
(['storage','namespaces','*','tables'],
['storage','namespaces','*','tables','*'],
['storage','namespaces','*','tables','*', ['uniques','indexes','foreignKeys']])
and then set SQL_HOOKS.shouldPreserveEmpty to
createPreserveEmptyPredicate(sqlPreserveEmptyPatterns); keep the SQL_HOOKS
symbol and remove the handwritten sqlPreserveEmpty function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: dbea8c9f-8110-48f2-b93e-8941da78e5a3

📥 Commits

Reviewing files that changed from the base of the PR and between ab6eaaa and c6fc971.

⛔ Files ignored due to path filters (6)
  • projects/contract-ir-planes/deferred.md is excluded by !projects/**
  • projects/contract-ir-planes/plan.md is excluded by !projects/**
  • projects/contract-ir-planes/slices/canonicalizer-family-hook/spec.md is excluded by !projects/**
  • projects/contract-ir-planes/slices/construction-discipline-shims/spec.md is excluded by !projects/**
  • projects/contract-ir-planes/slices/migration-element-coordinates/spec.md is excluded by !projects/**
  • projects/contract-ir-planes/spec.md is excluded by !projects/**
📒 Files selected for processing (50)
  • packages/1-framework/0-foundation/contract/package.json
  • packages/1-framework/0-foundation/contract/src/canonicalization-path-match.ts
  • packages/1-framework/0-foundation/contract/src/canonicalization-storage-sort.ts
  • packages/1-framework/0-foundation/contract/src/canonicalization.ts
  • packages/1-framework/0-foundation/contract/src/exports/hashing-utils.ts
  • packages/1-framework/0-foundation/contract/src/exports/hashing.ts
  • packages/1-framework/0-foundation/contract/src/hashing.ts
  • packages/1-framework/0-foundation/contract/src/testing-factories.ts
  • packages/1-framework/0-foundation/contract/test/canonicalization-path-match.test.ts
  • packages/1-framework/0-foundation/contract/test/canonicalization-storage-sort.test.ts
  • packages/1-framework/0-foundation/contract/test/canonicalization.test.ts
  • packages/1-framework/0-foundation/contract/test/hashing.test.ts
  • packages/1-framework/0-foundation/contract/tsdown.config.ts
  • packages/1-framework/1-core/framework-components/src/control/contract-serializer.ts
  • packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts
  • packages/1-framework/3-tooling/emitter/src/emit-types.ts
  • packages/1-framework/3-tooling/emitter/src/emit.ts
  • packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts
  • packages/1-framework/3-tooling/emitter/test/hashing.test.ts
  • packages/1-framework/3-tooling/emitter/test/utils.ts
  • packages/1-framework/3-tooling/migration/src/assert-descriptor-self-consistency.ts
  • packages/1-framework/3-tooling/migration/test/assert-descriptor-self-consistency.test.ts
  • packages/2-mongo-family/1-foundation/mongo-contract/package.json
  • packages/2-mongo-family/1-foundation/mongo-contract/src/canonicalization-hooks.ts
  • packages/2-mongo-family/1-foundation/mongo-contract/src/exports/canonicalization-hooks.ts
  • packages/2-mongo-family/1-foundation/mongo-contract/tsdown.config.ts
  • packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts
  • packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts
  • packages/2-mongo-family/9-family/src/core/control-instance.ts
  • packages/2-mongo-family/9-family/src/core/ir/mongo-contract-serializer-base.ts
  • packages/2-mongo-family/9-family/test/control-instance.descriptor-self-consistency.test.ts
  • packages/2-sql/1-core/contract/package.json
  • packages/2-sql/1-core/contract/src/canonicalization-hooks.ts
  • packages/2-sql/1-core/contract/src/exports/canonicalization-hooks.ts
  • packages/2-sql/1-core/contract/tsdown.config.ts
  • packages/2-sql/2-authoring/contract-ts/src/build-contract.ts
  • packages/2-sql/9-family/src/core/control-instance.ts
  • packages/2-sql/9-family/src/core/ir/sql-contract-serializer-base.ts
  • packages/2-sql/9-family/test/control-instance.descriptor-self-consistency.test.ts
  • packages/3-extensions/cipherstash/test/descriptor.test.ts
  • packages/3-extensions/pgvector/test/descriptor.test.ts
  • test/integration/test/authoring/cli.emit-parity-fixtures.test.ts
  • test/integration/test/authoring/psl.pgvector-dbinit.test.ts
  • test/integration/test/authoring/side-by-side-contracts.test.ts
  • test/integration/test/cli.emit-contract.test.ts
  • test/integration/test/contract-space-fixture-mongo/contract.ts
  • test/integration/test/contract-space-fixture/contract.ts
  • test/integration/test/control-api.test.ts
  • test/integration/test/family.verify-database.basic.test.ts
  • test/integration/test/family.verify-database.errors.test.ts

Comment thread packages/1-framework/0-foundation/contract/test/canonicalization.test.ts Outdated
Comment thread packages/1-framework/3-tooling/cli/src/control-api/operations/contract-emit.ts Outdated
The guard only searched single-quoted path literals, so a hardcoded
'tables'/'indexes'/'uniques'/'foreignKeys' in double quotes or a template
string would slip past. Match the literal regardless of quote style after
stripping comments (doc-comment prose like markdown `indexes` is not a code
literal). Helper-identifier checks keep their plain substring assertions.

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
…izer

contractSerializer is a required field on ControlTargetDescriptor and the
serializeContract closure already dereferences it non-optionally, so
contractSerializer?.shouldPreserveEmpty / ?.sortStorage was redundant
defensive chaining. Read the (still-optional) hook fields directly. Complete
the two partial target mocks that omitted the type-required serializer.

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
Comment thread packages/1-framework/3-tooling/emitter/test/utils.ts Outdated
Comment thread packages/1-framework/3-tooling/emitter/test/utils.ts Outdated
wmadden-electric and others added 7 commits May 30, 2026 12:21
Reconcile S1.D-1 (#630, now on main) with S1.D-2 (#631) hook work:
- sql-contract-serializer-base.ts / build-contract.ts: keep #630's
  materialised-namespace naming + unbound injection, layer #631's
  canonicalization hook threading (shouldPreserveEmpty/sortStorage) on top.
- descriptor-self-consistency test + contract-space fixture: compose the
  shared hooks over #630's namespace bodies.
- pnpm install relinked @prisma-next/utils into sql-contract (dep added by
  #630). Generated fixtures regenerated via scripts; fixtures:check zero-diff.

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
biome organize-imports on the merged serializer-base import block.

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
Apply the shared hashing-utils path-matcher/sort to two more test-helper
sites that still hand-roll the SQL preserve-empty/sort logic.

Signed-off-by: Will Madden <madden@prisma.io>
…rs (R5-R6)

Replace the remaining hand-rolled sqlPreserveEmpty / sqlSortStorage copies in
the emitter test utils and the migration descriptor-self-consistency test with
createPreserveEmptyPredicate / createStorageSort from
@prisma-next/contract/hashing-utils, composed from local SQL pattern data.
Each file's pattern set mirrors its prior predicate exactly, so recomputed
hashes are byte-identical (fixtures:check zero-diff). Comparator unchanged
(localeCompare; deferred to TML-2732).

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
@wmadden wmadden merged commit a91c750 into main May 30, 2026
10 checks passed
@wmadden wmadden deleted the tml-2727-s1d-canonicalizer-hook branch May 30, 2026 15:57
wmadden-electric added a commit that referenced this pull request May 30, 2026
…istory

Replace the three synthetic normal-shape golden cases with cases drawn
from real merged PRs, so the corpus measures Drive runs against work the
team actually shipped rather than synthesised tasks:

- direct-change-example-emit-outputpath (TML-2722 / #618)
- slice-dedupe-generated-imports (TML-2714 / #614)
- project-reap-subsumed-ir-surfaces (TML-2727 / #630, #631, #629) — a
  three-slice parallel fan-out that exercises planner parallelisation and
  scope discipline.

Each real case carries the task as posed (Linear ticket, solution-scrubbed
so the run still does the design/planning), a base_sha to run against, and
a reference.md describing the known-good output by commit SHA (the output
itself is fetchable via git diff <base_sha> <merge_sha>). case.json gains
source + base_sha; the loader ignores the extra fields until the
experiment-engine slice wires base_sha into a checkout.

The two pathological cases (i12-halt, spike-first) stay synthetic: no clean
merged PR exhibits a halted or spiked run.

Update harness tests, SKILL.md examples, and the corpus README for the
renamed slugs. validate-parser fixtures are left as-is — they are
synthetic parser fixtures with tuned event counts, not corpus members.

Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants