-
Notifications
You must be signed in to change notification settings - Fork 8
RFC 0001 — Typed, discoverable resources (+ code-first schema spikes) #1503
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
dawsontoth
wants to merge
11
commits into
main
Choose a base branch
from
claude/sad-lehmann-1b3b70
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
84b1763
docs(rfc): add RFC 0001 — typed, discoverable resources
dawsontoth 865dd76
docs(rfc): spike (b) — t builder + defineTable type inference
dawsontoth 258e4fa
docs(rfc): spike (c) — defineTable value -> table() registration
dawsontoth 2b94537
docs(rfc): prettier formatting + wildcard path params
dawsontoth 79b506b
fix(mcp): enumerate parameterised routes when building application tools
dawsontoth 6c703f2
test(resources): promote code-first registration spike + add @relatio…
dawsontoth 7f66b72
docs(rfc-0001): canonical single-Track spike — all verb shapes inferr…
kriszyp a118bac
docs(rfc-0001): enforceSchema spike — subset-typed request contract, …
kriszyp 63e7ede
feat(resources): canonical code-first schema — defineTable + types (R…
5646065
fix(defineTable): address cross-model review findings
6798056
feat(defineTable): relationOf/hasManyOf — escape hatch for mutual-pai…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| /** | ||
| * RFC 0001 — canonical-track contract, enforced against the REAL `defineTable` implementation. | ||
| * | ||
| * canonical-track.spike.ts proved the type mechanics on a self-contained stub; this file asserts | ||
| * the shipped module (`resources/defineTable.ts`, via the built package types) satisfies the same | ||
| * contract: the `$record`/`$insert`/`$upsert`/`$patch`/`$query` projections, getter-style field | ||
| * flags, and the negative cases. It imports the build output so this strict spike tsconfig checks | ||
| * only the declaration surface (skipLibCheck), not the whole repo graph. | ||
| * | ||
| * Verify (after `npm run build`): | ||
| * npx tsc --noEmit --project docs/rfcs/spikes/0001/tsconfig.json | ||
| * A green run IS the proof; `@ts-expect-error` lines prove the negative cases. | ||
| */ | ||
|
|
||
| /* eslint-disable @typescript-eslint/no-unused-vars */ | ||
|
|
||
| import { defineTable, types } from '../../../../dist/index.js'; | ||
|
|
||
| const { id, int, string, date } = types; | ||
|
|
||
| // NOTE: defineTable registers eagerly, so this module is type-check-only (never executed) — | ||
| // exactly like the spike, whose runtime lived in the registration test. | ||
| const Track = defineTable('Track', { | ||
| id: id.primaryKey, | ||
| name: string.indexed, | ||
| duration: int.nullable, | ||
| status: types.enum(['draft', 'published']).indexed, | ||
| createdTime: date.createdTime, | ||
| }); | ||
|
|
||
| // `Track` stays the central type; variants are derived off it as members. | ||
| type Track = InstanceType<typeof Track>; | ||
| type TrackRecord = (typeof Track)['$record']; | ||
| type InsertTrack = (typeof Track)['$insert']; | ||
| type UpsertTrack = (typeof Track)['$upsert']; | ||
| type PatchTrack = (typeof Track)['$patch']; | ||
| type TrackQuery = (typeof Track)['$query']; | ||
|
|
||
| // ── Type-level assertions (green tsc == proof) ────────────────────────────── | ||
| type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; | ||
| type Expect<T extends true> = T; | ||
|
|
||
| // The read-only variant: every field readonly, server-managed present. | ||
| type _record = Expect< | ||
| Equal< | ||
| TrackRecord, | ||
| { | ||
| readonly id: string; | ||
| readonly name: string; | ||
| readonly status: 'draft' | 'published'; | ||
| readonly createdTime: Date; | ||
| readonly duration?: number; | ||
| } | ||
| > | ||
| >; | ||
| type _insert = Expect< | ||
| Equal< | ||
| InsertTrack, | ||
| { name: string; status: 'draft' | 'published'; id?: string; duration?: number } // PK optional, server field gone | ||
| > | ||
| >; | ||
| type _upsert = Expect< | ||
| Equal< | ||
| UpsertTrack, | ||
| { id: string; name: string; status: 'draft' | 'published'; duration?: number } // PK required | ||
| > | ||
| >; | ||
| type _patch = Expect< | ||
| Equal<PatchTrack, { name?: string; status?: 'draft' | 'published'; duration?: number }> // all writable optional, no PK | ||
| >; | ||
| type _query = Expect< | ||
| Equal<TrackQuery, { name?: string; status?: 'draft' | 'published' }> // indexed fields only (id/duration excluded) | ||
| >; | ||
|
|
||
| // Relations: to-one resolves to the related read record (readonly), to-many to an array; both | ||
| // are dropped from every write projection. A to-one relation's FK must be DECLARED in the shape | ||
| // (like GraphQL), which makes it typed, queryable, and writable — the relation itself is not. | ||
| const Album = defineTable('Album', { | ||
| id: id.primaryKey, | ||
| title: string, | ||
| tracks: types.hasMany(() => Track, { to: 'albumId' }), | ||
| }); | ||
| type AlbumRecord = (typeof Album)['$record']; | ||
| type _album_record = Expect< | ||
| Equal<AlbumRecord, { readonly id: string; readonly title: string; readonly tracks: TrackRecord[] }> | ||
| >; | ||
| type _album_insert = Expect<Equal<(typeof Album)['$insert'], { title: string; id?: string }>>; | ||
|
|
||
| const Song = defineTable('Song', { | ||
| id: id.primaryKey, | ||
| title: string, | ||
| albumId: id.indexed, // declared FK — typed and writable | ||
| album: types.relation(() => Album, { from: 'albumId' }), | ||
| }); | ||
| type _song_record = Expect< | ||
| Equal< | ||
| (typeof Song)['$record'], | ||
| { readonly id: string; readonly title: string; readonly albumId: string; readonly album: AlbumRecord } | ||
| > | ||
| >; | ||
| // the declared FK is insertable/patchable; the relation projection is not | ||
| type _song_insert = Expect<Equal<(typeof Song)['$insert'], { title: string; albumId: string; id?: string }>>; | ||
| type _song_query = Expect<Equal<(typeof Song)['$query'], { albumId?: string }>>; | ||
|
|
||
| // ── Mutual pair (Book <-> Author): relationOf/hasManyOf break the const-initializer cycle ── | ||
| // | ||
| // Using plain `relation`/`hasMany` on BOTH sides of a mutual pair does NOT compile — TS's | ||
| // const-initializer inference is eager, so `typeof Book` and `typeof Author` would each depend on | ||
| // the other and both collapse to `any` (TS7022: "'Book' implicitly has type 'any' because it does | ||
| // not have a type annotation and is referenced directly or indirectly in its own initializer"). | ||
| // Verified empirically: swap `hasManyOf<BookRecord>` below for plain `hasMany` and tsc fails with | ||
| // exactly that error on both `Book` and `Author`. | ||
| // | ||
| // The fix: declare the related record shape by hand on whichever side closes the cycle, and use | ||
| // `hasManyOf<R>`/`relationOf<R>` there — its `target` param is typed `() => any`, so no inference | ||
| // is pulled from the argument and the cycle never forms. The OTHER side keeps plain | ||
| // `relation`/`hasMany` with full inference (Book's `author` field below is fully inferred). | ||
| interface BookRecord { | ||
| readonly id: string; | ||
| readonly title: string; | ||
| readonly authorId: string; | ||
| } | ||
|
|
||
| const Book = defineTable('Book', { | ||
| id: id.primaryKey, | ||
| title: string, | ||
| authorId: id.indexed, // declared FK, required for the to-one relation | ||
| author: types.relation(() => Author, { from: 'authorId' }), // plain relation — fully inferred | ||
| }); | ||
| const Author = defineTable('Author', { | ||
| id: id.primaryKey, | ||
| name: string, | ||
| books: types.hasManyOf<BookRecord>(() => Book, { to: 'authorId' }), // escape hatch closes the cycle | ||
| }); | ||
|
|
||
| declare const book: (typeof Book)['$record']; | ||
| declare const author: (typeof Author)['$record']; | ||
| const authorName: string = book.author.name; // Book -> Author: fully inferred, typo-checked | ||
| const firstBookTitle: string = author.books[0].title; // Author -> Book: typed via the explicit BookRecord | ||
|
|
||
| // The escape hatch doesn't weaken the write-side guarantees: relations stay read-only projections. | ||
| // @ts-expect-error relations are not writable, even through the escape hatch | ||
| const bad_author_insert: (typeof Author)['$insert'] = { name: 'x', books: [] }; | ||
|
|
||
| // Verbs are typed by the projections (await-friendly MaybePromise results). | ||
| async function verbs() { | ||
| const ro = await Track.get('DtMF'); | ||
| if (ro) { | ||
| ro.name.toUpperCase(); // read ok | ||
| ro.createdTime.getFullYear(); // server field present on reads | ||
| } | ||
| await Track.post({ name: 'Intro', status: 'draft' }); // id generated, createdTime server-set | ||
| await Track.put({ id: 'DtMF', name: 'Intro', status: 'published' }); // PK required for replace | ||
| await Track.patch('DtMF', { status: 'published' }); // partial | ||
| const live = await Track.update('DtMF', { duration: 75 }); // live instance | ||
| live.name = 'Renamed'; // writable field — mutable | ||
| } | ||
|
|
||
| // Explicit-typed forms, to show the projections stand alone too: | ||
| const toInsert: InsertTrack = { name: 'New Album', status: 'draft', duration: 50 }; | ||
| const toReplace: UpsertTrack = { id: 'DtMF', name: 'New Album', status: 'draft' }; | ||
| const toPatch: PatchTrack = { duration: 75 }; | ||
| const filter: TrackQuery = { name: 'Intro' }; | ||
|
|
||
| // ── Negative cases — each MUST error, or tsc fails ────────────────────────── | ||
| declare const ro: TrackRecord; | ||
| declare const live: Track; | ||
| // @ts-expect-error the read-only variant cannot be mutated | ||
| ro.name = 'nope'; | ||
| // @ts-expect-error server-managed field is readonly even on the live instance | ||
| live.createdTime = new Date(); | ||
| // @ts-expect-error server-managed field is not insertable | ||
| const bad_insert: InsertTrack = { name: 'x', status: 'draft', createdTime: new Date() }; | ||
| // @ts-expect-error upsert requires the primary key | ||
| const bad_upsert: UpsertTrack = { name: 'x', status: 'draft' }; | ||
| // @ts-expect-error 'archived' is not in the enum | ||
| const bad_enum: PatchTrack = { status: 'archived' }; | ||
| // @ts-expect-error duration is not indexed, so it is not a query key | ||
| const bad_query: TrackQuery = { duration: 50 }; | ||
| // @ts-expect-error relations are not writable | ||
| const bad_album: (typeof Album)['$insert'] = { title: 'x', tracks: [] }; | ||
|
|
||
| export { Track, Album, Book, Author, toInsert, toReplace, toPatch, filter, verbs, authorName, firstBookTitle }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.