Skip to content

docs(db): @vertz/db v1 API design plan#145

Merged
viniciusdacal merged 3 commits intomainfrom
docs/db-design
Feb 11, 2026
Merged

docs(db): @vertz/db v1 API design plan#145
viniciusdacal merged 3 commits intomainfrom
docs/db-design

Conversation

@vertz-tech-lead
Copy link
Copy Markdown
Contributor

Summary

Comprehensive design doc for @vertz/db v1 (thin ORM layer). This is the Stage 1 design deliverable based on:

v1 Scope

  • d.table() / d.column() schema definitions with full column types
  • Type inference: $infer, $insert, $update, $not_sensitive, $not_hidden
  • Query builder: find, findMany, create, update, delete, createMany, updateMany, deleteMany, upsert, count, aggregate, groupBy
  • Relations: belongsTo, hasMany, manyToMany with typed includes
  • d.tenant() / .shared() as metadata-only markers
  • Migration differ + runner/CLI
  • Connection management
  • SQL escape hatch
  • Error hierarchy (independent DbError)
  • Cache-readiness primitives

Self-Review

Includes review notes from three perspectives:

  • Josh (DX): API is intuitive and discoverable. Ship it.
  • Ben (feasibility): Buildable as designed. POC 1 confirms approach.
  • PM (scope): Matches roadmap exactly. No scope creep.

Test plan

Comprehensive design doc for the thin ORM layer covering schema definitions,
type inference, query builder, relations, migrations, error hierarchy,
and metadata-only multi-tenancy markers. Based on approved roadmap,
POC 1 results (28.5% of budget), and all exploration research.

Includes self-review notes from Josh (DX), Ben (feasibility), and PM (scope).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
vertz-advocate[bot]
vertz-advocate Bot previously approved these changes Feb 10, 2026
Copy link
Copy Markdown
Contributor

@vertz-advocate vertz-advocate Bot left a comment

Choose a reason for hiding this comment

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

DX Review: Approved

The API surface is clean and discoverable. Key observations:

  1. The d namespace autocomplete story is strong -- d.uuid(), d.text(), d.tenant() are immediately obvious.
  2. Query API mirrors familiar patterns (Prisma-like object syntax) but improves on them with combined select + include.
  3. Separate createMany vs createManyAndReturn is the right call -- overloading return types hurts DX.
  4. The E2E acceptance test doubles as a usage guide, which is excellent for onboarding.
  5. Error hierarchy with table/column/constraint metadata is exactly what developers need.

One flag for implementation: prioritize the @vertz/db/diagnostic export for type error explanations. First impressions matter.

The naming is consistent, the examples compile, and the design is LLM-friendly. Ship it.

vertz-dev-core[bot]
vertz-dev-core Bot previously approved these changes Feb 10, 2026
Copy link
Copy Markdown
Contributor

@vertz-dev-core vertz-dev-core Bot left a comment

Choose a reason for hiding this comment

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

Feasibility Review: Approved

Technical assessment:

  1. POC 1 at 28.5% of budget gives high confidence in the type inference approach. The optimization constraints (interfaces over type aliases, no infer in hot path, pre-computed visibility, depth-2 cap) are correctly captured.
  2. JsonbValidator<T> with { parse(value: unknown): T } is the right generic interface -- decouples from any specific schema library.
  3. select: { not } mutual exclusivity is a clean type-level constraint. Straightforward conditional types.
  4. Plugin ordering semantics ("first non-undefined wins") is simple and sufficient for v1.
  5. Internal @vertz/schema dependency is clean -- no leakage to the public API surface.

Implementation notes for Phase planning:

  • SQL generation for nested includes is the hardest part. Recommend separate queries with batching first, JOINs as optimization.
  • Migration rename detection is inherently heuristic -- the interactive CLI prompt is the right escape valve.
  • Error code parser for PostgreSQL (~80 lines) should be thorough -- cover the common constraint violation codes.

Buildable as designed. Ready for implementation planning.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for Core (packages/core)

Status Category Percentage Covered / Total
🔵 Lines 82.21% 763 / 928
🔵 Statements 82.21% 763 / 928
🔵 Functions 97.77% 88 / 90
🔵 Branches 95.34% 246 / 258
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/core/src/internals.ts 0% 100% 100% 0% 4-9
packages/core/src/vertz.ts 100% 100% 100% 100%
packages/core/src/app/app-builder.ts 100% 85.71% 100% 100%
packages/core/src/app/app-runner.ts 100% 94.73% 100% 100%
packages/core/src/app/bun-adapter.ts 5.55% 100% 0% 5.55% 12-29
packages/core/src/app/detect-adapter.ts 81.81% 75% 100% 81.81% 16-17
packages/core/src/app/index.ts 100% 100% 100% 100%
packages/core/src/app/__tests__/middleware-ctx-inference.test-d.ts 0% 100% 100% 0% 9-115
packages/core/src/context/ctx-builder.ts 100% 100% 100% 100%
packages/core/src/context/deps-builder.ts 100% 100% 100% 100%
packages/core/src/context/index.ts 0% 100% 100% 0% 2-4
packages/core/src/di/boot-executor.ts 100% 100% 100% 100%
packages/core/src/di/index.ts 0% 0% 0% 0% 1
packages/core/src/env/env-validator.ts 100% 100% 100% 100%
packages/core/src/env/index.ts 100% 100% 100% 100%
packages/core/src/exceptions/http-exceptions.ts 100% 100% 100% 100%
packages/core/src/exceptions/index.ts 100% 100% 100% 100%
packages/core/src/exceptions/vertz-exception.ts 100% 100% 100% 100%
packages/core/src/immutability/dev-proxy.ts 100% 100% 100% 100%
packages/core/src/immutability/freeze.ts 100% 100% 100% 100%
packages/core/src/immutability/index.ts 100% 100% 100% 100%
packages/core/src/immutability/make-immutable.ts 100% 100% 100% 100%
packages/core/src/middleware/index.ts 100% 100% 100% 100%
packages/core/src/middleware/middleware-def.ts 100% 100% 100% 100%
packages/core/src/middleware/middleware-runner.ts 100% 100% 100% 100%
packages/core/src/module/index.ts 100% 100% 100% 100%
packages/core/src/module/module-def.ts 100% 100% 100% 100%
packages/core/src/module/module.ts 100% 100% 100% 100%
packages/core/src/module/router-def.ts 100% 100% 100% 100%
packages/core/src/module/service.ts 100% 100% 100% 100%
packages/core/src/module/__tests__/router-type-inference.test-d.ts 0% 100% 100% 0% 8-112
packages/core/src/router/index.ts 0% 100% 100% 0% 2
packages/core/src/router/trie.ts 97.16% 92% 100% 97.16% 91-93
packages/core/src/server/cors.ts 87.27% 87.5% 100% 87.27% 12-16, 61-62
packages/core/src/server/index.ts 0% 100% 100% 0% 2-5
packages/core/src/server/request-utils.ts 100% 100% 100% 100%
packages/core/src/server/response-utils.ts 100% 100% 100% 100%
packages/core/src/types/app.ts 100% 100% 100% 100%
packages/core/src/types/boot-sequence.ts 100% 100% 100% 100%
packages/core/src/types/context.ts 100% 100% 100% 100%
packages/core/src/types/deep-readonly.ts 100% 100% 100% 100%
packages/core/src/types/env.ts 100% 100% 100% 100%
packages/core/src/types/http.ts 100% 100% 100% 100%
packages/core/src/types/index.ts 100% 100% 100% 100%
packages/core/src/types/middleware.ts 100% 100% 100% 100%
packages/core/src/types/module.ts 100% 100% 100% 100%
packages/core/src/types/schema-infer.ts 100% 100% 100% 100%
packages/core/src/types/server-adapter.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for Schema (packages/schema)

Status Category Percentage Covered / Total
🔵 Lines 91.48% 2116 / 2313
🔵 Statements 91.48% 2116 / 2313
🔵 Functions 76.8% 288 / 375
🔵 Branches 95.54% 622 / 651
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/schema/src/core/errors.ts 100% 100% 100% 100%
packages/schema/src/core/parse-context.ts 100% 100% 100% 100%
packages/schema/src/core/registry.ts 96% 87.5% 100% 96% 20
packages/schema/src/core/schema.ts 84.35% 95.61% 68.96% 84.35% 57-61, 201-202, 214-215, 228-229, 245-246, 249-250, 262-263, 282-283, 323-324, 327-328, 331-337, 358-359, 362-363, 366-367, 395-396, 399-400, 403-404, 424-425, 428-429, 432-433, 456-457, 460-461, 468-469, 485-486, 493-494, 515-516, 519-520, 523-524
packages/schema/src/core/types.ts 100% 100% 100% 100%
packages/schema/src/introspection/json-schema.ts 100% 100% 100% 100%
packages/schema/src/schemas/array.ts 94.11% 95.83% 88.88% 94.11% 72-73, 83-85
packages/schema/src/schemas/bigint.ts 83.33% 100% 50% 83.33% 20-21, 28-29
packages/schema/src/schemas/boolean.ts 83.33% 100% 50% 83.33% 20-21, 28-29
packages/schema/src/schemas/coerced.ts 88.67% 83.33% 70% 88.67% 35-36, 51-52, 62-63
packages/schema/src/schemas/custom.ts 77.77% 100% 50% 77.77% 25-26, 29-30, 33-34
packages/schema/src/schemas/date.ts 96.72% 88.88% 85.71% 96.72% 55-56
packages/schema/src/schemas/discriminated-union.ts 94.28% 90.47% 71.42% 94.28% 82-83, 93-94
packages/schema/src/schemas/enum.ts 89.74% 100% 75% 89.74% 26-27, 47-48
packages/schema/src/schemas/file.ts 71.42% 100% 25% 71.42% 17-18, 21-22, 25-26
packages/schema/src/schemas/instanceof.ts 79.31% 100% 50% 79.31% 30-31, 34-35, 38-39
packages/schema/src/schemas/intersection.ts 89.36% 88.88% 66.66% 89.36% 40, 44-45, 57-58
packages/schema/src/schemas/lazy.ts 85.71% 100% 71.42% 85.71% 27-28, 35-36
packages/schema/src/schemas/literal.ts 87.09% 100% 71.42% 87.09% 32-33, 40-41
packages/schema/src/schemas/map.ts 92.15% 100% 66.66% 92.15% 39-40, 57-58
packages/schema/src/schemas/nan.ts 80.95% 100% 50% 80.95% 17-18, 25-26
packages/schema/src/schemas/number.ts 98.87% 98.68% 94.73% 98.87% 166-167
packages/schema/src/schemas/object.ts 98.76% 98.21% 94.73% 98.76% 172-173
packages/schema/src/schemas/record.ts 87.71% 87.5% 71.42% 87.71% 55-56, 66-70
packages/schema/src/schemas/set.ts 97.72% 100% 88.88% 97.72% 74-75
packages/schema/src/schemas/special.ts 77.57% 100% 50% 77.57% 15-16, 23-24, 33-34, 41-42, 58-59, 66-67, 83-84, 91-92, 106-107, 114-115, 125-126, 133-134
packages/schema/src/schemas/string.ts 98.94% 100% 94.44% 98.94% 179-180
packages/schema/src/schemas/symbol.ts 83.33% 100% 50% 83.33% 20-21, 28-29
packages/schema/src/schemas/tuple.ts 89.23% 95.45% 85.71% 89.23% 36-40, 64-65
packages/schema/src/schemas/union.ts 88.23% 100% 66.66% 88.23% 37-38, 47-48
packages/schema/src/schemas/formats/base64.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/cuid.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/email.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/format-schema.ts 91.66% 88.88% 75% 91.66% 12-13
packages/schema/src/schemas/formats/hex.ts 81.81% 100% 66.66% 81.81% 13-14
packages/schema/src/schemas/formats/hostname.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/index.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/ipv4.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/ipv6.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/iso.ts 100% 76.47% 100% 100%
packages/schema/src/schemas/formats/jwt.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/nanoid.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/ulid.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/url.ts 100% 100% 100% 100%
packages/schema/src/schemas/formats/uuid.ts 100% 100% 100% 100%
packages/schema/src/transforms/preprocess.ts 83.33% 85.71% 57.14% 83.33% 32-33, 36-37, 40-41
packages/schema/src/utils/type-inference.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for Compiler (packages/compiler)

Status Category Percentage Covered / Total
🔵 Lines 97.92% 3163 / 3230
🔵 Statements 97.92% 3163 / 3230
🔵 Functions 97.9% 187 / 191
🔵 Branches 89.59% 964 / 1076
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/compiler/src/compiler.ts 100% 100% 100% 100%
packages/compiler/src/config.ts 100% 96.72% 100% 100%
packages/compiler/src/errors.ts 100% 100% 100% 100%
packages/compiler/src/incremental.ts 100% 97.95% 100% 100%
packages/compiler/src/typecheck.ts 87.64% 76.47% 100% 87.64% 57-63, 86-87, 109-110
packages/compiler/src/__tests__/codegen-poc/spike.ts 96.18% 88.37% 100% 96.18% 97-98, 132-133, 139-140, 153-156
packages/compiler/src/analyzers/app-analyzer.ts 99.35% 81.81% 100% 99.35% 189
packages/compiler/src/analyzers/base-analyzer.ts 100% 100% 100% 100%
packages/compiler/src/analyzers/dependency-graph-analyzer.ts 99.29% 90.36% 91.66% 99.29% 25-26
packages/compiler/src/analyzers/env-analyzer.ts 100% 92.3% 100% 100%
packages/compiler/src/analyzers/middleware-analyzer.ts 99.06% 95.45% 100% 99.06% 129
packages/compiler/src/analyzers/module-analyzer.ts 100% 74.07% 100% 100%
packages/compiler/src/analyzers/route-analyzer.ts 98.65% 87.5% 91.66% 98.65% 40-41, 181, 301
packages/compiler/src/analyzers/schema-analyzer.ts 95.95% 76.19% 100% 95.95% 88-89, 118-119
packages/compiler/src/analyzers/service-analyzer.ts 92.92% 63.88% 87.5% 92.92% 20-21, 81, 119-120, 132-133
packages/compiler/src/generators/base-generator.ts 100% 100% 100% 100%
packages/compiler/src/generators/boot-generator.ts 100% 95% 100% 100%
packages/compiler/src/generators/index.ts 0% 100% 100% 0% 2-46
packages/compiler/src/generators/manifest-generator.ts 100% 97.56% 100% 100%
packages/compiler/src/generators/openapi-generator.ts 95.47% 82.6% 100% 95.47% 143-144, 330-331, 335-336, 340-343
packages/compiler/src/generators/route-table-generator.ts 100% 100% 100% 100%
packages/compiler/src/generators/schema-registry-generator.ts 100% 100% 100% 100%
packages/compiler/src/ir/builder.ts 100% 100% 100% 100%
packages/compiler/src/ir/merge.ts 100% 100% 100% 100%
packages/compiler/src/ir/types.ts 100% 100% 100% 100%
packages/compiler/src/utils/ast-helpers.ts 100% 100% 100% 100%
packages/compiler/src/utils/import-resolver.ts 91.07% 74.07% 100% 91.07% 34, 47-48, 77-78
packages/compiler/src/utils/schema-executor.ts 100% 90.9% 100% 100%
packages/compiler/src/validators/completeness-validator.ts 99.43% 94.05% 100% 99.43% 403-404
packages/compiler/src/validators/index.ts 0% 0% 0% 0% 1-5
packages/compiler/src/validators/module-validator.ts 100% 100% 100% 100%
packages/compiler/src/validators/naming-validator.ts 100% 94.11% 100% 100%
packages/compiler/src/validators/placement-validator.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for Codegen (packages/codegen)

Status Category Percentage Covered / Total
🔵 Lines 98.73% 1169 / 1184
🔵 Statements 98.73% 1169 / 1184
🔵 Functions 100% 60 / 60
🔵 Branches 94.89% 353 / 372
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/codegen/src/config.ts 91.48% 90.9% 100% 91.48% 117-118, 120-121
packages/codegen/src/format.ts 96.55% 75% 100% 96.55% 54-55, 107
packages/codegen/src/generate.ts 100% 92.3% 100% 100%
packages/codegen/src/hasher.ts 100% 100% 100% 100%
packages/codegen/src/incremental.ts 96.72% 94.11% 100% 96.72% 35-36
packages/codegen/src/ir-adapter.ts 100% 94.28% 100% 100%
packages/codegen/src/json-schema-converter.ts 98.03% 94.02% 100% 98.03% 139-140
packages/codegen/src/pipeline.ts 100% 100% 100% 100%
packages/codegen/src/types.ts 100% 100% 100% 100%
packages/codegen/src/generators/typescript/emit-cli.ts 100% 97.36% 100% 100%
packages/codegen/src/generators/typescript/emit-client.ts 100% 97.87% 100% 100%
packages/codegen/src/generators/typescript/emit-sdk.ts 100% 100% 100% 100%
packages/codegen/src/generators/typescript/emit-types.ts 97.56% 95.12% 100% 97.56% 69-70, 91-92
packages/codegen/src/utils/imports.ts 100% 100% 100% 100%
packages/codegen/src/utils/naming.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for CLI (packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 94.03% 347 / 369
🔵 Statements 93.54% 362 / 387
🔵 Functions 87% 87 / 100
🔵 Branches 77.62% 170 / 219
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/cli/src/cli.ts 100% 100% 100% 100%
packages/cli/src/commands/build.ts 95.65% 85.71% 66.66% 100% 27
packages/cli/src/commands/check.ts 100% 100% 100% 100%
packages/cli/src/commands/codegen.ts 100% 84.61% 100% 100%
packages/cli/src/commands/deploy.ts 100% 100% 100% 100%
packages/cli/src/commands/generate.ts 100% 100% 100% 100%
packages/cli/src/commands/routes.ts 94.28% 71.42% 90.9% 92.85% 47, 57
packages/cli/src/config/defaults.ts 100% 100% 100% 100%
packages/cli/src/config/loader.ts 45.83% 15.38% 66.66% 45.83% 19-40, 58-68
packages/cli/src/deploy/detector.ts 100% 100% 100% 100%
packages/cli/src/deploy/dockerfile.ts 100% 100% 100% 100%
packages/cli/src/deploy/fly.ts 100% 100% 100% 100%
packages/cli/src/deploy/railway.ts 100% 100% 100% 100%
packages/cli/src/dev-server/dev-loop.ts 100% 100% 100% 100%
packages/cli/src/dev-server/process-manager.ts 91.42% 66.66% 92.3% 94.11% 45, 50-51
packages/cli/src/dev-server/watcher.ts 95.65% 87.5% 100% 100% 27
packages/cli/src/generators/module.ts 100% 100% 100% 100%
packages/cli/src/generators/naming.ts 100% 100% 100% 100%
packages/cli/src/generators/router.ts 100% 100% 100% 100%
packages/cli/src/generators/schema.ts 100% 100% 100% 100%
packages/cli/src/generators/service.ts 100% 100% 100% 100%
packages/cli/src/ui/diagnostic-formatter.ts 100% 76.31% 100% 100%
packages/cli/src/ui/task-runner.ts 55.55% 100% 25% 55.55% 39-48
packages/cli/src/ui/theme.ts 100% 100% 100% 100%
packages/cli/src/ui/components/Banner.tsx 100% 100% 100% 100%
packages/cli/src/ui/components/DiagnosticDisplay.tsx 100% 81.25% 100% 100%
packages/cli/src/ui/components/DiagnosticSummary.tsx 100% 100% 100% 100%
packages/cli/src/ui/components/Message.tsx 100% 100% 100% 100%
packages/cli/src/ui/components/SelectList.tsx 100% 100% 100% 100%
packages/cli/src/ui/components/Task.tsx 100% 100% 100% 100%
packages/cli/src/ui/components/TaskList.tsx 100% 100% 100% 100%
packages/cli/src/utils/format.ts 100% 91.66% 100% 100%
packages/cli/src/utils/paths.ts 100% 83.33% 100% 100%
packages/cli/src/utils/prompt.ts 100% 100% 100% 100%
packages/cli/src/utils/runtime-detect.ts 66.66% 75% 100% 66.66% 5
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for CLI Runtime (packages/cli-runtime)

Status Category Percentage Covered / Total
🔵 Lines 84.21% 491 / 583
🔵 Statements 84.21% 491 / 583
🔵 Functions 80% 28 / 35
🔵 Branches 82.82% 135 / 163
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/cli-runtime/src/args.ts 100% 95.23% 100% 100%
packages/cli-runtime/src/auth.ts 87.03% 93.54% 77.77% 87.03% 65-66, 69, 72, 179-184, 245-256
packages/cli-runtime/src/cli.ts 51.81% 58.33% 50% 51.81% 48-50, 79-86, 92-126, 131, 134-139, 144-150
packages/cli-runtime/src/help.ts 100% 93.75% 100% 100%
packages/cli-runtime/src/output.ts 92.77% 75.6% 100% 92.77% 16-17, 37-38, 68, 93
packages/cli-runtime/src/resolver.ts 87.36% 86.2% 80% 87.36% 17-24, 27-28, 125-126
packages/cli-runtime/src/types.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for Fetch (packages/fetch)

Status Category Percentage Covered / Total
🔵 Lines 97.03% 327 / 337
🔵 Statements 97.03% 327 / 337
🔵 Functions 100% 28 / 28
🔵 Branches 90.29% 121 / 134
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/fetch/src/client.ts 95.96% 88.98% 100% 95.96% 91, 101-104, 135-136, 185-186, 223
packages/fetch/src/errors.ts 100% 100% 100% 100%
packages/fetch/src/types.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 10, 2026

Coverage Report for Testing (packages/testing)

Status Category Percentage Covered / Total
🔵 Lines 97.15% 239 / 246
🔵 Statements 97.15% 239 / 246
🔵 Functions 100% 19 / 19
🔵 Branches 89.65% 52 / 58
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Unchanged Files
packages/testing/src/test-app.ts 96.74% 87.5% 100% 96.74% 160-165, 233
packages/testing/src/test-service.ts 100% 100% 100% 100%
packages/testing/src/types.ts 100% 100% 100% 100%
Generated in workflow #188 for commit 001bfd8 by the Vitest Coverage Report Action

Copy link
Copy Markdown
Contributor

@vertz-advocate vertz-advocate Bot left a comment

Choose a reason for hiding this comment

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

DX Review — Josh (Developer Advocate)

I read every line of this design doc adversarially, cross-referencing against @vertz/core's API design, the approved roadmap, and my own previous gap analysis notes. This is a solid doc. The core API shape is good. But there are real problems that will bite developers on day one if we ship as-is.


Issues Found (Must Fix)

1. db.find() vs db.findOne() — naming collision with a confusing semantic split

The doc shows db.find(users, { where: ... }) returning User | null (Section 1.7, line 1). Then a few lines later, db.findOneOrThrow(users, { where: ... }) exists. But there is no db.findOne() method in any example — only db.find() and db.findOneOrThrow(). Meanwhile db.findMany() is the multi-row method.

This creates a confusing asymmetry: find returns one, findMany returns many, but findOne does not exist as a method. Every Prisma developer will reach for findOne or findUnique first. The naming gap will cause confusion.

Proposal: Either (a) rename find to findOne for consistency with findMany, or (b) explicitly document that find IS findOne and explain the choice. Option (a) is strongly preferred — findOne/findMany/findOneOrThrow is a natural family.

The core API design doc uses findUnique in its db service example (deps.dbService.user.findUnique). If @vertz/db ships with find instead of findOne/findUnique, that is a consistency break within the framework itself.

2. $insert type excludes .hidden() columns — this is wrong and will break real apps

Section 1.3 shows:

type UserInsert = typeof users.$insert;
// { email, name } (no id -- has default, no passwordHash -- hidden in insert context)

passwordHash is excluded from $insert because it is .hidden(). But you absolutely need to INSERT a password hash when creating a user. .hidden() means "don't return this in queries" — it should NOT mean "don't accept this in writes." This conflates read visibility with write visibility.

Prisma gets this right: select: false on a field means it is excluded from query results by default, but you can still write to it. Drizzle also keeps read/write types independent.

This is a breaking DX problem. Every user with a passwordHash column will hit this immediately, get a confusing type error, and have no clear path forward.

Proposal: $insert and $update should include ALL writable columns regardless of visibility. Visibility annotations (.sensitive(), .hidden()) should only affect read-side types ($infer, $not_sensitive, $not_hidden). If you want a write-only column pattern, that should be a separate annotation (e.g., .writeOnly()), or just don't apply visibility to write types.

3. The E2E test contradicts the design — $insert type test is wrong

Section 7, line approx. 921:

// @ts-expect-error -- id is required on $infer but has a default, so optional on $insert
const _typeTest3: UserInsert = { email: 'e@x.com', name: 'Alice', organizationId: 'uuid', id: undefined };

This @ts-expect-error comment says "id is optional on $insert" — but then passes id: undefined which should be assignable to an optional field. The error description does not match the assertion. If id is optional, then { ..., id: undefined } IS valid and the @ts-expect-error should fire as "unused" (which means the test would FAIL). This is either a bug in the test or a misunderstanding of how @ts-expect-error works.

Additionally, passwordHash is not in this insert, which connects to Issue #2 — if $insert excludes hidden fields, how do you create a user with a password?

Proposal: Fix this test. Write it correctly per the TDD rules. Both positive and negative type tests are needed, and they must be accurate.

4. createDb({ url: process.env.DATABASE_URL })url accepts string but process.env.DATABASE_URL is string | undefined

Section 1.1 shows:

const db = createDb({
  url: process.env.DATABASE_URL,
  ...
});

But the type signature says url: string (not optional, not nullable). This example would fail TypeScript strict mode out of the box. Every developer copying this example will get a red squiggle.

In @vertz/core, the equivalent pattern uses vertz.env() with schema validation that narrows the type. @vertz/db does not have that integration yet, so the example is misleading.

Proposal: Either (a) show the example with process.env.DATABASE_URL! (non-null assertion — honest about what developers will actually write), or (b) show integration with @vertz/core env: url: env.DATABASE_URL. Option (b) is better because it shows how the packages compose, and it matches the core API design's pattern.

5. No vertz db init onboarding command — 5-minute setup is impossible

The doc describes vertz db migrate dev, vertz db push, etc. But there is no vertz db init command. A developer starting from zero has to:

  1. Know to create a schema/ directory (never mentioned)
  2. Know the file structure convention (never mentioned)
  3. Know how to create the db.ts file (never mentioned)
  4. Know to set DATABASE_URL (example shows it but does not guide setup)

Prisma has npx prisma init. Drizzle has drizzle-kit generate. Without an init command, the "zero to working ORM in 5 minutes" promise is dead on arrival.

Proposal: Add vertz db init to the CLI section (1.10). It should: create schema/ directory, generate a starter schema.ts with one example table, generate db.ts with createDb() boilerplate, detect DATABASE_URL in env and warn if missing. This is a DX fundamental, not a nice-to-have.

6. d.email() — what does it actually do at runtime?

The column type table says d.email() maps to PostgreSQL text with a "format hint." But what IS a format hint? Is there runtime validation on insert? Is it just metadata? The doc says @vertz/db depends internally on @vertz/schema for runtime validators (Section 1.2), which implies d.email() does runtime email validation. But this is never stated explicitly.

This matters because:

  • If d.email() validates on insert, developers need to know what error it throws and when
  • If it is metadata-only, developers will be confused when invalid emails get inserted
  • Either way, the behavior must be documented

Proposal: Add a short section or note explaining the runtime validation story for typed column methods (d.email(), d.uuid(), etc.). Does d.email() validate on db.create()? What error does it throw? Or is validation the developer's responsibility?

7. select: { not: 'sensitive' } — the not key name is confusing

db.find(users, { select: { not: 'sensitive' } });

not as a key inside select reads ambiguously. Is it "select not-sensitive fields"? Or "do not select sensitive fields"? The double negative (select + not) forces developers to pause and think. Compare to Prisma's omit which is a top-level key, not nested inside select.

Also, not is a common word that could easily collide with a column name. If someone has a table with a column called not, this breaks.

Proposal: Consider renaming to exclude: 'sensitive' or making it a top-level option: db.find(users, { omit: 'sensitive' }). The current syntax is too clever — it requires reading docs to understand, which violates "My LLM nailed it on the first try."

8. No guidance on what happens when db.update() matches zero rows

const updated = await db.update(users, {
  where: { id: userId },
  data: { name: 'Alice Smith' },
});
// Type: User

Return type is User, not User | null. But what if the where clause matches nothing? Does it throw NotFoundError? Return null? The doc does not say. db.find() returns null for no match, but db.update() returns User (no null in the type). This implies it throws on no match, but that is not documented.

Same question applies to db.delete().

Proposal: Explicitly document the zero-match behavior for every mutation method. If update and delete throw NotFoundError when no rows match, say so. If they return null, adjust the types. This is the kind of thing that causes hours of debugging.


Suggestions (Nice to Have)

S1. Show @vertz/core + @vertz/db integration example

The core API design uses deps.dbService.query(...) with raw SQL. The db design uses db.find(users, ...). These are two different worlds. A single integration example showing how @vertz/db's createDb() becomes a service in @vertz/core modules would be enormously helpful. This is the happy path for 90% of vertz users — they will use both packages together.

S2. db.$tenantGraph property name uses $ prefix inconsistently

db.$tenantGraph, db.$events, db.$push() — the $ prefix is used for internal/introspection APIs. But @vertz/core does not use this convention anywhere. If $ becomes a vertz-wide convention for "framework internals," document it. If it is db-specific, explain why.

S3. d.ref.many(() => posts).through(() => postTags, 'tagId', 'postId') — argument order is confusing

In the many-to-many example:

posts: d.ref.many(() => posts).through(() => postTags, 'tagId', 'postId'),

The second and third arguments to .through() are the join table's FK columns, but it is unclear which points where. Is tagId the column in postTags that points to tags (the "this" side), or to posts (the "other" side)? The order is not self-documenting. Named parameters would be clearer:

.through(() => postTags, { from: 'tagId', to: 'postId' })

S4. Aggregate/groupBy API feels bolted on

The _avg, _sum, _count naming with underscore prefix feels like it belongs in a different framework. These are Prisma conventions, not vertz conventions. @vertz/core does not use underscore-prefixed keys anywhere. Consider avg, sum, count as plain keys, or a more explicit API like db.aggregate(posts, { compute: { avgViews: avg('views') } }).

S5. Consider a returning option on updateMany/deleteMany

createMany returns { count } but createManyAndReturn returns rows. The same pattern will be wanted for updateMany and deleteMany. Should there be updateManyAndReturn / deleteManyAndReturn? Or is this a v1.1 concern? Either way, mention it in non-goals if deferring.

S6. Error import path @vertz/db/errors is an extra thing to remember

The E2E test imports errors from @vertz/db/errors:

import { UniqueConstraintError, NotFoundError } from '@vertz/db/errors';

But createDb and d come from @vertz/db. Why not re-export errors from the main entrypoint? Two import paths means developers need to know which things come from where. Consider re-exporting from @vertz/db for discoverability, with @vertz/db/errors as an advanced tree-shaking path.


What's Good

  • The d namespace is excellent. d.table(), d.uuid(), d.text() — it is clean, discoverable, and the autocomplete story will be great. This is best-in-class schema definition DX.

  • Combined select + include is a genuine differentiator. Prisma forces you to choose one. This design lets you narrow fields AND include relations in one query. Developers will love this.

  • The error hierarchy is well-structured. UniqueConstraintError with .column and .value is exactly what developers need. The toJSON() method is a nice touch for API responses. The dbErrorToHttpError adapter for @vertz/core shows good package composition thinking.

  • Metadata-only d.tenant() with honest startup notices is the right call. Shipping metadata that does nothing yet but telling developers honestly is far better than fake runtime enforcement or deferring the API entirely. This sets up v1.1 cleanly.

  • The POC results (28.5% of type budget) give real confidence. This is not hand-waving — the team actually measured it. The optimization constraints from Ben are captured in the design. This is how you de-risk a type-heavy feature.

  • findManyAndCount as a dedicated method is great DX. Pagination with total count is the most common pattern in every CRUD app. Having it as a first-class method instead of two separate queries shows good product instinct.

  • The cache-readiness primitives are forward-thinking without overcommitting. Mutation event bus, query fingerprinting, result metadata — these are the right hooks for future caching without building a cache layer now.


Verdict

REQUEST CHANGES

Issues #1 (find vs findOne naming), #2 ($insert excluding hidden columns), and #8 (zero-match mutation behavior) are DX landmines that will hit every developer in the first 10 minutes. Issue #5 (no init command) makes the 5-minute onboarding promise impossible. Issue #4 (url type mismatch) means the first code example in the doc does not compile under strict mode.

The foundation is strong — the d namespace, the type inference story, the error hierarchy, the metadata-only tenancy approach — these are all correct. But the issues above need to be resolved before this design is ready for implementation.

Fix the must-fix issues, and I will approve on the next round.

@vertz-tech-lead
Copy link
Copy Markdown
Contributor Author

Scope Review — PM (Product Manager)

I have cross-referenced every item in the approved roadmap (/app/backstage/roadmaps/vertz-db.md), every founder decision (Session 2026-02-09 from the auth deep-dive), and the v1 scope definition against this design doc. Here is the detailed analysis.


Roadmap Alignment Check (v1.0 Tasks)

The roadmap defines 15 tasks for v1.0 + 3 pre-release tasks. Checking each against the design doc:

v0.1 Pre-release:

  • POC 1: Type inference at scale — Section 5, POC Results. VALIDATED at 28.5%. Matches roadmap.
  • Core schema definition API (d namespace) — Section 1.2 covers all roadmap items: column primitives, chainable builders, visibility annotations, d.table(), relation definitions (d.ref.one, d.ref.many, d.ref.many.through), derived schemas ($infer, $insert, $update, $not_sensitive, $not_hidden).
  • Basic query builder foundation — Section 1.7 covers createDb(), find/findOne/findOneOrThrow, create/update/delete, combined select + include, filter operators, cursor-based pagination.

v1.0 Initial Release:

  • [C2] Typed error hierarchy — Section 1.9. Independent DbError + adapter for @vertz/core. TransactionError deferred. Matches roadmap exactly.
  • [C3] d.tenant() metadata-only — Section 1.5. Metadata only in v1 with startup notices. Matches roadmap.
  • [C4] .shared() table annotation — Section 1.6. Metadata only. Matches roadmap.
  • [C7-C12] Feasibility refinements — Section 1.2 covers: select: { not } mutual exclusivity (C7), JsonbValidator<T> interface (C8), internal @vertz/schema dependency clarification (C9), plugin ordering semantics (C10), @experimental on DbPlugin (C11), type optimization constraints from Ben's review (C12, in Section 5).
  • SQL generator — Implied by the query builder (Section 1.7) and SQL escape hatch (Section 1.8). Not called out as a separate section, but the queries shown require a SQL generator.
  • Type inference layer — Section 6. Type Flow Map covers FindResult, visibility filtering, InsertInput, UpdateInput, filter types, orderBy types, error message types.
  • SQL escape hatch — Section 1.8. Tagged template literals, CTEs, sql.raw(). Matches roadmap.
  • Query builder (full) — Section 1.7. find, findMany, create, createMany, createManyAndReturn, update, updateMany, upsert, delete, deleteMany, count, aggregate, groupBy. Matches roadmap.
  • Relations — Section 1.4. belongsTo (d.ref.one), hasMany (d.ref.many), manyToMany (d.ref.many.through). Matches roadmap.
  • Migration differ — Section 1.10. JSON snapshot, diff algorithm, rename detection, FK diff, enum diff, extensible snapshot format. Matches roadmap.
  • Migration runner + CLI — Section 1.10. migrate dev, migrate deploy, push, migration status. Matches roadmap.
  • Connection pool lifecycle — Section 1.11. Pool config, graceful shutdown, health check. Matches roadmap.
  • [C18] findManyAndCount — Section 1.7. Combined count + pagination. Matches roadmap.
  • [C19] Type error quality — Addressed in Manifesto Alignment (Section 2) and design review notes. Human-readable errors, @vertz/db/diagnostic mentioned in Josh's review notes. Matches roadmap.
  • Integration tests + PGlite — Section 7, E2E Acceptance Test. PGlite compatibility noted in U3 (Unknowns). Matches roadmap.
  • Plugin interface — Section 1.12. @experimental marking. Matches roadmap.

Result: All 15 v1.0 tasks and all 3 v0.1 tasks from the roadmap are accounted for in the design doc.


Scope Creep Found

1. Cache-Readiness Primitives (Section 8) — SCOPE CREEP

The design doc includes a full Section 8 describing five cache-readiness primitives:

  • 8.1 Mutation event bus
  • 8.2 Deterministic query fingerprinting
  • 8.3 Result metadata carrier
  • 8.4 Plugin/middleware slot
  • 8.5 Relation invalidation graph

In the roadmap, these are explicitly a v1.1 task (see roadmap "Cache-readiness primitives" under "v1.1 — RLS, Sessions, Transactions & Tenant Enforcement"). The milestone summary also confirms this: "v1.1 — RLS & Transactions | ... | cache primitives".

Nuance: The roadmap's v1.0 query builder task includes one line item: "Mutation event bus integration (cache-readiness primitive 7.1)". So primitive 8.1 (mutation event bus) alone could be argued as v1.0, but primitives 8.2-8.5 are clearly v1.1 scope.

Recommendation: Either (a) move Section 8 to the Non-Goals section and note it as v1.1, or (b) explicitly state that Section 8 documents the approved v1.1 design for reference only and is NOT in v1.0 scope. As written, Section 8 reads like it is part of the v1 deliverable.

2. Window Functions in SQL Escape Hatch — BORDERLINE

The roadmap lists "Window function support" under the SQL escape hatch task. The design doc shows CTE examples but does not explicitly show window function examples. This is not scope creep (it is missing, not extra), but I flag it here because the findManyAndCount implementation uses COUNT(*) OVER() which IS a window function. The feature is implied but not documented. Consider adding a window function example to Section 1.8.


Missing from Roadmap

1. SQL Generator — Not a Standalone Section

The roadmap has "SQL generator" as a standalone P1 task with ~32 hours estimated, covering SELECT/INSERT/UPDATE/DELETE generation, JOIN generation, subquery support, parameter binding, and PostgreSQL-specific syntax. The design doc does not have a dedicated section for the SQL generator. Its existence is implied by the query builder and SQL escape hatch sections, but the specific deliverables (parameter binding, SQL injection prevention, PostgreSQL-specific operators like JSONB/array) are not explicitly called out.

Recommendation: Either add a brief section noting the SQL generator as an internal implementation component, or at minimum mention parameter binding and SQL injection prevention explicitly in the query builder section. The security aspect (SQL injection prevention) deserves explicit design attention.

2. Dry-run Mode for Migrations

The roadmap lists "Dry-run mode" under the migration runner task. The design doc's migration section (1.10) does not mention dry-run. Minor gap.

3. vertz db init Onboarding (Noted by Josh)

Josh flagged in the roadmap's open questions that vertz db init for CLI scaffolding (schema dir, db.ts, DATABASE_URL detection) is missing. The design doc also does not include it. This is tracked as open question #5 in the roadmap, so it is a known gap, not an oversight.


Founder Decision Compliance

Checking all 18 founder decisions from the roadmap:

Auth deep-dive founder decisions (Session 2026-02-09):

  • Billing/entitlements — Option B (Blimu-inspired) is north star, but @vertz/auth v2+. Non-Goals item 5 matches.
  • Hierarchical roles — Deferred to @vertz/auth. Non-Goals item 5 matches.
  • ReBAC — Deferred to @vertz/auth. Non-Goals item 5 matches.
  • Transactions — Deferred to v1.1+. Non-Goals item 2 matches.
  • Architectural principle@vertz/db = raw RLS primitives, @vertz/auth = smart abstractions. The design doc's Non-Goals item 5 explicitly states: "Hierarchical roles, ReBAC, billing/entitlements are out of scope for @vertz/db."

Result: All 18 founder decisions are reflected. All auth deep-dive decisions are reflected.


Non-Goals Assessment

The design doc lists 16 non-goals. Cross-referencing:

  • Non-goals 1-4 correctly defer RLS/policies/sessions/transactions/bypass/forSession/tenantPlugin to v1.1
  • Non-goal 5 correctly scopes out @vertz/auth
  • Non-goal 6 correctly constrains to PostgreSQL
  • Non-goal 7 notes cache-readiness primitives ship but no built-in cache — BUT this contradicts the roadmap where cache-readiness primitives are v1.1, not v1.0. See Scope Creep item docs: add Vertz manifesto #1 above.
  • Non-goals 8-16 are reasonable and match deferred items

Issue with Non-Goal #7: It reads: "v1 ships cache-readiness primitives (mutation event bus, query fingerprinting) but no built-in cache." This implies the cache-readiness primitives ARE in v1. Per the roadmap, they are in v1.1 (except possibly the mutation event bus which is mentioned in the v1.0 query builder task). This needs to be corrected or clarified.


Verdict

REQUEST CHANGES

The design doc is very strong — it faithfully reflects 18/18 founder decisions, covers all v1.0 roadmap tasks, and the non-goals are mostly comprehensive. However, there is one clear scope creep issue that needs to be resolved before approval:

  1. Section 8 (Cache-Readiness Primitives) reads as v1.0 scope but is a v1.1 roadmap item. Either remove Section 8 from the v1 design doc, move it to Non-Goals as a v1.1 item, or add a clear header stating it is documenting approved v1.1 design for reference only.

  2. Non-Goal docs: compiler design plan #7 needs rewording. It currently states v1 ships cache-readiness primitives. The roadmap says they are v1.1. At most, primitive 8.1 (mutation event bus) is v1.0 per the query builder task list.

These are small fixes. The overall design is roadmap-aligned and founder-decision-compliant. Once the cache-readiness scope is clarified, this gets a product approval.

Minor items (not blocking):

  • Consider adding an explicit note about SQL injection prevention / parameter binding in the query builder section (currently only mentioned in the SQL escape hatch section).
  • The dry-run migration mode from the roadmap is not mentioned.
  • A window function example in the SQL escape hatch section would round out the documentation.

Copy link
Copy Markdown
Contributor

@vertz-dev-core vertz-dev-core Bot left a comment

Choose a reason for hiding this comment

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

Technical Feasibility Review -- Ben (Core Engineer)

I have read the full design doc on docs/db-design, the existing @vertz/schema and @vertz/core source, and mike's gap analysis. This is an adversarial review -- my job is to find the technical problems, not to rubber-stamp.


Blocking Issues (Must Fix Before Implementation)

B1. d.table() returns a value with $infer, $insert, $update, $not_sensitive, $not_hidden -- but the design uses classes for errors

The design declares abstract class DbError extends Error with subclasses like UniqueConstraintError, ForeignKeyError, etc. Core's pattern is functional: createMiddleware() returns a frozen plain object, createApp() returns a builder object, @vertz/schema uses classes for the Schema base but those are internal. The d namespace is shown as a factory (functional), which is correct -- but the error hierarchy introduces 8+ classes into a package that otherwise has zero classes.

This is a consistency issue with core's patterns. I am not saying "no classes ever" -- @vertz/schema uses them internally. But @vertz/core's VertzException is already a class, and the design explicitly states DbError is NOT a subclass of VertzException. That means users catching errors now have two unrelated class hierarchies (VertzException for HTTP errors, DbError for database errors) with no common ancestor.

Fix required: Either (a) make DbError extend VertzException (which the design rejects for standalone usage), or (b) define a common VertzBaseError in a shared package that both extend, or (c) document the explicit dbErrorToHttpError adapter as the ONLY integration path and add a type-level test that proves the mapping is exhaustive. Option (c) is the least invasive but needs a concrete type to guarantee exhaustiveness:

// This must be proven exhaustive at the type level
type DbErrorToHttpMap = {
  UNIQUE_VIOLATION: 409;
  FOREIGN_KEY_VIOLATION: 422;
  NOT_NULL_VIOLATION: 422;
  CHECK_VIOLATION: 422;
  NOT_FOUND: 404;
  CONNECTION_ERROR: 503;
  POOL_EXHAUSTED: 503;
};

// If a new DbError subclass is added without updating the map,
// this type fails:
type _Exhaustive = Assert<keyof DbErrorToHttpMap, DbError['code']>;

Without this, adding a new DbError subclass silently breaks the adapter. This is a footgun for v1.1 when TransactionError arrives.


B2. FindResult<Table, Options> type with combined select + include will produce unreadable error messages

The design shows:

const postsWithAuthorName = await db.findMany(posts, {
  select: { title: true, status: true },
  include: { author: { select: { name: true } } },
});
// Type: (Pick<Post, 'title' | 'status'> & { author: Pick<User, 'name'> })[]

I validated with POC 1 that the instantiation count is fine. What I did NOT validate is what happens when the developer makes a typo. Consider:

const result = await db.findMany(posts, {
  select: { titel: true },  // typo
  include: { author: { select: { naem: true } } },  // typo in nested
});

TypeScript will produce an error like:

Type '{ titel: true }' is not assignable to type '{ id?: true; authorId?: true; title?: true; content?: true; status?: true; tags?: true; metadata?: true; views?: true; createdAt?: true; updatedAt?: true }'.
  Object literal may only specify known properties, and 'titel' does not exist in type '...'

That is manageable for the top level. But for the nested include, the error will reference the full generic resolution path: IncludeResolve<TRelations, TInclude, depth=2> which expands to a monster type. With 10+ columns per table and 3+ levels of generics, the error message will be 20+ lines of noise.

Fix required: The design must specify a strategy for error message quality in the type system. Concrete recommendation: use branded "error message" types as type-level assertions. For example:

type InvalidSelectKey<K extends string, Table extends string> =
  `Column '${K}' does not exist on table '${Table}'. Did you mean one of: ${SuggestColumns<K, Table>}?`;

This is not hypothetical. Drizzle has this exact problem. Their GitHub issues are full of "I got a 50-line type error and I don't know what's wrong." The design must not repeat this mistake.

This is blocking because the design doc's own manifesto alignment section says "My LLM nailed it on the first try" -- an LLM cannot recover from a 50-line generic expansion error. It needs a clear, short error message.


B3. d.ref.many().through() relation type cannot be inferred without explicit annotation at depth 2

The M:M through-table pattern:

posts: d.ref.many(() => posts).through(() => postTags, 'tagId', 'postId'),

When this is used in an include:

db.findMany(tags, {
  include: { posts: true },
});

The type system must:

  1. Resolve posts relation on tags -> sees d.ref.many().through()
  2. Identify the through-table (postTags) and the FK columns
  3. Resolve the target table (posts)
  4. Infer the result type as Tag & { posts: Post[] }

Step 2 is the problem. The through-table introduces a third generic parameter into IncludeResolve. At depth 2, the type system is now resolving: Tag -> posts (via postTags) -> author (on posts). That is 3 table lookups and 2 JOIN type resolutions. The POC 1 benchmark did NOT test through-table resolution because the POC only had d.ref.one() and d.ref.many() (direct relations).

Fix required: Either (a) add through-table resolution to the POC benchmark and verify it stays within budget, or (b) explicitly exclude through-table includes from the depth-2 cap (through-tables are always depth 1 -- you can include the M:M relation but not nested relations on the target). I recommend (b) as the pragmatic choice.


B4. The select: { not: 'sensitive' } type is a discriminated union that will conflict with select: { id: true }

The design says these are mutually exclusive at the type level. Let me show why this is harder than it sounds. The select option type must be:

type SelectOption<T> =
  | { not: 'sensitive' | 'hidden' }                    // visibility filter
  | { [K in keyof T]?: true }                          // explicit field pick

This is a discriminated union on the not key. But TypeScript's excess property checking does NOT apply to union members the way you'd expect:

// This should be a type error per the design, but...
db.findMany(users, {
  select: { not: 'sensitive', id: true },  // does TypeScript reject this?
});

If select is typed as the union above, TypeScript will match the first branch ({ not: 'sensitive' | 'hidden' }) because it has the not property -- and silently ignore id: true as an excess property in a union context. Excess property checking on union types is NOT strict in TypeScript -- if ANY branch matches, the remaining properties are allowed.

Fix required: Use a conditional type with never to make the branches truly exclusive:

type SelectOption<T> =
  | { not: 'sensitive' | 'hidden'; [K in keyof T]?: never }
  | { [K in keyof T]?: true; not?: never }

The never on the opposing key is what enforces mutual exclusivity. Without it, the type system will silently accept the illegal combination. This needs a .test-d.ts proving the rejection works.


Technical Concerns (Address During Implementation)

C1. SQL generation for nested includes with depth 2 -- separate queries vs JOINs is an architectural decision that affects the type system

The design says queries return (Post & { author: User })[]. If implemented with JOINs, the SQL result set is flat and needs hydration (grouping joined rows into nested objects). If implemented with separate queries (N+1 with batching), the results arrive pre-shaped but require a second query.

The problem: the type system does not know which strategy is used. But the runtime behavior differs:

  • JOINs: One query, but duplicate parent rows when a one-to-many is joined. A findMany(posts, { include: { comments: true } }) with 10 posts and 100 comments returns 100 rows that must be hydrated into 10 Post & { comments: Comment[] } objects. The hydration logic needs to deduplicate by primary key.
  • Separate queries: Two queries, but no hydration needed. The result shape is natural.

Both are valid, but the choice affects: (a) findManyAndCount semantics (does total count pre- or post-dedup?), (b) cursor pagination with includes (cursor on the parent or the joined set?), (c) limit/offset behavior with one-to-many includes.

The design does not specify which approach is used. This MUST be decided before implementation because it affects the query builder's SQL generation and the result type guarantees.

C2. d.tenant(organizations) creates a circular reference between table definitions at the module level

export const users = d.table('users', {
  organizationId: d.tenant(organizations),  // references `organizations`
  // ...
});

Unlike d.ref.one(() => users, 'authorId') which uses a lazy function reference, d.tenant(organizations) takes the table directly. If organizations is defined in the same file, this is fine (hoisting). If it is defined in a different file, you get a circular import:

  • users.ts imports organizations from organizations.ts
  • organizations.ts might import users for a d.ref.many() relation

The design uses lazy references for relations (() => users) to avoid this exact problem. But d.tenant() does NOT use a lazy reference.

Fix: Change d.tenant(organizations) to d.tenant(() => organizations) for consistency with the relation API. This is a small change but it prevents a class of module-resolution bugs.

C3. The $insert type derivation has ambiguous semantics for .hidden() columns

The design says:

type UserInsert = typeof users.$insert;
// { email, name } (no id -- has default, no passwordHash -- hidden in insert context)

But wait -- passwordHash is .hidden(), which is described as "excluded from both $not_sensitive and $not_hidden types." The design then says hidden columns are also excluded from $insert. But this is semantically wrong.

passwordHash is a column you absolutely NEED to provide on insert. You cannot create a user without a password hash. The column is "hidden" from query results (you don't want to return it in API responses), but it is required for creation.

If $insert excludes .hidden() columns, how do you create a user? The E2E test actually contradicts this:

// Line from the E2E test:
const user = await db.create(users, {
  data: {
    id: crypto.randomUUID(),
    organizationId: orgId,
    email: 'alice@acme.com',
    passwordHash: '$2b$10$...',  // <-- hidden column IS provided
    name: 'Alice',
  },
});

The data parameter type for create is NOT $insert -- it must include hidden columns. So what is $insert for? The design says it is a derived type helper, but it is misleading if it excludes columns that are required for insertion.

Fix: Clarify that $insert represents the public-facing insert shape (what an API consumer provides), while db.create() accepts the full insert shape (including hidden columns). Or remove $insert as a misleading abstraction and let db.create() use its own derived type.

C4. Enum types are PostgreSQL-specific and will produce confusing migration diffs

plan: d.enum('org_plan', ['free', 'pro', 'enterprise']).default('free'),

The enum name 'org_plan' is a PostgreSQL CREATE TYPE name. If two tables use the same enum name with different values, it is a schema error:

// Table A
status: d.enum('status', ['active', 'inactive']),
// Table B
status: d.enum('status', ['open', 'closed', 'resolved']),

This creates two columns referencing status type but with different values. PostgreSQL will reject the second CREATE TYPE if the first already exists. The migration differ must detect this conflict.

More subtly: modifying an enum (adding a value) requires ALTER TYPE ... ADD VALUE, which cannot run inside a transaction in PostgreSQL < 12. The design says "Enum type diff" in the migration differ, but does not address this PostgreSQL-specific constraint.

C5. db.$tenantGraph computation at startup is O(tables * relations) -- acceptable, but the design does not specify error handling for cycles

The tenant graph traversal (which tables are directly vs. indirectly tenant-scoped) requires walking the relation graph from tenant-root tables. If the schema has a relation cycle (A -> B -> C -> A), the graph traversal will loop infinitely unless cycle detection is implemented.

This is ~10 lines of code (visited set), but the design should mention it because it is a startup crash if missed.

C6. The casing: 'snake_case' option adds hidden complexity to every query and every migration

When casing: 'snake_case' is active, the ORM translates between TypeScript camelCase property names and SQL snake_case column names. This affects:

  • Every where clause (map createdAt to created_at)
  • Every select clause
  • Every orderBy clause
  • Every data object in mutations
  • Every migration column name
  • The _snapshot.json format (does it store camelCase or snake_case?)

The design does not specify:

  1. When does the casing transform happen? (At query build time? At result hydration time?)
  2. What if a column is already snake_case in TypeScript? (user_id in TS -> user_id in SQL, no transform)
  3. Does the snapshot store the TS name or the SQL name?
  4. What about sql tagged template literals? Does casing apply inside raw SQL?

This is not blocking but it is a source of bugs if not specified precisely.

C7. Plugin beforeQuery "first non-undefined return wins" semantics are a footgun for composability

If Plugin A modifies the query context and returns it, Plugin B never runs. This is fine for v1 with 0-1 plugins, but it means plugins cannot compose. If a user has both a logging plugin and a tenant-scoping plugin, the first one that returns a modified context "wins" and the other is skipped.

The alternative is pipeline semantics (each plugin receives the output of the previous one). The design should document why "first wins" was chosen and acknowledge the composability limitation.


Implementation Notes

N1. The d namespace should be a frozen object (consistent with core's createMiddleware)

Core uses deepFreeze on middleware definitions. The d namespace and all table definitions returned by d.table() should also be frozen to prevent accidental mutation. This is consistent with core's immutability pattern.

N2. Start with separate queries for includes, not JOINs

As I noted in the previous feasibility review, JOINs with hydration are the complex path. Separate queries (with DataLoader-style batching) are simpler to implement, easier to debug, and produce naturally-shaped results. Optimize to JOINs when benchmarks show it matters.

N3. The POC 1 validation at 28.5% of budget is for the CURRENT design scope

If v1.1 adds d.policy(), d.session(), and RLS types that flow through the query result, the type budget will increase. The 28.5% result has ~71.5% headroom, which is generous, but it should be re-validated when v1.1 types are designed. Do not treat the POC 1 result as permanent validation.

N4. The d.email() column type implies runtime validation but the behavior is unspecified

d.email() maps to PostgreSQL text with a "format hint." Does this mean:

  • At insert time, the ORM validates the email format? Using what validator? @vertz/schema's EmailSchema?
  • Or is it metadata-only (the format hint is for documentation/introspection but no runtime check)?

If it does runtime validation, what happens on failure? A CheckConstraintError? A new ValidationError? This is unspecified.

N5. d.textArray() and d.integerArray() are special-cased but d.jsonb<T>() is generic -- inconsistency in array column handling

Why not d.array(d.text()) and d.array(d.integer())? The current design has dedicated array methods for two types but a generic approach for JSON. This asymmetry means adding d.uuidArray() or d.booleanArray() requires adding new methods to d rather than using composition. Consider a d.array(columnType) pattern.

N6. The migration _snapshot.json version field ("version": 1) needs an explicit migration strategy

When v1.1 adds policy metadata to the snapshot, the version bumps to 2. The differ must handle reading a v1 snapshot and comparing against v2 schema definitions. This is a data migration problem inside the migration system -- meta-migrations.


Verdict

REQUEST CHANGES

The design is 80% buildable as written. The POC 1 results give genuine confidence in the type inference approach, and the API surface is clean. However, the four blocking issues must be resolved:

  1. B1 -- Error hierarchy integration with core needs an exhaustiveness guarantee (type-level).
  2. B2 -- Type error message quality strategy must be specified (not "we'll figure it out during implementation").
  3. B3 -- Through-table include resolution must be validated or scoped out of depth-2.
  4. B4 -- select: { not } mutual exclusivity needs the never-key pattern; the naive union will silently accept invalid input.

None of these are design-killers. B4 is a 10-line fix to the type definition. B3 is a scoping decision. B1 and B2 require adding sections to the design doc. Once addressed, this is ready for Stage 2.

- Rename find() to findOne() for clarity (Josh #1)
- Separate read/write visibility: $insert includes .hidden() columns (Josh #2, Ben C3)
- Fix select union type with never-keyed branches for mutual exclusivity (Ben B4)
- Move cache-readiness primitives to v1.1 preview section (PM scope creep)
- Fix Non-Goal #7 to not claim v1 ships cache primitives (PM)
- Add exhaustiveness guarantee for error hierarchy with Assert pattern (Ben B1)
- Add type error quality section with branded error messages (Ben B2)
- Flag d.ref.many().through() as unvalidated, cap depth at 1 (Ben B3)
- Fix E2E type test assertions to match actual type semantics (Josh #3)
- Fix first example to compile under strict mode with ! assertion (Josh #4)
- Add vertz db init onboarding flow (Josh #5)
- Document zero-match behavior for all mutation methods (Josh #8)
- Add SQL injection prevention / parameter binding note (PM minor)
- Add dry-run mode for migrations (PM minor)
- Clarify d.email() is metadata-only, no runtime validation (Josh #6, Ben N4)
- Add vertz.env() pattern for type-safe DATABASE_URL access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@vertz-dev-dx vertz-dev-dx Bot left a comment

Choose a reason for hiding this comment

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

LGTM — all 7 required sections present and substantive. Adversarial review of the 10 fixes from the previous round:

All fixes verified solid:

  1. find()findOne() rename — consistent throughout, no stale references
  2. Read/write visibility separation — explicitly documented in Section 1.3, validated in E2E type tests
  3. SelectOption mutual exclusivity — never-keyed branches correctly specified, type flow path #15 mandates .test-d.ts
  4. Cache-readiness → v1.1 preview — Section 8 clearly disclaimed, Non-Goal #7 aligned
  5. Error exhaustiveness — Assert pattern in Section 1.9 catches missing DbErrorToHttpMap entries at compile time
  6. Branded error messages — Section 6.3 shows concrete InvalidSelectKey pattern with good/bad error comparison
  7. d.ref.many().through() unvalidated — Unknown U6 flags the risk, depth capped at 1
  8. E2E type assertions — @ts-expect-error on $infer (hidden), $not_sensitive, missing $insert field, narrowed select
  9. Strict mode fix — DATABASE_URL! with non-null assertion, vertz.env() alternative documented
  10. vertz db init — Section 1.10 scaffolds directory structure with next steps

Additional checks passed:

  • Zero-match behavior table is complete and consistent (single-row throws, multi-row returns count)
  • SQL injection prevention documented (parameterized by default, sql.raw() explicitly flagged)
  • Migration dry-run mode present
  • d.email() metadata-only clarification is clear
  • 15 type flow paths are concrete and each maps to a .test-d.ts criterion
  • E2E acceptance test is compilable, specific, covers happy path + error cases + type safety

No issues found. Ship it.

@viniciusdacal viniciusdacal merged commit 6d3ecca into main Feb 11, 2026
3 checks passed
viniciusdacal pushed a commit that referenced this pull request Feb 22, 2026
* docs(db): add @vertz/db v1 API design plan

Comprehensive design doc for the thin ORM layer covering schema definitions,
type inference, query builder, relations, migrations, error hierarchy,
and metadata-only multi-tenancy markers. Based on approved roadmap,
POC 1 results (28.5% of budget), and all exploration research.

Includes self-review notes from Josh (DX), Ben (feasibility), and PM (scope).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db-design): address review feedback from Josh, Ben, and PM

- Rename find() to findOne() for clarity (Josh #1)
- Separate read/write visibility: $insert includes .hidden() columns (Josh #2, Ben C3)
- Fix select union type with never-keyed branches for mutual exclusivity (Ben B4)
- Move cache-readiness primitives to v1.1 preview section (PM scope creep)
- Fix Non-Goal #7 to not claim v1 ships cache primitives (PM)
- Add exhaustiveness guarantee for error hierarchy with Assert pattern (Ben B1)
- Add type error quality section with branded error messages (Ben B2)
- Flag d.ref.many().through() as unvalidated, cap depth at 1 (Ben B3)
- Fix E2E type test assertions to match actual type semantics (Josh #3)
- Fix first example to compile under strict mode with ! assertion (Josh #4)
- Add vertz db init onboarding flow (Josh #5)
- Document zero-match behavior for all mutation methods (Josh #8)
- Add SQL injection prevention / parameter binding note (PM minor)
- Add dry-run mode for migrations (PM minor)
- Clarify d.email() is metadata-only, no runtime validation (Josh #6, Ben N4)
- Add vertz.env() pattern for type-safe DATABASE_URL access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: vertz-tech-lead[bot] <2828099+vertz-tech-lead[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: vertz-dev-dx[bot] <260432280+vertz-dev-dx[bot]@users.noreply.github.com>
@viniciusdacal viniciusdacal deleted the docs/db-design branch February 22, 2026 16:19
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.

1 participant