RFC 0001 — Typed, discoverable resources (+ code-first schema spikes)#1503
RFC 0001 — Typed, discoverable resources (+ code-first schema spikes)#1503dawsontoth wants to merge 11 commits into
Conversation
Proposes one canonical schema model (GraphQL or TypeScript front-ends), a per-method request contract with derived handler types, and typed base-request behavior (filter/sort/select/include) — all feeding validation, OpenAPI, and MCP from a single source of truth. Companion spikes (t builder + defineTable inference, and defineTable -> table() registration) follow in this PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Code Review
This pull request introduces RFC 0001, which proposes a unified approach for typed, discoverable resources in HarperDB through a single canonical schema model, per-method request contracts, and typed query behaviors. The feedback suggests improving the proposed PathParams utility type to support wildcard path segments (e.g., *filepath) to align with the existing runtime capabilities.
Self-contained, type-checked proof of code-first schema inference under the repo's NodeNext/erasableSyntaxOnly settings (strict). Demonstrates: - Select<typeof T>: enum -> literal union, nullable -> optional, relation -> related record (to-one) / array (to-many), server-managed fields -> readonly. - Insert<typeof T>: drops primary key + server-managed + relations, keeps writable fields, preserves optionality. - Negative cases (excess PK / server field, off-enum literal, missing required) rejected via @ts-expect-error. Verify: npx tsc --noEmit --project docs/rfcs/spikes/0001/tsconfig.json Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proves a code-first schema value compiles into the existing table() factory's options (resources/databases.ts) — the same factory GraphQL drives and dataLoader.ts already uses — yielding a working table with no GraphQL: - registered in the databases registry - attribute metadata reflects the compiled schema (PK, types, indexed, assignCreatedTime; enum -> String column) - CRUD round-trip with server-managed createdAt auto-assigned - a schema change (added indexed attribute) re-asserts on the same class and triggers reindexing — the migration/DDL-parity guardrail in action Run: npx mocha docs/rfcs/spikes/0001/defineTable-registration.spike.test.js (requires a built dist/, i.e. `npm run build` first; or wire into test:unit) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Run prettier on the RFC doc and spike (b) to satisfy Format Check.
- Extend the PathParams sketch to handle Harper's `*wildcard` path segments
(named `*path` -> { path: string }; bare `*` -> { wildcard: string }),
matching `static path` support (resources/Resource.ts). Addresses PR review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A custom resource on a parameterised path (e.g. `static path = '/widget/:id'`) is stored by Resources.setParamRoute in the `paramRoutes` side-array and never inserted into the base Map. buildApplicationTools iterated only the Map, so such resources produced ZERO MCP tools — even though OpenAPI (which iterates paramRoutes) exposed them. Enumerate `resources.paramRoutes` too, so the MCP tool surface matches the REST surface. The common single-`:id` case binds via `target.id` in the existing verb handlers; richer multi-segment/named-wildcard binding rides on the per-method request contract (RFC 0001). Regression test: unitTests/components/mcp/tools/application-paramroutes.test.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nship Promote the defineTable->table() registration spike into the suite (unitTests/resources/defineTable-registration.test.js) so CI exercises it, and extend it with a many-to-one @relationship resolving end-to-end: the relation compiles to a relationship attribute with an auto-added indexed FK column and a linked definition.tableClass, and the related record resolves on read. Passes under both the default and lmdb storage engines. Update the RFC appendix for the promotion and the MCP param-route fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
This is excellent, lots of great ideas in here. Let me comment on some of them:
One source of truth ... Today GraphQL SDL is the only way to define a table, and the canonical Attribute[]
Yes, that was previously true, and we did have a lot of muddiness with this in the past, but I believe #1167 has really solidified a single source of truth around canonical JSON-schema aligned data structures on classes. This is fed from graphql and is compatible/consistent across tables and custom resources, I believe, and largely addressed pillar 1.
defineTable
I think defineTable would be a great addition. I think there are some things to consider here:
- I would like to consider a little more literate version of this, is it feasible to write:
import { defineTable, types } from 'harper';
const { id, int, string } = types;
export const Tracks = defineTable('Tracks', {
id: id.primaryKey,
name: string.indexed,
duration: int.nullable,
- There are numerous options for tables beyond their fields, including expiration, sealed, replicated, etc. I guess that all goes in a third arg?
- I still believe that trying to teach people how to simultaneously deal with a singular
Trackand pluralTracksis going to be confusing. I have very intentionally sought a singular table name and type to give devs a single concept work with. I think when/why to plurarize is not a complexity that we want to burden devs with.
4.2 Serve every surface from one projector
Is one projector different than a single source of truth?
Pillar 2 — A per-method request contract
Augmenting a resource with schema-enforcement (and trustable types built on that), sounds good. But the current withSchema seems rather hostile to our best-practice of static methods and seems to introduce a third request/target type, RequestOf. I don't think we want the complexity of third request type, I think polymorphic/type-subset compatibility should be a explicit goal of schema-ification. And it should be compatible with top-level (non-instance functions):
export const Widget = {
path: '/widget/:id',
async get(req) {
return loadWidget(req.id, req.get('expand'));
}
async post(req) {
return createWidget(req.body);
}
}
// and StricterWidget should be type compatible stricter subset, directly drop-in replacement, no new types
export const StricterWidget = enforceSchema<RecordType>({ // RecordType is default return type of `get` and input type of `put`
get: ... extra type definitions
post: ...
// req.id: string · req.get('expand')?: ('parts'|'owner')[] * req.body all are now inferred in a type subsetting fashion
}, Widget);
I'm hesitant to get into too much discussion of later pillars since they appear to somewhat build on top of each other.
Also, I'm curious about the intended future of the PR. Is 0001-typed-discoverable-resources.md a transient component of the PR? We have intentionally been putting proposals in GH issues (and several much larger than this) rather than PRs to avoid transient information in the repo. However, implementation plans as a transient artifacts of a PR in progress is something we have occasionally done.
|
|
||
| > **Sharpest finding.** A custom resource with `static path = '/widget/:id'` is registered into a `paramRoutes` side-array and `Resources.set()` returns _before_ adding it to the base map that MCP iterates. Result: parameterized custom resources produce **zero MCP tools**, while OpenAPI emits them with string-only params and a generic body. | ||
|
|
||
| ## 4. Pillar 1 — One canonical schema model |
There was a problem hiding this comment.
@kriszyp breaking the various threads out for easier discussion:
One source of truth ... Today GraphQL SDL is the only way to define a table, and the canonical Attribute[]
Yes, that was previously true, and we did have a lot of muddiness with this in the past, but I believe #1167 has really solidified a single source of truth around canonical JSON-schema aligned data structures on classes. This is fed from graphql and is compatible/consistent across tables and custom resources, I believe, and largely addressed pillar 1.
I think 1167 helps to fill out the information we can feed out through MCP and OpenAPI, but it doesn't tackle the harder problem I see pillar 1 addressing: a single, discoverable, cohesive surface defining the schemas that infers types with full language support without any generation.
There was a problem hiding this comment.
So a canonical schema encompasses the scope of types? That seems to contradict pillar's assertion that it "also be authored in TypeScript", and "no generation"; i.e. we aren't attempting to create (canonical)types from graphql in the scope of this PR, are we? However, I definitely support the ability to define a single canonical source of type definitions and runtime metadata for a specific table from a defineTable call though (that's just don't make everything have a universal single source of truth across types and runtime).
| id: t.id().primaryKey(), | ||
| name: t.string().indexed(), | ||
| duration: t.int().nullable(), | ||
| status: t.enum(['draft', 'published']), // -> 'draft' | 'published' | ||
| createdAt: t.createdTime(), // server-managed -> readonly, omitted from insert | ||
| album: t.relation(() => Albums, { from: 'albumId' }), // a real reference -> exact typed graph |
There was a problem hiding this comment.
I would like to consider a little more literate version of this, is it feasible to write:
class Types {
private metadata: Partial<Record<keyof Types, any>> = {};
get id(): Types {
this.metadata.id = true;
return this;
}
get string(): Types {
this.metadata.string = true;
return this;
}
enum(values: string[]): Types {
this.metadata.enum = values;
return this;
}
get indexed(): Types {
this.metadata.indexed = true;
return this;
}
get nullable(): Types {
this.metadata.nullable = true;
return this;
}
build(): Partial<Record<keyof Types, boolean>> {
return this.metadata;
}
}
const types = new Types();
console.log('empty build:');
console.log(types.build());
console.log('example string');
console.log(types.string.indexed.build());
console.log('example enum');
console.log(types.indexed.enum(['a', 'b']).build());
output:
empty build:
{}
example string
{ string: true, indexed: true }
example enum
{ string: true, indexed: true, enum: [ 'a', 'b' ] }
There was a problem hiding this comment.
Seems like you could do this without the build() call (by skipping metadata layer), right?
| type Track = t.Select<typeof Tracks>; // inferred read record | ||
| type NewTrack = t.Insert<typeof Tracks>; // PK + computed + timestamps omitted, inferred |
There was a problem hiding this comment.
I still believe that trying to teach people how to simultaneously deal with a singular Track and plural Tracks is going to be confusing. I have very intentionally sought a singular table name and type to give devs a single concept work with. I think when/why to plurarize is not a complexity that we want to burden devs with.
@kriszyp I think this shows where the singular vs plural discussion comes to something fruitful. A table doesn't give or receive a single interface, it needs different things when you're querying, responding, inserting, upserting, patching, etc. By having the table class in TypeScript, we can follow the type inference precedents set out by other libraries to make it really easy to maintain this sprawl of types. Because the fields are defined in TypeScript, we can include or omit fields automatically.
There was a problem hiding this comment.
We can't genericize t.Select<typeof Track> and t.Insert<typeof Track?
There was a problem hiding this comment.
Can you show me what you mean with example code? I'm a bit confused.
There was a problem hiding this comment.
here is what I generated for a spike/example: https://github.com/HarperFast/harper/blob/claude/sad-lehmann-1b3b70/docs/rfcs/spikes/0001/canonical-track.spike.ts
There was a problem hiding this comment.
sure, looks good to me
| status: t.enum(['draft', 'published']), // -> 'draft' | 'published' | ||
| createdAt: t.createdTime(), // server-managed -> readonly, omitted from insert | ||
| album: t.relation(() => Albums, { from: 'albumId' }), // a real reference -> exact typed graph | ||
| }); |
There was a problem hiding this comment.
There are numerous options for tables beyond their fields, including expiration, sealed, replicated, etc. I guess that all goes in a third arg?
I think so. Another approach would be to shift to accept a single dictionary and do it based off of names, nest the fields in one level. That would be more discoverable, and I like the visual nesting you get, too.
There was a problem hiding this comment.
Also, I'm curious about the intended future of the PR. Is 0001-typed-discoverable-resources.md a transient component of the PR? We have intentionally been putting proposals in GH issues (and several much larger than this) rather than PRs to avoid transient information in the repo. However, implementation plans as a transient artifacts of a PR in progress is something we have occasionally done.
It was purely because PRs have threads, separate files, and syntax highlighting. All of which feel very helpful for the conversation. I don't think the RFC would actually land in the repo, since over time it'll become inaccurate. The 0001 was Claude being optimistic.
|
|
||
| ## 4. Pillar 1 — One canonical schema model | ||
|
|
||
| Today GraphQL SDL is the only way to define a table, and the canonical `Attribute[]` it produces is then translated to a type-shaped representation in **four** independent places that disagree. Two changes turn that model into a true single source of truth: let it also be **authored in TypeScript**, and serve every surface from **one projector** instead of four. Both front-ends compile to the same internal model; everything else is derived from it. |
There was a problem hiding this comment.
Is one projector different than a single source of truth?
I don't know that projector was the best choice of words, but the way I was visualizing it is... GraphQL can be a single source of truth, yes. But there isn't language-service-level-support for making it project outward into JavaScript and TypeScript in all the ways we need for a great developer experience. That's where the codegen plugin I wrote comes in, but it has compromises (like causing a cascade of Harper restarts, or only doing stuff when Harper is running it). OpenAPI and MCP do similar work for us because GraphQL doesn't natively have a bridge to TS/JS.
By instead shifting the source of truth to be in that TS/JS world, as soon as the characters are typed, the interfaces can be inferred without any codegen (thus eliminating that plugin). The MCP and OpenAPI work becomes more succinct as well.
|
|
||
| > **Main implementation risk.** The TypeScript front-end must drive the exact same DDL/migration semantics as GraphQL — attribute add/remove, index changes, `sealed`, audit, replication — or the two paths subtly diverge. The front-end conformance fixture is the guardrail; schema evolution should run off the canonical `typeDef`, not off either authoring format directly. | ||
|
|
||
| ## 5. Pillar 2 — A per-method request contract |
There was a problem hiding this comment.
Augmenting a resource with schema-enforcement (and trustable types built on that), sounds good. But the current withSchema seems rather hostile to our best-practice of static methods and seems to introduce a third request/target type, RequestOf. I don't think we want the complexity of third request type, I think polymorphic/type-subset compatibility should be a explicit goal of schema-ification. And it should be compatible with top-level (non-instance functions):
export const Widget = {
Couldn't we use the provided language concepts for polymorphism? The struggle with statics is discoverability and adherence to an interface. You can put anything you want on export const Widget, and if you typo gwt instead of get, we have no mechanism to detect if this is a messed up resource or table or some utility class that we should ignore. But yes, this does lead to a very different interface across the stack, and hopefully to the removal of the old interfaces in a large breaking change, v6, v7, whenever.
…ed from one name Answers the singular-vs-plural thread: the dev names ONE `Track`. It stays the central type — `type Track = InstanceType<typeof Track>` is the live, mutable instance (`Track.update()` result: writable fields + `.save()`, `instanceof Track`). The read-only and write-side shapes are derived projections off it, exposed as members: `$record` (TrackRecord), `$insert`, `$upsert`, `$patch`, `$query`. Field flags use the proposed property/getter surface (`id.primaryKey`, `string.indexed`, `int.nullable`, `date.createdTime`) via a destructured `types`; only arg-taking builders (`enum([...])`) remain calls. Type-checks under repo settings (strict, NodeNext, erasableSyntaxOnly); @ts-expect-error lines pin the negative cases (readonly read-variant, server fields non-writable, PK required on upsert, off-enum, non-indexed query key). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…no third request type Proves the Pillar-2 alternative from the review thread: handlers keep the same (narrowed) RequestTarget, enforced resources are drop-in assignable to the untyped plain-object/class-static shape, and verb typos are caught both bare (satisfies) and under the contract (required verbs + excess-property). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@dawsontoth I put together spikes at docs/rfcs/spikes/0001/t-builder.spike.ts and an enforce-schema spike to plan out these paths. Let me know what you think (and I could proceed with implementation). |
| get: { query: { expand: s.arrayOf(s.enumOf(['parts', 'owner'])).optional } }, | ||
| post: { body: schemaOf<NewWidget>(), response: schemaOf<Widget>() }, | ||
| }, | ||
| { | ||
| async get(target) { | ||
| // target.id: string (from ':id') · target.get('expand'): ('parts'|'owner')[] | undefined | ||
| return loadWidget(target.id, target.get('expand')); | ||
| }, | ||
| async post(_target, data) { | ||
| // data: NewWidget (declared body) — no cast | ||
| return createWidget(data); | ||
| }, |
There was a problem hiding this comment.
Could these collapse together into a single get and post, instead of having two positional arguments? So...
get: {
query: { expand: s.arrayOf(s.enumOf(['parts', 'owner'])).optional },
execute: async get(target) {
// target.id: string (from ':id') · target.get('expand'): ('parts'|'owner')[] | undefined
return loadWidget(target.id, target.get('expand'));
},
}
…FC 0001) Implements the canonical-track model: defineTable eagerly registers through the same table() factory GraphQL drives and returns the live class as the single typed handle, with per-verb projections ($record/$insert/$upsert/ $patch/$query) inferred from the one definition. Getter-style field flags, lazy relation thunks (forward-reference/cycle safe via the definition late-binding contract), GraphQL-parity nullability mapping, and a type- contract check file enforcing the spike's assertions on the real module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Relation definition now carries lazy type/attributes (matching GraphQL's connectPropertyType typeDef) so OpenAPI's includeDefinitionInSchema always defines the $ref component for code-first relations [Codex] - Require the to-one FK to be declared in the shape (like GraphQL) instead of auto-adding an untyped, unprojected column — the declared FK is typed, in properties, and writable in $insert; clear error otherwise [Gemini] - Memoize the lazy relation properties fragment; DRY makeDateField [Gemini] - Document types.enum narrowing as advisory (shared validate() has no enum case today — same as GraphQL) [Codex] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r type cycles A truly mutual relation pair (Book.author <-> Author.books, each referencing the other) breaks TS's const-initializer inference: typeof Book and typeof Author each depend on the other and collapse to any (TS7022). relationOf<R>/ hasManyOf<R> break the cycle by typing the thunk target () => any (no inference pulled from the argument) and taking the related record shape explicitly via <R> instead. Runtime is identical to relation/hasMany — only the type-level path differs, proven by a dedicated registration test. Verified empirically (both that the escape hatch compiles clean under strict, and that the naive plain-relation-on-both-sides form genuinely fails with TS7022) before writing the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Nice |
Status: Draft — RFC for discussion; the spikes have now been promoted into a working implementation of the canonical code-first model (phase 2b), landing on this branch.
What this is
RFC + implementation for strengthening type discoverability and strength across Harper resources. The full RFC is in
docs/rfcs/0001-typed-discoverable-resources.md.The anchoring principle (forced by
--conditions=typestrip): runtime metadata must be values; TypeScript types are derived from them — never the reverse. Taken to its conclusion, the schema itself can be authored as a TS value with the record type inferred.Three pillars
attributeToFragment).withSchema/defineResourcecarryingparams/query/body/response, with handler types derived and the same declaration feeding validation + OpenAPI + MCP.Implementation: canonical
defineTable+types(NEW —resources/defineTable.ts)The canonical-track model, for real:
defineTablecompiles to the same typeDef GraphQL builds and registers via the existingtable()factory — shared DDL/migration semantics (re-definition re-asserts/evolves the schema), not a parallel path.Trackis the table you query/write through and the instance type;$record(readonly reads),$insert(PK optional),$upsert(PK required),$patch,$query(indexed fields only) are inferred members, never parallel names.types.relation(() => Album, { from: 'albumId' })/types.hasMany(...)— forward references and cycles are safe (thedefinitionlate-binding contract the query-time resolvers already rely on). Like GraphQL, the to-one FK must be declared in the shape, which makes it typed, projected, and writable.propertiesper field (including the nullability mapping: code-first plain field ≡!,.nullable≡ GraphQL plain; PK/server-managed unmarked).resources.jsauto-exposes it through the existing resource walk — same semantics asexport class X extends tables.X {}.docs/rfcs/spikes/0001/defineTable-real.check.tsre-runs the canonical-track assertions (incl.@ts-expect-errornegatives) against the built package types.relationOf/hasManyOf— mutual-pair escape hatch: a truly mutual pair (Book.author<->Author.books, each referencing the other) breaks TS's eager const-initializer inference —typeof Book/typeof Authoreach depend on the other and collapse toany(TS7022), verified empirically both ways (fails with plainrelation/hasManyon both sides, passes with the escape hatch).relationOf<R>/hasManyOf<R>type the thunk target() => anyand take the related record shape explicitly via<R>instead of inferring it, breaking the cycle; runtime is identical torelation/hasMany(only the type path differs — proven by a dedicated registration test).Verify:
npm run build && npx mocha unitTests/resources/defineTable-registration.test.js unitTests/resources/jsResource.test.js→ 16 passing;npx tsc --noEmit --project docs/rfcs/spikes/0001/tsconfig.json→ green.Cross-model review (Codex + Gemini + Harper-domain, adjudicated)
No blockers. Addressed in-branch: OpenAPI
$refcomponents for code-first relations now always resolvable (lazytype/attributeson the relationdefinition); to-one FK must be declared (was an untyped auto-added column). Surfaced for discussion:null: nullable fields type as?: T(absence), but the runtime accepts/returns explicitnullfor unmarked-nullable fields, sopatch(id, { duration: null })is runtime-valid yet type-rejected. Drizzle-style would beT | null. The canonical spike pins optional-only; keeping it that way unless we decide otherwise.types.enum()narrows the TS type, but the sharedTable.validate()has no enum case (true for GraphQL today too) andattributeToFragmentdrops the constraint. A runtime enum check would benefit both front-ends; deferred as its own issue.Spikes (proof-of-concept, superseded by the implementation above)
docs/rfcs/0001-typed-discoverable-resources.mdtbuilder +defineTableinference —docs/rfcs/spikes/0001/t-builder.spike.ts(superseded by canonical-track)defineTablevalue →table()registration — promoted tounitTests/resources/defineTable-registration.test.js, now exercising the real public APITrack, every verb shape inferred —docs/rfcs/spikes/0001/canonical-track.spike.ts(the model the implementation follows)docs/rfcs/spikes/0001/enforce-schema.spike.ts(next up: itsrecord/bodyslots take the$projections directly)This PR is kept up to date as work proceeds. Not for merge as-is — it is a living design surface.
Context
Grounded in a review of
harpercore (resources/,components/mcp/,server/) and theHarperFast/schema-codegenplugin. Verified findings (e.g. parameterized custom resources produce zero MCP tools; OpenAPI emits none of the query grammar; four divergent type translators over oneAttribute) are tabulated in §11 of the RFC.🤖 Generated with Claude Code