When Ahmed asked for a tool that would let an AI populate a Studio canvas — designing a full app with models and workflows — the first instinct was to load the manifest, spin up the model classes, and introspect from there.
That instinct is wrong. And the reason it’s wrong reveals a pattern that applies far beyond this specific case.
The Running Process Problem
The obvious way to know what fields a model has is to instantiate it and call collectFields(). This works perfectly when you have a running app. The DarJS MCP tools do exactly this for most operations: they load the manifest, the models are live, everything is available.
But the canvas tools have a different problem. They don’t operate on an existing app. They’re building one from scratch, validating a proposed structure, pushing a new model graph to the Studio. There’s nothing to load yet. And even when there is an existing app, requiring the full framework to start in order to answer “what fields does an Order have?” is a heavy dependency for a lightweight question.
The question “what fields does this model have?” is a metadata question. It doesn’t require execution. The information exists in the source file — in the schema.prisma or the models/Order.js — as text. Parsing that text is faster, simpler, and works in contexts where running the app isn’t possible or desirable.
The SchemaAdapter Interface
The solution in DarJS is a three-level adapter hierarchy:
SchemaAdapter (abstract)
├── DarJSSchemaAdapter — wraps live ModelClass[]
└── PrismaSchemaAdapter — parses schema.prisma as text
All three implement the same interface: getModels(), getFields(name), getTransitions(name). Every consumer — the PageDef renderer, the router, the CLI generator — calls through this interface. None of them know or care which implementation is behind it.
The abstract base is intentionally strict: every method throws ${this.constructor.name}.method() not implemented. If you subclass and forget a method, the error is immediate and named. Silent no-ops are worse than loud failures.
DarJSSchemaAdapter is the live path. It wraps an array of loaded ModelClasses, delegates getFields to collectFields() and getTransitions to the static transitions map. This is the path used in a running app.
PrismaSchemaAdapter is the text path. It receives the raw content of schema.prisma, strips comments, extracts enum blocks and model blocks with regex, maps Prisma scalar types to DarJS types, resolves enum field values. No imports. No execution. No database connection. The result is the same interface: a flat field map, nullable flags, default values, enum members.
What the Parser Actually Does
A Prisma schema model block looks like this:
model Order {
id String @id @default(uuid())
status OrderStatus @default(PENDING)
total Decimal? @default(0)
created_at DateTime? @default(now())
order_id String
}
enum OrderStatus {
PENDING
CONFIRMED
SERVED
}
The parser extracts the enum block first — the model block references it by name. Then it processes each model field: the type token maps to a DarJS type (Decimal → decimal, Boolean → boolean, DateTime → datetime), the ? suffix becomes { nullable: true, required: false }, and the @default(...) value is extracted handling one level of nested parentheses — which is enough to capture now(), uuid(), and autoincrement() correctly.
Relation fields — fields whose type resolves to neither a Prisma scalar nor a known enum — are skipped. They don’t have DarJS field equivalents and don’t belong in a column or filter list.
The one thing PrismaSchemaAdapter always returns for getTransitions() is null. Prisma schemas don’t encode workflow state machines. If you need transitions, you need the live DarJS model, or you need to supply them separately. The interface doesn’t pretend otherwise.
Why This Matters for Agentic Tools
An AI tool that calls get_mixin_vocab or validate_app on the DarJS MCP server needs field metadata. But it may be working on a fresh scaffold before the models are loaded, or it may be a static analysis tool that processes repos without running them, or it may be validating an IR payload that was just typed into the canvas.
In all of those cases, requiring a running process to answer a metadata question is the wrong dependency.
The broader principle: a running process knows more than its source files, but it also requires more to operate. When the question is only about structure — what fields exist, what types they have, what defaults are set — the source file is sufficient and the running process is unnecessary overhead.
This maps to a general rule for AI tools: your introspection interface should have a text-only implementation. Not because it will always be used, but because having it forces the interface to be genuinely stateless. If you can answer a structural question from text alone, the interface is clean. If you can’t, there’s hidden state dependency in your abstraction that will surface at the wrong moment.
The Consumer Side
The payoff of the adapter is in the consumers. PageDefRenderer.listContext() accepts a schema option. When it’s present, it calls schema.getFields(pageDef.model) to auto-derive filter types and enum values. When it’s absent, it falls back to ModelClass.collectFields(). Both paths produce the same output.
app.js builds a DarJSSchemaAdapter from the manifest at startup and stores it on app.locals.schema. Every request that reaches PageDefRouter has access to it. The router passes it to every renderer call. The renderer never calls collectFields() directly anymore.
The same schema is used in coerceBody() — which needs field types to convert form strings to the right JavaScript types — and in buildWhere() — which needs to know whether a model has a store_id field before applying store scoping. Both of these are metadata questions. Both now read from the adapter.
The adapter was also wired into the generate command: before calling PageDefGenerator.fromManifest(), it now builds a DarJSSchemaAdapter and passes it in. The generator uses the schema path for all page generation instead of calling fromModel() per class. Same output, cleaner path.
The Test That Proves the Interface Works
The validation for any adapter interface is: can you swap implementations in a consumer test without changing the test?
The PageDefRenderer tests pass DarJSSchemaAdapter or plain ModelClass or PrismaSchemaAdapter — the test expectations are identical. The renderer doesn’t know which source provided the fields. The consumer tests for PageDefGenerator.fromSchema and the router tests don’t reference adapter internals at all.
This is the practical test for whether an abstraction is real: the consumer tests are identical regardless of which implementation is behind the interface. If you need to write separate consumer tests per implementation, the interface is leaking.
The Pattern
Build a text implementation of any introspection interface you expose to AI tools. Not because the live implementation is wrong — it’s correct and fast. But because the text implementation:
- Forces the interface to be genuinely stateless
- Works in static analysis, scaffolding, and pre-boot contexts where the running process isn’t available
- Makes the interface testable without framework initialization
- Signals to every future caller what the interface actually requires
The PrismaSchemaAdapter is 150 lines. The DarJSSchemaAdapter is 80 lines. The SchemaAdapter base is 30 lines. Three files, one interface, zero hidden state. Any AI tool that needs to know what fields a model has can now get that answer from a file path, a manifest, or a live class — same call either way.