Agent guidance rewritten for the Prisma 8 architecture - #1410
Conversation
Describes the two surfaces, the contract generator and what it emits, the dev loop and the migration split, transactions and the row lock, and auth over the Auth adapter. Every deleted construct is gone rather than renamed: no schema-language generation, no client-constructor or schema-extension option, no SQLite, no join-table naming, no fragment API, no read delegates. Fixes three live defects in this file: `db.indexes` documented a working `sort` direction and the index it supposedly emitted (generation refuses it, ADR-0040); the module-init recipe named `context.ormHandle`, which is on `AccessContext` and not on the request context; and `@opensaas/stack-ui/server` was documented as exporting `getAdminContext`, which it does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The entry-point list now names every subpath the package exports (contract, unsafe, origin, dev-database, testing, client) with what each is for; the composed read, the Where vocabulary, contract derivation, the dev database and the test harness get sections of their own. Fixes six defects: `OperationAccessControl` and `applyAccessControl()` are named nowhere under `packages/*/src`; `createContext(...)` is not the factory's name; the two sections stating Bare read and One hop as stack rules cited ADR-0024 and ADR-0026, both withdrawn; the MCP projection section cited ADR-0033 (which is about auth lists) and described a read path the translator no longer takes; `context.db.post`/`context.db.task` were wrongly cased; and the client-constructor recipe and the SQLite config sample are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes the session-provider recipe, which taught `getContext({ session })` —
a wrapper object that makes an anonymous caller read as signed in, because
the factory only checks for null and an object holding `undefined` is
truthy (#1397). The corrected form branches on the session and passes its
own fields, and `getSessionFromAuth` is shown with its real three-argument
signature.
The credential-fields section drove three read methods the secured surface
no longer has and a wrongly-cased list key; it now shows the composed read
and separates a stripped field from the predicate-time read check that
refuses one named in a `where`. Schema placement is described as contract
namespaces rather than schema-language attributes and a preview feature,
and the module-init note no longer says `createAuth` wraps
`context.ormHandle`, which is not a member of the request context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are outside the three files #1175 names, and both carried the same kind of fabricated binding the ticket's method was meant to catch: `packages/cli/CLAUDE.md` imported `createContext` from `@opensaas/stack-core`, and `packages/tiptap/CLAUDE.md` imported from `@opensaas/stack-tiptap/components`. Neither exists. Correcting the CLI import alone would have left it inside a sample that was fiction in every other line, so the generation half of that file is rewritten against what the generator emits — the Contract module, the two emitted artifacts, the real `prisma.config.ts` and context factory, the dev loop and `db update`. The migration half is untouched: the Keystone story is out of scope for this effort, and the Prisma schemas it reads are a source project's, not ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sample constructed the ORM client without the `Contract` type parameter the generated bundle passes, and imported the JSON without the emitted declarations beside it. Matches the generated form now, and carries the `contract.d.js` spelling note the CLI guidance already has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report for Core Package Coverage (./packages/core)
File CoverageNo changed files found. |
Coverage Report for UI Package Coverage (./packages/ui)
File CoverageNo changed files found. |
Coverage Report for CLI Package Coverage (./packages/cli)
File CoverageNo changed files found. |
Coverage Report for Auth Package Coverage (./packages/auth)
File CoverageNo changed files found. |
Coverage Report for Storage Package Coverage (./packages/storage)
File CoverageNo changed files found. |
Coverage Report for RAG Package Coverage (./packages/rag)
File CoverageNo changed files found. |
Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)
File CoverageNo changed files found. |
Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)
File CoverageNo changed files found. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
borisno2
left a comment
There was a problem hiding this comment.
REQUEST CHANGES
Documentation-only, and the architecture rewrite is largely accurate — but one new line in the root file teaches a write call shape that does not exist, and the root file is the one an agent reads first.
Blocking — argument shape
CLAUDE.md:653 — "create(data), where(...).update(data) and where(...).delete()".
The secured write surface takes argument objects on the list, not terminals chained off a composed read:
create({ data }),update({ where: { id }, data }),delete({ where: { id } })—ListOpsinpackages/core/src/types/secured-list.ts.populateDbDelegate(packages/core/src/context/index.ts:1228–1258) putscreate/update/deleteon the list object and copies only the read-composition members onto it. The valuewhere()returns is aListQuery, which carrieswhere/orderBy/include/select/limit/offset/distinct/distinctOn/cursor/forUpdateand the four terminals — noupdate, nodelete.
So context.db.Post.where({ id: { equals: id } }).update(data) is a TypeError at runtime and a compile error under the generated types. The line is new here — git show origin/prisma-8:CLAUDE.md has no where(...) construction — and it contradicts two correct uses in the same file (:739, :825) and packages/core/CLAUDE.md:159, which states the real shape. create(data) on the same line is loose for the same reason: the real argument is { data }.
Since nothing compiles these files, this one line is the whole check.
Non-blocking — follow-up residue
CLAUDE.md:1198(argument shape) —all().forUpdate(). The order is.forUpdate().all():forUpdateis a composition member returning aListQuery,all()is a terminal returning aPromise. Terse cost note, but it is set in code.CLAUDE.md:381(semantics) — saysprisma.config.tsresolves the URL "throughresolveDatabaseUrl()". The generator emitsfindDatabaseUrl()(packages/cli/src/generator/prisma-config.ts:84,104), and the difference is load-bearing:resolveDatabaseUrlthrows when neither source is present;findDatabaseUrlis the non-throwing accessor that exists precisely so the offline Prisma commands work (packages/core/src/db/url.ts:64–75).packages/cli/CLAUDE.md:130has it right.packages/auth/CLAUDE.md:256–266(consistency) — one code block names the same listcontext.db.Sessiontwice andcontext.db.AuthSessiononce. Both keys are real under different configs (default derivation vsadoptBetterAuthTables), but not inside one example.
My own exported-surface diff
Independent of the author's: every @opensaas/… specifier and named import in the five files, resolved against each package's exports map and then transitively through the barrel source (not dist). 54 import statements, 82 named bindings, 15 distinct subpaths, 7 package names — 0 unresolved. Every subpath used is declared by its owner. @opensaas/stack-mcp appears nowhere, and dropping @opensaas/stack-tiptap/components is right — the package declares . and ./fields only. The prose export inventories at packages/core/CLAUDE.md:13–23 (44 names over 11 entry points) also resolve. I confirm the author's "0 findings" claim.
Blind-spot categories
- Member access —
UnsafeSurfacereally is{ sql, raw, orm, query, execute }(packages/core/src/unsafe.ts:93–101), matchingCLAUDE.md:141exactly.ormHandleis gone from all five files, andpackages/core/CLAUDE.md:78–82places it correctly onAccessContext(packages/core/src/access/types.ts:232) and states it is not onStackBaseContext(packages/core/src/types/context.ts, which carriesunsafe).context.prismais gone. Clean apart from the write-shape line above. - Argument shape — resolved every call in every sample.
getSessionFromAuth(auth, sessionFields, headers)matchespackages/auth/src/server/index.ts:641–645.aggregate((a) => ({ k: a.count() })),.select(...fields),nearest(field, vector, options?),postgres<Contract>({ contractJson, middleware, pg })all match. The two misses are:653and:1198. - Semantics —
virtual({ type })is right: the builder takestype: TypeDescriptorand producesoutputType(packages/core/src/fields/index.ts:1470–1490).db.idField,db.extensions,db.client.pg(lazy factory),db.timestamps,db.indexeswithname/unique, listdb.map/db.schemaall check out. The one miss is:381. - Prose claims — the emitted
prisma.config.tssample inpackages/cli/CLAUDE.md:127–144is byte-accurate againstgeneratePrismaConfig.pnpm dev -- tsx <script>is real:examples/blog's owntestscript isopensaas dev -- tsx test-access-control.ts, and the four commands listed atCLAUDE.md:88–103are exactly blog's scripts.
The four specific claims
- Transaction example — compared line by line with
packages/core/src/secured/capacity-gate.test.ts. The test locks first, re-reads the threshold in its own statement, then counts; its third case ('the gate compares against the threshold read after the lock, not the one on the locked row') proves the two values differ.CLAUDE.md:659–687andpackages/core/CLAUDE.md:256–277both match, including the "columns as of before the lock" prose. Correctly left alone. - Index sort —
packages/core/src/validation/database-config.ts:12–21refuses asorton adb.indexesfield reference by name (reason: 'index-sort', ADR-0040).sort:appears in none of the five files;CLAUDE.md:438is true. - Session shape — the generated factory is
getContext(session?)passingsession ?? null(packages/cli/src/generator/context.ts:252–256), so the "truthy wrapper reads as signed in" reasoning is exactly right.getContext({ session })survives inCLAUDE.md:641andpackages/auth/CLAUDE.md:414only as the named bug; no file teaches it. - Nothing removed presented as current — grepped all five for
prismaClientConstructor,extendPrismaSchema,joinTableNaming,relationName,defineFragment/runQuery/ResultOf,sqlite/better-sqlite3,P2002/P2025/PrismaClientKnownRequestError,findMany/findUnique,getDbKey,getAdminContext,@@map/@@schema/@@index/@@unique. Zero hits.schema.prismasurvives only inpackages/cli/CLAUDE.md's migration half (317, 322, 401, 570, 591, 621, 668), reading a source project — correct.stack-mcp-server.ts(cli:518, 683) is a real file underpackages/cli/src/mcp/server/, not the deleted package. The sole historical pointer ispackages/core/CLAUDE.md:469marking ADR-0024/ADR-0026 withdrawn.
Hygiene
- Documentation only.
git diff --name-statusagainstorigin/prisma-8is fiveMrows, allCLAUDE.md. No package source, no manifest, no changeset needed. - Doc type-checker untouched.
scripts/doc-blocks/has no diff at all, sofiles.txtis reverted as described andfragments.jsonhas no excuse added, widened or removed. Worth restating that this leaves the five files uncovered by any checker. - Format clean.
prettier --checkpasses on all five. - CI: 7/7 pass — test, coverage, e2e (dev-database), e2e (postgres), GitGuardian, Vercel, Vercel Preview Comments. None of the known flakes (#1404, #1411, #1260) appeared.
Fix :653 and this is good to land; the other three are fine as a follow-up if you'd rather.
borisno2
left a comment
There was a problem hiding this comment.
Line-anchored detail for the findings in my review above.
| therefore read in its own statement _after_ the lock. | ||
| **Reads are composed, then run by a terminal.** `where`, `orderBy`, `select`, `include`, `limit`, `offset`, `cursor`, `distinct`/`distinctOn` build an immutable value; `all()`, `first()`, `aggregate()` and `nearest()` run it. A method appears on the surface only where the engine knows how to scope it — omission is the signal, not an oversight. A read materialises: no terminal is an async iterable, and a caller that needs a cursor uses `context.unsafe`. | ||
|
|
||
| **Writes take scalars plus `connect`.** `create(data)`, `where(...).update(data)` and `where(...).delete()` accept the row's own columns plus `connect` on a field that owns the foreign key, where it lowers to a reachability query against the target's `query` access and a scalar foreign-key write. Assigning `null` clears the edge. There is no nested `create`/`update`/`delete`/`connectOrCreate`/`set`. A caller writing several rows atomically does so inside `context.transaction` (ADR-0050). |
There was a problem hiding this comment.
Blocking — argument shape. where(...).update(data) and where(...).delete() are not on the surface.
ListOps in packages/core/src/types/secured-list.ts declares create({ data, select?, include? }), update({ where: { id }, data, ... }) and delete({ where: { id }, ... }). populateDbDelegate (packages/core/src/context/index.ts:1228-1258) puts those three on the list object and copies only where/orderBy/include/select/limit/offset/distinct/distinctOn/cursor/forUpdate/all/first/nearest/aggregate onto it — the composed value where() returns carries no update and no delete at all.
So an agent following this line writes a TypeError. It also contradicts this file's own correct uses at :739 and :825, and packages/core/CLAUDE.md:159, which already states the real shape. create(data) is loose for the same reason — the argument is { data }.
Suggest lifting the core file's wording verbatim: "create({ data }), update({ where, data }), delete({ where })".
There was a problem hiding this comment.
Fixed in dd5b726. Resolved against source rather than reasoned about: ListOps in packages/core/src/types/secured-list.ts declares create({ data, select?, include? }), update({ where, data, select?, include? }) and delete({ where, select?, include? }), with where typed ListIdentityWhere<C, K> = { id: ListId<C, K> } — the row's identity and nothing else. populateDbDelegate confirms the placement at runtime: create/update/delete go on the list object, and the non-singleton branch copies only where/orderBy/include/select/limit/offset/distinct/distinctOn/cursor/forUpdate/all/first/nearest/aggregate, so the value where() returns carries no write member.
The line now reads create({ data }), update({ where, data }) and delete({ where }), says they are members of the list rather than terminals chained off a composed read, and states that where there is { id } alone. data keeps the "row's own columns plus connect" clause, which was the part that was true.
I then swept every write example across all five files: CLAUDE.md:685,739,825 and packages/core/CLAUDE.md:178,180,275,344,574,646 all already use the argument-object form, and packages/core/CLAUDE.md:159 states it correctly in prose. No further instances.
| - A `resolveOutput` hook reading an undeclared column breaks silently unless it is typed through `Lists.<List>.TypeInfo`, where it is a compile error | ||
| - Every to-one read off an included row is a null check, `NOT NULL` column or not | ||
| - A secured read holds its whole result; bounding a large read is the caller's job | ||
| - A row lock is two round trips, and `all().forUpdate()` binds a bounded key set |
There was a problem hiding this comment.
Non-blocking — argument shape. The order is inverted: .forUpdate().all(), not all().forUpdate().
forUpdate is a composition member returning a ListQuery (RowLock in packages/core/src/types/secured-list.ts); all() is a terminal returning a Promise. As written it calls forUpdate on a promise.
There was a problem hiding this comment.
Fixed in dd5b726 — now .forUpdate().all().
Checked against the declarations rather than inferred: in packages/core/src/types/secured-list.ts, RowLock declares forUpdate as () => ListQuery<...> (a composition member returning the composed value), while ComposedRead declares all: () => Promise<...> and first: () => Promise<...> (terminals). ListQuery = ComposedRead & RowLock, so composition returns something you can keep composing and a terminal ends the chain — a terminal is the last call, never the first.
I swept both directions across all five files. grep -E '(all|first)\(\)\.[a-zA-Z]|aggregate\([^)]*\)\.[a-zA-Z]|nearest\([^)]*\)\.[a-zA-Z]|get\(\)\.[a-zA-Z]' returned this line and nothing else; the two worked forUpdate examples (CLAUDE.md:673, packages/core/CLAUDE.md:261) already compose .forUpdate() before .first(). No further inversions.
One note for elsewhere, outside this PR's scope: specs/prisma-8/architecture-spec.md:267 carries the same inversion in its section 14 list, which is presumably where this bullet was condensed from.
| **Generated files:** | ||
| 1. **`prisma/contract.ts`** — the Contract module: standalone and fully literal, importing nothing from the config, so the builder's purity rules hold by construction | ||
| 2. **`prisma/contract.json`** + **`prisma/contract.d.ts`** — the **Contract artifacts**, written by `prisma contract emit`, committed and byte-deterministic. The `.d.ts` carries per-field nullability, codecs, the column map and the relation graph, so nothing downstream re-derives them | ||
| 3. **`prisma.config.ts`** — Prisma's CLI config, importing each declared pack's control descriptor and resolving the database URL through `resolveDatabaseUrl()` |
There was a problem hiding this comment.
Non-blocking — semantics. The generated prisma.config.ts resolves the URL through findDatabaseUrl(), not resolveDatabaseUrl() — see packages/cli/src/generator/prisma-config.ts:84 and :104, and packages/cli/CLAUDE.md:130, which has it right.
The distinction is load-bearing rather than cosmetic: resolveDatabaseUrl throws DatabaseUrlUnresolvedError when neither DATABASE_URL nor a running Dev database is present, and findDatabaseUrl is the non-throwing accessor that exists so the offline Prisma commands still work (packages/core/src/db/url.ts:64-75).
There was a problem hiding this comment.
Fixed in dd5b726, and I kept the reason rather than just the name — the line now reads "resolving the database URL through findDatabaseUrl() — the non-throwing accessor, because this file is evaluated for every Prisma command including the offline ones".
Verified at the emitter, not from the docs: packages/cli/src/generator/prisma-config.ts:84 pushes import { findDatabaseUrl } from '@opensaas/stack-core' and :104 pushes db: { connection: findDatabaseUrl() },. packages/core/src/db/url.ts:67 declares findDatabaseUrl(): string | undefined, and its own docblock states the reason verbatim — the generated prisma.config.ts is evaluated for every Prisma command including the offline ones, so it must not fail when nothing is configured. resolveDatabaseUrl is the sibling at :105 returning ResolvedDatabaseUrl and throwing.
On whether anything else names one where it means the other: I grepped both names across all five files. Three other hits, all correct as written.
packages/cli/CLAUDE.md:130,143,152—findDatabaseUrl, all describing the generated config.packages/core/CLAUDE.md:385—resolveDatabaseUrl, "else a throw", correct for the throwing accessor.CLAUDE.md:278— attributes the runtime's resolution toresolveDatabaseUrl. I checked this one rather than assuming it was the same mistake:packages/core/src/db/client.ts:87does callresolveDatabaseUrl(options)to build the runtime client, so the runtime really is the throwing path and the line stands. The generated CLI config at:381was the only wrong one.
|
|
||
| ```typescript | ||
| // Throws: `token` is read-denied, and this predicate would otherwise be an oracle. | ||
| await context.db.Session.where({ token: { equals: candidate } }).first() |
There was a problem hiding this comment.
Non-blocking — consistency. This block names the same list two ways: context.db.Session here and at :259, but context.db.AuthSession at :265.
Both keys are real — Session under the default derivation (per :136), AuthSession under adoptBetterAuthTables (per :382) — but they cannot both be right inside one example. Pick one for all three lines.
There was a problem hiding this comment.
Fixed in dd5b726 — the third line is now context.db.Session too, so all three agree.
I picked Session because it is what the plugin derives by default, confirmed in source rather than from the surrounding prose: packages/auth/src/lists/index.ts sets DEFAULT_MODELS.session = { modelName: 'Session', fields: {} }, createSessionList() returns deriveAuthLists(DEFAULT_MODELS).lists.Session, and the file's own docblocks name the default set User/Session/Account/Verification. AuthSession only appears under adoptBetterAuthTables({ useBetterAuthTableNames: true }), which this example does not assume. It also matches the paragraph immediately above the block, which frames the whole example as "Opening query on Session for a 'your active sessions' screen".
While there I re-resolved the sudo() call the block depends on, since the same file says at :227 that sudo is "not a method on context": those are two different types and both statements hold. StackContext declares sudo: () => StackContext<DB, S, P, TxDB> at packages/core/src/types/context.ts:65 (and StackTransactionContext at :121), implemented at packages/core/src/context/index.ts:1046; the :227 sentence is about AccessContext, the callback context, where sudo arrives as the second argument to plugin.runtime(context, sudo). No change needed there.
Prettier reformats nothing after the edit — the chain keeps its existing multi-line shape.
The root file taught `create(data)`, `where(...).update(data)` and
`where(...).delete()`. None exists: `ListOps` declares `create({ data })`,
`update({ where: { id }, data })` and `delete({ where: { id } })`, and
`populateDbDelegate` copies only the read-composition members onto the value
`where()` returns, so the composed read carries no write terminal at all.
Three smaller corrections in the same pass: the row lock composes before the
terminal (`.forUpdate().all()`); the generated `prisma.config.ts` calls the
non-throwing `findDatabaseUrl()`, which is why it survives the offline Prisma
commands; and the auth credential-field example named one list two ways.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
borisno2
left a comment
There was a problem hiding this comment.
APPROVE
All four fixes verified against source in a fresh gh pr checkout 1410 worktree, not against the round's own replies. The blocking write-shape defect is gone, the three smaller ones are right, my independent sweeps found no further instance of any of the four classes, and I found no new defect introduced by this round. Nothing here would make an assistant write wrong code.
The four, each resolved against the declaration
1. Write shapes — fixed, and correct. ListOps (packages/core/src/types/secured-list.ts:659-681) declares create({ data, select?, include? }), update({ where, data, select?, include? }) and delete({ where, select?, include? }) as members of the list. It builds its read half as Pick<ListQuery<…>, ComposedReadKey<…>>, so the value where() returns is a ListQuery and carries no write member — the "never terminals chained off a composed read" clause is structurally true, not just true at runtime. where there is ListIdentityWhere<C, K> = { id: ListId<C, K> } (:77), so "the row's identity, { id }, alone" is exact. The retained data clause also holds: CreateInput/UpdateInput (packages/core/src/types/inputs.ts:87-110) admit the row's own columns plus { connect: { id } } on a relation, with | null on the clearable side — matching "assigning null clears the edge".
2. Lock order — fixed, and correct. RowLock declares [M in RowLockKey<Tx>]: () => ListQuery<…> (:511-544) — composition, returning something you keep composing. ComposedRead declares all: () => Promise<…> and first: () => Promise<…> (:614-616) — terminals. .forUpdate().all() is the only order that type-checks.
3. URL accessor — fixed, and the reason is right too. generatePrismaConfig pushes import { findDatabaseUrl } from '@opensaas/stack-core' and db: { connection: findDatabaseUrl() }, (packages/cli/src/generator/prisma-config.ts:84,104). findDatabaseUrl(): string | undefined (packages/core/src/db/url.ts:67); resolveDatabaseUrl is the sibling that throws DatabaseUrlUnresolvedError. The added clause — "evaluated for every Prisma command including the offline ones" — is the accessor's own docblock reason, not a guess.
4. List key — fixed, and it is the right key. All three lines now read context.db.Session. DEFAULT_MODELS.session = { modelName: 'Session', fields: {} } and createSessionList() returns deriveAuthLists(DEFAULT_MODELS).lists.Session (packages/auth/src/lists/index.ts:40,61), so Session is what the plugin derives by default; AuthSession needs adoptBetterAuthTables, which this example does not assume. sudo() the block depends on is real on StackContext (packages/core/src/types/context.ts:65).
My own sweeps (not the round's)
Run over all five changed files at dd5b726a:
- Chained writes:
grep -rnE '\)\s*\.(update|delete)\(|where\([^)]*\)\.(update|delete)'— no matches. All nine write calls in the five files use the argument-object form:CLAUDE.md:685,739,825,packages/core/CLAUDE.md:178,180,275,344,574,646. I read each;:825'screate({ data: { title, author: { connect: { id } } } })and:344'supdate({ where: { id }, data })both match the declarations above. - Composition inversions:
grep -rnE '\.(all|first|aggregate|nearest|get)\([^)]*\)\s*\.[a-zA-Z]'— no matches. EveryforUpdateoccurrence checked by hand (11 across the five files); the two worked examples (CLAUDE.md:673,packages/core/CLAUDE.md:261) compose before the terminal.packages/core/CLAUDE.md:283's "context.db.Slot.forUpdate()is a compile error" is also right —RowLockKey<false>isnever. - Both URL accessors: every occurrence re-resolved.
CLAUDE.md:278attributing the runtime's resolution toresolveDatabaseUrlis correct (packages/core/src/db/client.tscalls it);packages/cli/CLAUDE.md:130,143,152andpackages/core/CLAUDE.md:385are each right for their path.
Looking for what this round introduced
The round is four hunks, 4 changed lines. I resolved every claim in each against source rather than reading for plausibility — signatures against ListOps/RowLock/ComposedRead, where's shape against ListIdentityWhere, data against CreateInput/UpdateInput, the emitted import against generatePrismaConfig, the list key against DEFAULT_MODELS. Nothing new. Given the run of rounds that each introduced an instance of what they fixed, I looked hardest at the two lines that gained new prose (:381, :653) — both added clauses are load-bearing and both check out.
The earlier findings still hold
- Transaction example —
CLAUDE.md:659-687matchespackages/core/src/secured/capacity-gate.test.ts: lock first (:160,234), re-read the parent in its own statement,aggregate((aggregate) => ({ taken: aggregate.count() }))(:170,243), compare. The test's third case (onLockedRowvsafterLock,:245-261) is exactly what the "columns as of before the lock" prose describes. Undisturbed. - Index sort —
refuseIndexSortinpackages/core/src/validation/database-config.ts:6-28refuses by name withreason: 'index-sort'.sort:occurs 0 times in the five files;CLAUDE.md:438states the refusal correctly. - Session shape —
getContext({ session })survives only as the named bug atCLAUDE.md:641andpackages/auth/CLAUDE.md:414. Every real call passes the session's own fields. - Removed surfaces — 0 occurrences each for
getAdminContext,OperationAccessControl,applyAccessControl,createContext,@opensaas/stack-tiptap/components,defineFragment,runQuery,ResultOf; and 0 forprismaClientConstructor,extendPrismaSchema,joinTableNaming,relationName,sqlite,better-sqlite3,P2002,P2025,PrismaClientKnownRequestError,findMany,findUnique,getDbKey,@@map/@@schema/@@index/@@unique. The twostack-mcphits arestack-mcp-server.ts, which exists underpackages/cli/src/mcp/server/— not the deleted package.
Hygiene
- Documentation only.
git diff --name-statusagainst the merge base is fiveMrows, allCLAUDE.md. No package source, no manifest,scripts/has a zero-line diff — sofiles.txtis reverted and no excuse was added, widened or removed. No changeset needed. - Doc checker: because
scripts/is byte-identical to the base and none of the five files is listed infiles.txt, the check's inputs are unchanged by this PR and its result cannot differ fromprisma-8's. I ranpnpm check:doc-ts-blocksand--self-testin the fresh tree; both stop at "Runpnpm buildfirst" because that worktree has nodist/— a property of my checkout, not of the diff. The CItestjob covers it on this SHA and is green. Restating the standing caveat: the five files themselves remain covered by no checker. - Format:
prettier --checkon all five — "All matched files use Prettier code style!" - CI on
dd5b726a— 7/7 green:testsuccess,coveragesuccess,e2e (dev-database)success,e2e (postgres)success,GitGuardian Security Checkssuccess,Vercel Preview Commentssuccess,Vercelsuccess. Combined statussuccess. None of #1404, #1411 or #1260 appeared.
Follow-up residue (non-blocking, not for this PR)
all().forUpdate() as a name for the multi-row lock path is repo-wide idiom, not one stray line: specs/prisma-8/architecture-spec.md:48,227,267, docs/adr/0062-…:27, and the published docs/content/reference/context-api.md:397. The change here is right regardless — CLAUDE.md:1198 sits in a file agents copy code from, so the literal order is what belongs there — but the out-of-scope note naming only spec :267 understates the spread, and context-api.md is reader-facing. Worth one issue covering all four, not four changes here.
Good to land.
| therefore read in its own statement _after_ the lock. | ||
| **Reads are composed, then run by a terminal.** `where`, `orderBy`, `select`, `include`, `limit`, `offset`, `cursor`, `distinct`/`distinctOn` build an immutable value; `all()`, `first()`, `aggregate()` and `nearest()` run it. A method appears on the surface only where the engine knows how to scope it — omission is the signal, not an oversight. A read materialises: no terminal is an async iterable, and a caller that needs a cursor uses `context.unsafe`. | ||
|
|
||
| **Writes take scalars plus `connect`.** `create({ data })`, `update({ where, data })` and `delete({ where })` are members of the list itself, never terminals chained off a composed read — and `where` there is the row's identity, `{ id }`, alone. `data` accepts the row's own columns plus `connect` on a field that owns the foreign key, where it lowers to a reachability query against the target's `query` access and a scalar foreign-key write. Assigning `null` clears the edge. There is no nested `create`/`update`/`delete`/`connectOrCreate`/`set`. A caller writing several rows atomically does so inside `context.transaction` (ADR-0050). |
There was a problem hiding this comment.
Verified — blocking finding resolved. Resolved against the declaration, not the reply.
ListOps (packages/core/src/types/secured-list.ts:659-681) puts create({ data, select?, include? }), update({ where, data, select?, include? }) and delete({ where, select?, include? }) on the list, and builds its read half as Pick<ListQuery<…>, ComposedReadKey<…>> — so the value where() returns is a ListQuery and carries no write member at all. "Members of the list itself, never terminals chained off a composed read" is structurally true, not merely true at runtime.
where there is ListIdentityWhere<C, K> = { id: ListId<C, K> } (:77), so "the row's identity, { id }, alone" is exact. The retained data clause also checks out: CreateInput/UpdateInput (packages/core/src/types/inputs.ts:87-110) admit the row's own columns plus { connect: { id } } on a relation, with | null on the clearable side.
My own sweep, independent of the round's: grep -rnE '\)\s*\.(update|delete)\(|where\([^)]*\)\.(update|delete)' over all five files returns nothing, and all nine write calls (CLAUDE.md:685,739,825, packages/core/CLAUDE.md:178,180,275,344,574,646) use the argument-object form — I read each rather than counting matches.
| - A `resolveOutput` hook reading an undeclared column breaks silently unless it is typed through `Lists.<List>.TypeInfo`, where it is a compile error | ||
| - Every to-one read off an included row is a null check, `NOT NULL` column or not | ||
| - A secured read holds its whole result; bounding a large read is the caller's job | ||
| - A row lock is two round trips, and `.forUpdate().all()` binds a bounded key set |
There was a problem hiding this comment.
Verified — correct now. RowLock declares [M in RowLockKey<Tx>]: () => ListQuery<C, R, K, Included, Selected, Tx> (packages/core/src/types/secured-list.ts:511-544) — composition, returning something you keep composing. ComposedRead declares all: () => Promise<…> and first: () => Promise<…> (:614-616) — terminals. .forUpdate().all() is the only order that type-checks.
My sweep for the inverse class: grep -rnE '\.(all|first|aggregate|nearest|get)\([^)]*\)\s*\.[a-zA-Z]' over all five files returns nothing, and I checked all 11 forUpdate occurrences by hand.
Follow-up residue, wider than the note in the reply. all().forUpdate() as a name for the multi-row lock path is repo-wide idiom, not one stray spec line: specs/prisma-8/architecture-spec.md:48,227,267, docs/adr/0062-…:27, and the published docs/content/reference/context-api.md:397. The change here is right regardless — this file is one agents copy code from, so the literal order belongs in it — but context-api.md is reader-facing, and one issue should cover all four rather than the spec alone. Not for this PR.
| **Generated files:** | ||
| 1. **`prisma/contract.ts`** — the Contract module: standalone and fully literal, importing nothing from the config, so the builder's purity rules hold by construction | ||
| 2. **`prisma/contract.json`** + **`prisma/contract.d.ts`** — the **Contract artifacts**, written by `prisma contract emit`, committed and byte-deterministic. The `.d.ts` carries per-field nullability, codecs, the column map and the relation graph, so nothing downstream re-derives them | ||
| 3. **`prisma.config.ts`** — Prisma's CLI config, importing each declared pack's control descriptor and resolving the database URL through `findDatabaseUrl()` — the non-throwing accessor, because this file is evaluated for every Prisma command including the offline ones |
There was a problem hiding this comment.
Verified at the emitter. generatePrismaConfig pushes import { findDatabaseUrl } from '@opensaas/stack-core' (packages/cli/src/generator/prisma-config.ts:84) and db: { connection: findDatabaseUrl() }, (:104). findDatabaseUrl(): string | undefined at packages/core/src/db/url.ts:67; resolveDatabaseUrl is the sibling that throws DatabaseUrlUnresolvedError.
The added clause is the accessor's own docblock reason rather than a reconstruction — url.ts:55-60 says the generated prisma.config.ts "is evaluated for every Prisma command, including the offline ones (contract emit), so it must not fail when nothing is configured."
I re-resolved every occurrence of both names across the five files myself. CLAUDE.md:278 attributing the runtime's resolution to resolveDatabaseUrl is correct (packages/core/src/db/client.ts calls it to build the client), and packages/cli/CLAUDE.md:130,143,152 and packages/core/CLAUDE.md:385 are each right for their path. This was the only wrong one.
| // Reads the row with `token` on it, and owns the decision to elevate. | ||
| const full = await context | ||
| .sudo() | ||
| .db.Session.where({ id: { equals: id } }) |
There was a problem hiding this comment.
Verified — internally consistent, and Session is the right key. All three lines in the block (:256, :259, :265) now read context.db.Session.
Confirmed against the derivation rather than the surrounding prose: DEFAULT_MODELS.session = { modelName: 'Session', fields: {} } (packages/auth/src/lists/index.ts:40) and createSessionList() returns deriveAuthLists(DEFAULT_MODELS).lists.Session (:61), so Session is what the plugin derives by default. AuthSession requires adoptBetterAuthTables, which this example does not assume.
sudo() the block chains off is real on StackContext (packages/core/src/types/context.ts:65), so it does not collide with this file's :227 statement that sudo is not a method on context — that sentence is about AccessContext, a different type.
Implements the guidance half of #1175. Part of #1129.
The engineering-manager half of #1175 — merging
prisma-8intomain— is not in this PR and is not started here.What changed
CLAUDE.md,packages/core/CLAUDE.mdandpackages/auth/CLAUDE.mdnow describe the architecture the repository actually has: the two surfaces, the contract generator and its five emissions, the dev loop and the dev/production migration split, transactions and the row lock, the stack-owned error classes, and auth over the Auth adapter. No deleted construct is named in any of the three except as an explicit historical pointer — the one instance ispackages/core/CLAUDE.mdsaying ADR-0024 and ADR-0026 are withdrawn, where the sections that used to state Bare read and One hop as stack rules stood.packages/cli/CLAUDE.mdandpackages/tiptap/CLAUDE.mdare outside the three files the ticket names. They are fixed here anyway: both carried the same class of defect the ticket exists to eliminate, in the same kind of file. The CLI file's fabricated import sat inside a sample that was fiction in every other line, so its generation half is rewritten too; its migration half is untouched, because the Keystone story is out of scope for this effort (spec §15, ADR-0041) and the Prisma schemas it reads belong to a source project, not to us.What the exported-surface diff found
I enumerated the real exported surface from the built
.d.tsfiles named by each package's ownexportsmap — 40 subpaths, 809 names, walked with the TypeScript checker — then diffed every@opensaas/…import specifier and named import in the five files against it, and every backticked identifier against the union of those names and every token underpackages/*/src.Before, per file:
CLAUDE.md—getAdminContextimported from@opensaas/stack-ui/server. That package exportsserializeFieldConfig,serializeFieldConfigsand three types;getAdminContextis not among them and appears nowhere underpackages/*/src. Also@opensaas/stack-mcpnamed as a package — there is none.packages/core/CLAUDE.md—OperationAccessControl(the real name isOperationAccess),applyAccessControl(), andcreateContext(config, ormHandle, session?)as the context factory's name. None of the three resolves anywhere in the packages' source.packages/cli/CLAUDE.md—createContextimported from@opensaas/stack-core.packages/tiptap/CLAUDE.md— an import from@opensaas/stack-tiptap/components, which the package'sexportsmap does not declare (it declares.and./fields).packages/auth/CLAUDE.md— clean on this diff. Its defects were semantic, not fabricated bindings.After: 0 findings on all five files. The remaining unresolved backticked names are
blog_post(a deliberate counter-example in prose),wontfix(a triage label),SlotList/SlotTxList(per-application generated names — I verified the generator emits<List>Listand<List>TxList), anduseSession/signIn/signOut/storeTokens/storeClientSecret(better-auth's own).Defects fixed beyond the fabricated bindings
sortclaim. The root guidance documented{ field: 'createdAt', sort: 'desc' }on adb.indexesentry as working and showed the index it supposedly emitted.packages/core/src/validation/database-config.tsrefuses it by name with reasonindex-sort. Fixed against the code, as the ticket instructs.context.ormHandleon the request context. The root file's module-init recipe and the auth file'screateAuthnote both named it.ormHandleis a member ofAccessContext;StackBaseContextcarriesunsafeinstead andormHandleis deliberately not on it. Found by resolving the member against the type, not by the sweep — see blind spots below.getContext({ session })in the auth guidance (A session object holding undefined is truthy, so an anonymous caller is treated as signed in #1397). A wrapper object, which makes an anonymous caller read as signed in: the generated factory passessession ?? nulland an object holdingundefinedis truthy. Replaced with the corrected branch, andgetSessionFromAuthis now shown with its real three-argument signature (it callsauth.api.getSessionitself — the previous sample passed it an already-resolved session).context.db.post.update,context.db.task.update,context.db.session.findUnique, andfindMany/countin the auth file's credential-fields section. Rewritten onto the composed read, with the stripped-field case and the predicate-time read check separated.src/mcp/projection.tsas it stands.The root guidance's row-lock example was already correct on
prisma-8— #1335 and #1172 fixed it after the note on #1175 was written. I left it as it stands and checked it againstpackages/core/src/secured/capacity-gate.test.tsline by line.The sweep's scoping assumption, and what it cannot see
Assumption: a package's built
exportsmap is the whole of its public surface, and a name is real if the checker lists it as an export of one of those entries. That holds only against a built tree —pnpm buildran first, and everyexports[*].typesfile was present (noMISSINGrows). Against a staledist/the diff would pass a wrong file.Blind spots, structurally:
import { X } from 'pkg'; it does not resolvecontext.ormHandleagainstStackBaseContext, orslot.capacityagainst a row type. That defect class is invisible to it — I found theormHandleone by reading the type.getSessionFromAuth(resolved, sessionFields)sweeps clean: the name exists and the specifier is right. Only reading the signature showed it takes(auth, sessionFields, headers).virtual({ outputType })where the builder takestypeand producesoutputType— resolves fine. I caught that one by readingpackages/core/src/fields/index.tsafter writing it wrong.@opensaasspecifiers.@prisma/orm-postgres/runtime,decimal.js,@neondatabase/serverlessare checked against nothing.DATABASE_URL" — each verified by reading the code, none by the sweep.The documentation type-checker
I added all five
CLAUDE.mdfiles toscripts/doc-blocks/files.txtand ran the check. 72 blocks fail, in four classes, and only one is fixable without adding an excuse — which #1175 forbids. 26 diagnostics are modules the scratch project cannot resolve and that are the subject of their sample (decimal.js,@neondatabase/serverless,./components/MyCustomField); 21 are lists the hand-written prelude does not model (context.db.Post,.Slot,.Booking); 17 parse errors plus most of 110 unresolved names are illustrative fragments that are not statement lists (alists: { … }body, a field builder shown as it appears inside core).files.txtis reverted here and the check is green and unchanged: 126 blocks, 89 compile, 28 fragment entries, 0 stale, 0 orphaned — no excuse added, widened or removed. Recorded with the full measurement on #1301.The fragment API
The ticket also asks for the migration guide's fragment-API references to be removed.
docs/content/how-to/migrate.mdanddocs/content/how-to/migrate-from-keystone.mdalready carry none — #1174 did that. Every remaining "fragment" in them names Keystone's own GraphQL fragments on the left-hand side of a mapping table, which is correct. The stack's deleteddefineFragment/runQuery/ResultOfsurvive only inclaude-plugins/opensaas-migration/skills/, which ADR-0041 and spec §15 both put out of scope for this effort; I left them and am filing them separately rather than leaving it implicit.Verified by running, not by reasoning
pnpm build— clean, and the surface enumeration ran against that treepnpm check:doc-ts-blocks— green;--self-test— 55 blocks, 0 mismatchespnpm check:prisma-error-codes— no literals beyond the allowlistpnpm check:adr-duplicates,pnpm check:client-side-effect-imports— cleanpnpm --filter opensaas-stack-docs link-check— 10 paths, 32 redirects, 34 pagespnpm --filter opensaas-stack-docs build— exit 0pnpm lint— one pre-existing warning inmigration-generator.ts, untouched by this PRpnpm format/prettier --writeon every file touchedpnpm dev -- tsx some-script.ts— I probed pnpm's argument forwarding with a throwaway package rather than assuming it, because pnpm's handling of--decides whether that line in the guidance is trueNo changeset: no package source changed.
🤖 Generated with Claude Code