Skip to content

The opaque wrapper with all() and first(): operation access, the Access Filter as a filter entry, Silent failure - #1216

Merged
borisno2 merged 2 commits into
prisma-8from
claude/issue-1146-opaque-wrapper
Sep 6, 2026
Merged

The opaque wrapper with all() and first(): operation access, the Access Filter as a filter entry, Silent failure#1216
borisno2 merged 2 commits into
prisma-8from
claude/issue-1146-opaque-wrapper

Conversation

@borisno2

@borisno2 borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Implements #1146
Part of #1123

What this lands

context.db.<List> is now spelled the way the config spells the list — context.db.AuthUser, never context.db.authUser — and it is an opaque wrapper over a Prisma 8 collection: .where(...) composes an immutable value, and .all() / .first() are the engine-owned terminals that run it.

A terminal, in one place (packages/core/src/secured/read.ts):

  1. resolves operation-level query access lazily (sudo skips it),
  2. runs the predicate-time key and read-access checks (Read path forwards undeclared where / orderBy keys straight to Prisma #912/A field read gate withholds the value but not the predicate — every gated column is a filter oracle #915) — only once the caller is known to have some access to the list, so the error messages are not an oracle,
  3. lowers the caller's predicates and the Access Filter each to one entry in the collection's own filter list.where(caller).where(accessFilter), which rc.8 ANDs natively. Nothing is hand-merged; mergeFilters is not on this path,
  4. enters the engine origin around the ORM call and materialises (ADR-0046),
  5. applies Field Visibility per row,
  6. returns [] / null on denial — Silent failure, indistinguishable from an empty result, and the database is never reached.

getDbKey is deleted (getUrlKey / getListKeyFromUrl stay). The engine's ORM handle, ormModel, context.db, the MCP handler, nav counts, the fragment runner, the RAG storages and the auth plugin all key by the list name now, as does the generated DB interface (packages/cli/src/generator/types.ts), which is where context.db.authUser becomes a compile error — Property 'authUser' does not exist on type 'DB'. Did you mean 'AuthUser'? is exactly what the CLI's type-fixture tests now report for the old spelling.

The delegate-name / short-circuit problem

Spec 3's QA gate (on #1123) recorded two failure modes: the engine called Prisma 7 delegate names (findMany and siblings) against rc.8 collections, which expose where/all/first/create; and without sudo a call returned [] / null because deny-by-default short-circuits before the database — so a test could be green while touching nothing.

  • The read path now calls the rc.8 names. The terminals drive collection.where(...)/all()/first() directly, so context.db.<List>.all() is a real round-trip against the harness's Postgres.
  • The write path is untouched and explicitly owned elsewhere. create/update/delete/count/findMany and the fragment reads still carry the Prisma 7 delegate names; writes are spec 4 (Spec: The secured surface, writes — connect, transactions, the row lock and stack-owned errors #1124) and the remaining read shapes are The Where vocabulary and its lowering; the filter module retyped #1147The nearest() terminal over a native vector column #1151. Nothing here silently leaves the read path broken — the terminals are the read path.
  • The round-trip test (packages/core/src/secured/read.test.ts) seeds a row through the Unsafe origin (the harness's own collection, not the engine — the write path is not this ticket's) and reads it back through context.db.Post.all(), asserting on the row's id and createdAt: values the database minted, which a short-circuited read cannot produce.
  • The negative control seeds the same way into a list that declares no query rule (deny-by-default), reads it through the identical call, and asserts both that the result is [] and that the positive test's own assertion fails against it (expect(() => expect(rows[0]).toMatchObject(...)).toThrow()). A sibling test asserts the recorder saw no plan at all for that read — the denial short-circuits before the database, and the round-trip assertion is provably falsifiable by it.

Test coverage, by CONTEXT.md term

Everything is asserted through createTestContext and the recording middleware — never on wrapper internals.

  • Silent failure — anonymous / author / other-user sessions over a blog fixture each get exactly their rows; a denied list returns [] and null, equal to a genuinely empty list's; a denied read compiles no plan.
  • Access Filter — the recorded AST's where is { kind: 'and', exprs: [caller, accessFilter] }; with no caller predicate it carries the access filter alone; under sudo it carries the caller's predicate alone.
  • Field Visibility — a read-denied field is absent from the row, and a predicate naming it (or a column the list does not declare) is refused.
  • Engine origin — both terminals compile their plans under origin: 'engine', with the tripwire installed in its only (throwing) mode.
  • Opacity — a composed query's own keys are exactly where/all/first, and state/ctx/modelName/registry/tableName are all undefined on it.
  • Immutability — narrowing a query leaves the query it was narrowed from unchanged.
  • PascalCase keyingcontext.db.Post works, context.db.post is undefined, Object.keys(context.db) is the list names, and getDbKey is gone from the package entry.

Design notes

  • Why an equality-only predicate. where takes { column: value } or { column: { equals: value } }, and lowerPredicate throws UnsupportedPredicateError naming the list and column for anything else — a predicate can only ever narrow, so an operator the engine cannot lower is refused rather than dropped. The closed Where vocabulary (in, not, the comparisons, contains, AND/OR/NOT, some/every/none) is The Where vocabulary and its lowering; the filter module retyped #1147, and lowerPredicate is the single seam it replaces. The type layer mirrors the runtime: ListPredicate on the generated SecuredList accepts equality only, so { title: { contains: … } } is a compile error today as well as a runtime refusal.
  • Why the Access Filter is a second .where() call. rc.8 documents repeated where as AND-combined and emits them as sibling entries under one and node — which is what makes "the access filter is a filter entry, not a merge" a checkable fact about the plan rather than a claim about our code.
  • MCP tool names are unchanged. The handler keys context.db by the list name but still spells its tool names list_post_query via pascalToCamel — the tool name is an identifier assistants have already bound to, and renaming it is not this ticket's to do.
  • The legacy delegate members stay on the wrapper for now (findMany, create, …), so the admin UI, MCP and RAG keep compiling while specs 4 and 7 convert them. They expose no Collection and no CollectionState; the acceptance criterion is about what is reachable, and nothing here hands one out.

Verification

Run in this worktree, all green:

  • pnpm lint — 0 errors (2 pre-existing warnings, untouched)
  • pnpm format:check — clean; pnpm manypkg fix — no changes needed
  • turbo run build --filter='./packages/*' — 10/10
  • turbo run test --filter='./packages/*' — core 1545 passed / 1 skipped, ui 611, cli 332, auth 298 (+4 skipped), rag 362, storage 197, storage-s3 43, storage-vercel 56, create-opensaas-app 42 (+2 skipped)
  • The contract fixture regenerates byte-identical (opensaas generate leaves it clean), as CI requires.

🤖 Generated with Claude Code

… all()/first()

`context.db.<List>` is keyed by the config's own spelling of the list name and
`getDbKey` is deleted; every call site through the secured surface, and every
ORM handle the engine reaches a model through, moves to the list key.

The same member is now a query value. `.where(...)` composes an immutable read;
`.all()` and `.first()` are engine-owned terminals that resolve operation-level
`query` access lazily, add the Access Filter as a second entry in the
collection's own filter list, enter the engine origin around the ORM call,
apply Field Visibility and materialise — returning `[]`/`null` on denial.

The terminals call the rc.8 collection method names (`where`/`all`/`first`), so
the read path reaches a real database through `createTestContext`. The Prisma 7
delegate names on the write path are untouched and stay #1124's.

Implements #1146
Part of #1123

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 6, 2026 12:09pm UTC

@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 193bfed

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-auth Minor
@opensaas/stack-cli Minor
@opensaas/stack-rag Minor
@opensaas/stack-ui Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.55% (🎯 65%) 2708 / 2864
🟢 Statements 93.1% (🎯 65%) 2958 / 3177
🟢 Functions 97.26% (🎯 62%) 534 / 549
🟢 Branches 87.88% (🎯 50%) 2075 / 2361
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/access/field-visibility.ts 97.65% 93.12% 100% 99.14% 441, 476, 477
packages/core/src/access/orm-client.ts 100% 100% 100% 100%
packages/core/src/access/relationship-count.ts 86.66% 78.78% 100% 94.18% 89, 91, 124, 130, 138, 154, 158, 160, 172, 196, 204, 225, 270, 317
packages/core/src/config/nav-count.ts 92% 78.57% 100% 100% 53, 62
packages/core/src/lib/case-utils.ts 100% 100% 100% 100%
packages/core/src/mcp/handler.ts 90.15% 79.13% 95.23% 90.55% 47-53, 152-161, 347-351, 417, 442, 485-488, 497-500, 505, 536, 548
packages/core/src/secured/read.ts 91.35% 89.47% 95.45% 93.93% 76-80, 99, 103, 110, 128
packages/core/src/testing/context.ts 86.44% 85.71% 82.75% 89.18% 222-225, 242, 288, 314, 326-331, 447, 451, 459-462
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 71.75% 1326 / 1848
🔵 Statements 72.03% 1419 / 1970
🔵 Functions 83.58% 224 / 268
🔵 Branches 59.83% 706 / 1180
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/cli/src/generator/context.ts 100% 100% 100% 100%
packages/cli/src/generator/types.ts 99.29% 94.44% 100% 100% 24
packages/cli/src/mcp/lib/generators/feature-generator.ts 41.2% 30.45% 33.33% 42.37% 42-48, 58, 75-80, 83, 86, 89, 92, 95, 99, 204, 333-402, 440-441, 445-446, 545, 555-558, 562, 565, 569, 572, 575-576, 579, 583, 584, 804-1152
packages/cli/src/migration/generators/migration-generator.ts 62.04% 52.27% 50% 63.71% 38, 45, 95-338, 365, 401, 425-435, 438, 440-448, 461, 476-479, 485-487, 509-530, 537-561
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 99.48% 195 / 196
🔵 Statements 98.13% 210 / 214
🔵 Functions 100% 44 / 44
🔵 Branches 90.77% 187 / 206
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/auth/src/config/plugin.ts 100% 100% 100% 100%
packages/auth/src/mcp/better-auth.ts 100% 100% 100% 100%
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 79.66% 235 / 295
🔵 Statements 81.17% 263 / 324
🔵 Functions 87.91% 80 / 91
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 54.38% 397 / 730
🔵 Statements 53.85% 419 / 778
🔵 Functions 64.06% 82 / 128
🔵 Branches 47.25% 198 / 419
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/rag/src/config/plugin.ts 23.07% 22.22% 26.66% 25.53% 61-70, 75-77, 105-149, 189-216, 230-301
packages/rag/src/storage/json.ts 100% 100% 100% 100%
packages/rag/src/storage/pgvector.ts 73.21% 51.72% 84.61% 73.21% 38-43, 52-57, 72, 97-100, 107, 135, 164-167, 183-206
packages/rag/src/storage/sqlite-vss.ts 0% 0% 0% 0% 15-155
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #1998 for commit 193bfed by the Vitest Coverage Report Action

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES. (GitHub refuses a formal --request-changes review because the PR was authored by this same identity, so this is posted as a comment review; treat it as a blocking review.)

Code review — effort high

I reviewed the full diff: the new secured read surface (packages/core/src/secured/read.ts), its tests, the PascalCase re-key across core/cli/ui/auth/rag/MCP, and the mechanical test churn (I spot-checked tests/context.test.ts, tests/resolve-chain.test.ts, tests/sudo.test.ts and src/access/orm-client.test.ts for weakened assertions and found none — the churn really is a rename).

The design is good and the acceptance criteria mostly land. Two things need fixing before merge.

Verdict: REQUEST CHANGES.


High

1. packages/cli/src/mcp/lib/generators/feature-generator.ts:692 — the re-key missed the CLI's code-emitting templates, so generated apps ship broken code.

The blog feature generator writes this into the user's app/blog/page.tsx:

const posts = await context.db.post.findMany({})

context.db is now keyed by the PascalCase list name, so that is undefined at runtime — TypeError: Cannot read properties of undefined (reading 'findMany') on first render — and it no longer type-checks against the regenerated DB interface (packages/cli/src/generator/types.ts:212 now emits Post: PostList). Same fault at :734 (context.db.post.findFirst) and :515 in the emitted auth dev-guide snippet (context.db.user.findUnique).

packages/cli/src/migration/generators/migration-generator.ts:113 compounds it: the emitted Keystone-migration guide tells users to "Replace with context.db.{listName}.{method}() — list names are camelCase" and shows context.db.post.findMany. That guidance is now exactly backwards.

The sweep covered packages/core, ui, rag and auth call sites but not the strings the CLI emits as user code. git grep -nE 'context\.db\.[a-z]' packages/*/src finds all of them.


Medium

2. packages/core/src/secured/read.ts:153lowerPredicate silently drops undefined, which widens the Access Filter.

for (const [column, condition] of Object.entries(predicate)) {
  if (condition === undefined) continue
  entry[column] = lowerCondition(listName, column, condition)
}

The docblock directly above (line 146) says it is "Total or throwing … so a rule the engine cannot lower fails loudly instead of silently widening the read," and line 204 routes the Access Filter through this same function. A conventional rule such as

query: ({ session }) => ({ authorId: session?.userId })

returns { authorId: undefined } for an anonymous caller. That lowers to {}, collection.where({}) constrains nothing, and .all() returns every row of the list. The explicit form { authorId: { equals: undefined } } is refused (isWhereValue(undefined) is false at line 137) — so the one shape the docblock promises cannot happen is precisely the shape that slips through, and the two spellings of the same rule behave oppositely.

This is behaviour-compatible with Prisma's own undefined-means-omitted semantics, so it is not a new hole relative to mergeFilters. But the whole point of this seam is to be total, the docblock asserts that it is, and the test fixture never exercises a filter rule that yields undefined. Either throw here (my preference — a dropped access-filter clause should never be silent) or drop the claim and add a test pinning the chosen semantics.

3. packages/core/src/secured/read.ts:225 — the terminals skip resolveReadInclude, so relation-valued Declared Dependencies break.

runAll/runFirst call filterReadableFields(row, fields, ctx, config, 0, listName) with no additions. createFindMany (context/index.ts:1367) folds needs in via resolveReadInclude first (ADR-0025/ADR-0051). Scalar needs are unaffected — .all() returns every column — but a computed field declaring a relation dependency, e.g.

authorName: virtual({ needs: ['author'], hooks: { resolveOutput: ({ item }) => item.author.name } })

gets the relation under context.db.Post.findMany() and undefined under context.db.Post.all(). The hook then computes a wrong value silently rather than the two paths agreeing. If deferring to #1147#1151 is intended, say so in the changeset and file it; right now nothing records the divergence.


Low

4. packages/core/src/context/index.ts:992where/all/first are installed for singleton lists, bypassing the singleton guard.

populateDbDelegate adds the three read members unconditionally, but createFindMany (line 1296) deliberately throws Cannot use findMany: X is a singleton list. Use get() instead. The generated type agrees they shouldn't be there — SingletonOps in types/secured-list.ts was not intersected with ListQuery, only ListOps was — so context.db.Settings.all() compiles as an error but works at runtime and returns the raw row array. Gate the three on !isSingletonList(listConfig), or intersect SingletonOps too.

5. packages/core/src/secured/read.ts:120 — dead disjunct. if (typeof condition !== 'object' || condition === undefined)typeof undefined is 'undefined', so the first clause already caught it.

6. ListConfig<any> is reachable from an exported signature. ReadBinding.listConfig (read.ts:167) carries the repo's usual no-explicit-any disable, and createSecuredRead(binding: ReadBinding) is exported, so the any is structurally part of a public signature. Consistent with the rest of the engine, so I'd accept it — flagging only because CLAUDE.md forbids any in external types. Otherwise the type hygiene here is clean: I found no new any, unknown leak or as cast in the generated DB interface, the wrapper generics or secured-list.ts, and the any-typed client placeholder really is gone.

7. Stale camelCase in docs and TSDoc. packages/cli/src/generator/context.ts:247,251 (and its snapshot) means every newly generated project ships const posts = await context.db.post.findMany() as its worked example. Same spelling survives in editor-visible public TSDoc — access/types.ts:143,152,187,212, fields/index.ts:392,895,1086,1820, config/types.ts:453,2551, testing/context.ts:484, auth/src/mcp/better-auth.ts:84 — and in ~99 places under docs/content/, of which only rag-advanced.md was touched (because it happened to import getDbKey). Not blocking, but this is the release where it is cheapest to fix.


Things I checked that are not problems

  • Opacity holds. SecuredQuery exposes only where/all/first; where returns SecuredQuery, not the delegate. The collection is closed over in collectionFor and never handed out. I traced every legacy member left on the wrapper for UI/MCP/RAG compatibility (findUnique, findMany, findFirst, create, update, delete, count, createMany, updateMany, get) — none returns a Collection or CollectionState, and AccessControlledDelegate extends SecuredQuery adds no leak. The inferred return type is the declared one.
  • The mixed method-name split is coherent. isDelegate (orm-client.ts:23) is presence-only, so the legacy paths still resolve their handle under the new key and fail — if at all — at their own call site, exactly as before. The read path is the only thing re-pointed at where/all/first, and it is fully covered. No silently-broken call path is introduced by this change; whether rc.8 collections answer findMany is spec 4's (#1124) pre-existing problem, correctly scoped out.
  • The negative control is not vacuous, and it does prove its claim. The positive test asserts on id and createdAt the database minted and on recorder.plans.some(p => p.kind === 'select'), so it cannot pass without a round trip. The control seeds a Draft (no query rule), reads through the identical call, gets [], and shows expect(rows[0]).toMatchObject(…) throws against it — rows[0] is undefined, so toMatchObject genuinely fails. The sibling recorder.plans toEqual [] test closes it: denial short-circuits before the database. Minor: the control asserts falsifiability of the assertion form on a different list, and omits createdAt; asserting the same three keys would tighten it slightly.
  • No access oracle in the refusal path. resolveAccessFilter runs checkAccess and returns null before validateQueryKeys / validateQueryFieldReadAccess and before any lowering, so a caller with no access to the list gets []/null and never an UnsupportedPredicateError or a key-naming error — matching createFindMany. A caller with partial access can still tell "column does not exist" from "column is read-denied" by the two distinct messages, but that is the established #912/#915 contract, not a regression here.
  • withOrigin correctness. withOrigin('engine', () => collection.all()) awaits inside the ALS scope, so the lazy AsyncIterableResult is still stamped when it executes — the origin tests confirm both terminals record origin: 'engine'.
  • MCP. pascalToCamel preserves the wire tool names, and hoisting the list lookup out of the query branch is a strict improvement — create/update/delete previously indexed context.db with an unvalidated key.
  • The rest of the re-key. ormModel, nav-count, relationship-count, query/index.ts, the RAG storages, packages/ui and authPlugin's normalized.models.user.modelName (PascalCase by default) are all correct, including the runtime-string call sites.

Fix #1, decide on #2, and this is good to merge.

The PascalCase re-key missed the code the CLI emits into user projects: the
feature generator's blog and auth pages and the Keystone migration guide still
spelled `context.db` camelCase, which is `undefined` at runtime and does not
type-check against the regenerated `DB` interface. A source-scanning guard test
plus behavioural assertions on the generated output keep that class of miss
from returning silently.

Also: correct `lowerPredicate`'s docblock, which claimed a totality the code
does not have, and pin both spellings of an `undefined` condition so #1147
changing them is a visible test change; drop the secured read members from
singleton lists so the runtime matches the emitted type; remove `any` from the
exported `createSecuredRead` signature; drop a dead `undefined` disjunct; and
re-key the stale camelCase in the generated context example and package TSDoc.

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

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Review findings addressed in 193bfed. Per numbered finding:

1 (High) — the CLI's code-emitting templates. Fixed, and swept rather than spot-fixed.

  • packages/cli/src/mcp/lib/generators/feature-generator.ts:515 context.db.user.findUniquecontext.db.User.findUnique, :692context.db.Post.findMany, :734context.db.Post.findFirst. The emitted config at :591 already declares Post: list({...}), so the PascalCase spelling is the one that resolves.
  • packages/cli/src/migration/generators/migration-generator.ts:113 — the guide now says context.db.{ListName}.{method}() — list names are PascalCase, exactly as spelled in opensaas.config.ts, and its diff example emits context.db.Post.findMany.
  • A full sweep of packages/*/src for context.db.<lowercase> found no further emitted occurrences; the remaining matches were TSDoc and the generated context example, handled under finding 7 below.

On the method names: I kept findMany/findFirst/findUnique rather than rewriting the templates onto .where().all(). ListOps in types/secured-list.ts declares all three, and the templates need orderBy and include: { author: true }, which the composed surface does not take — emitting .where(...).all() would type-check but silently drop the ordering and render By Unknown. Whether rc.8 collections answer findMany is #1124's, as you noted, and these call sites move with every other one when it lands.

New test: packages/cli/src/mcp/lib/generators/emitted-db-key-case.test.ts. Two behavioural cases drive FeatureGenerator for the blog and auth features and assert the PascalCase key across every emitted string (files, config updates, dev guide, instructions, next steps), plus one guard that scans all of packages/cli/src for context.db.<lowercase>. Verified falsifiable — reverting context.db.Post.findMany to camelCase fails 2 of the 3.

2 (Medium) — lowerPredicate's docblock. Runtime behaviour unchanged (Prisma-parity, and totality is #1147's). The docblock no longer claims totality; it now carries a Known-limit note stating that an undefined condition is skipped, that ({ session }) => ({ authorId: session?.userId }) therefore constrains nothing for an anonymous caller while { equals: undefined } is refused, and that totality arrives with #1147. Three tests pin the current semantics: both spellings as unit cases on lowerPredicate, and an end-to-end one on a new Widened fixture list whose access filter yields undefined, asserting an anonymous caller does get every row. #1147 changing that is now a deliberate test edit.

3 (Medium) — terminals skip resolveReadInclude. Deliberately deferred to #1149, per that issue's scope. Recorded in the changeset so the divergence is no longer unwritten.

4 (Low) — singleton lists. populateDbDelegate now installs where/all/first only on the non-singleton branch, alongside get on the singleton one, so the runtime matches SingletonOps. Two cases added to tests/singleton.test.ts.

5 (Low) — dead disjunct. condition === undefined removed from lowerCondition.

6 (Low) — any in an exported signature. ReadBinding.listConfig is now ListConfig<TypeInfo> and the no-explicit-any disable is gone, so createSecuredRead's exported signature carries no any. I tried a full generic (ReadBinding<T extends TypeInfo>) first; ListConfig is invariant in its parameter, so listConfig.fields no longer satisfied filterReadableFields. The concrete instantiation is the strongly-typed form that composes. No casts.

7 (Low) — stale camelCase in package-owned copy. 18 sites re-keyed: the generated context example (cli/src/generator/context.ts + its snapshot) and public TSDoc in core/src/access/types.ts, core/src/fields/index.ts, core/src/config/types.ts, core/src/testing/context.ts, core/src/access/field-visibility.ts and auth/src/mcp/better-auth.ts. The ~99 pages under docs/content/ are left to #1129 (spec 9).

Related, not touched: examples/starter and examples/starter-auth are copied into create-opensaas-app's templates at build time, so their context.db.post.* call sites — and examples/starter-auth/CLAUDE.md:30, which states the context uses camelCase — are the same class of miss one directory over. Re-keying two whole example apps felt out of scope for a review-fix pass; flagging it so it gets tracked rather than lost.

Changeset opaque-owls-compose.md updated in place (no second changeset): the singleton carve-out, the CLI's emitted spelling, and the two recorded limits (#1147, #1149).

Verification on 193bfed: pnpm build (tsc across all 11 tasks) green; pnpm lint 0 errors, 2 warnings both pre-existing and unrelated; pnpm -r test green across every package — core 1550 passed / 1 skipped, cli 335, ui 611, rag 362, auth 298 / 4 skipped, storage 197, storage-s3 43, storage-vercel 56, create-opensaas-app 42 / 2 skipped. pnpm manypkg fix and pnpm format run.

@borisno2
borisno2 merged commit 26f47b0 into prisma-8 Sep 6, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/issue-1146-opaque-wrapper branch September 6, 2026 12:15
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