From 84b1763aa3349c6e8cd8ebed3564c9a9cb8b96a8 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 26 Jun 2026 11:49:43 -0400 Subject: [PATCH 01/11] =?UTF-8?q?docs(rfc):=20add=20RFC=200001=20=E2=80=94?= =?UTF-8?q?=20typed,=20discoverable=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rfcs/0001-typed-discoverable-resources.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/rfcs/0001-typed-discoverable-resources.md diff --git a/docs/rfcs/0001-typed-discoverable-resources.md b/docs/rfcs/0001-typed-discoverable-resources.md new file mode 100644 index 0000000000..7118fb2af7 --- /dev/null +++ b/docs/rfcs/0001-typed-discoverable-resources.md @@ -0,0 +1,338 @@ +# RFC 0001 — Typed, discoverable resources + +| | | +|---|---| +| **Status** | Draft · for discussion | +| **Author** | dawson@harperdb.io | +| **Date** | 2026-06-26 | +| **Scope** | harper core · `schema-codegen` · mcp · openApi | +| **Companion** | A rendered version of this RFC is published as a Claude artifact (shareable). | + +> **One declaration — authored as runtime values, with TypeScript derived from it — that strengthens types in the editor and feeds validation, OpenAPI, and MCP from a single source of truth.** + +## Summary + +Harper describes table-backed resources richly — the GraphQL schema flows into runtime `attributes`, which feed write validation, OpenAPI, and MCP. Everything else is comparatively opaque: a custom `extends Resource` class, its URL path, its per-method params, and the built-in filter/sort/include query grammar are invisible to the type system and to the generated API metadata. This RFC proposes closing that gap with three coordinated pillars, anchored on one principle that Harper's runtime already forces. + +**The principle.** Harper strips TypeScript at runtime (`--conditions=typestrip`), which erases types and rules out metadata-emitting decorators. So **runtime metadata must be values, and TypeScript types must be derived from those values — never the reverse.** Taken to its conclusion, this means the *schema itself* can be authored as a TypeScript value, with the record type **inferred** — no generation step, nothing to drift. `schema-codegen` already lives the value-first model for tables; we extend it everywhere and let the schema be code. + +| Pillar | Idea | +|---|---| +| **1 — One schema model** | Author tables in GraphQL *or* TypeScript; both compile to one canonical model, projected to every surface from a single core. | +| **2 — A request contract** | A per-method `params/query/body/response` contract that types handlers and feeds every surface. | +| **3 — Typed query behavior** | Base types for Harper's built-in filter, sort, select, and relationship-include grammar, keyed to the record. | + +## 1. Background & motivation + +When a developer builds something with Harper, the experience splits sharply along one line: *is it a table, or is it custom?* + +Tables are well served — but only via one front door. A GraphQL `@table` type (today the only way to define a table) is parsed in `resources/graphql.ts` into a canonical `Attribute[]` carried on the class, and that array feeds three consumers — write validation in `Table.ts`, the OpenAPI document in `resources/openApi.ts`, and the MCP tool schemas in `components/mcp/tools/schemas/derive.ts`. The community `@harperfast/schema-codegen` plugin adds a fourth: it introspects the live `databases` object and emits a `.d.ts` that augments `declare module 'harperdb'`, so `tables.Tracks` and `extends tables.Tracks` become strongly typed. + +Custom resources are not. A class that `extends Resource` has no `attributes`, so it is invisible to codegen, types as `any` in its handlers, gets an empty/generic MCP schema, and — if it declares a parameterized path — can disappear from MCP entirely. The exact things this RFC's reader asked about (customizing the path, adding method handlers, validating path/query/body params) are precisely the surface that has no typed home. + +A second, quieter problem sits underneath: the same `Attribute` is translated to a type-shaped representation in **four** independent places that disagree with each other. Consolidating them is not greenfield — a shared projector (`attributeToFragment` in `resources/jsonSchemaTypes.ts`) already exists and is already on the canonical parse path; OpenAPI, MCP, and codegen simply bypass it. + +## 2. Goals & non-goals + +**Goals** + +- **Strong, derived types** for custom resources: path params, query params, request bodies, and responses, inferred in the editor from one runtime declaration. +- **Typed base-request behavior** — filtering, sorting, field selection, and relationship inclusion — keyed to each record and its relationship graph. +- **One source of truth** that drives editor types, runtime validation, OpenAPI, and MCP, so they can never silently diverge. +- **Schema as code (optional).** Let a table be defined in TypeScript with the same builder, inferring the record type and driving validation/OpenAPI/MCP — *alongside*, not replacing, GraphQL SDL. +- **Readable JSDoc** for the JavaScript path, so non-TypeScript users get the same IDE guidance. +- **Build-grade codegen** that runs in CI, not only inside a live dev server. + +**Non-goals** + +- Replacing GraphQL as the way tables are defined. The data shape stays in the schema; this RFC adds the *HTTP-binding* layer around it. +- Forcing a validation library. Bring-your-own (Standard Schema) and a zero-dependency built-in both work. +- Breaking the existing class model. `extends Resource` and `extends tables.Foo` remain the idioms. + +## 3. Current state: who feeds what + +| Surface | Reads from | Custom resources? | Notable gaps (verified) | +|---|---|---|---| +| Write validation | `Table.validate()` | No per-param path | Throws a joined *string*, not per-field errors; ignores `enum/format/pattern` the fragment can express | +| OpenAPI | `openApi.ts` (own switch) | Path only, untyped body | Emits **none** of the filter/sort/select/limit query grammar; Blob → `{type: undefined}` | +| MCP | `derive.ts` (own switch) | `attributes = []` | Mistypes nested objects/arrays (capitalized literals the parser never emits); parameterized custom routes yield **zero** tools; advertises a schema it does not enforce | +| Editor types | `schema-codegen mapType.js` | Not covered | Date→`string`, Blob→`any`; no enums; computed/timestamp fields neither `readonly` nor omitted from `New` | +| *(shared core)* | `attributeToFragment` | — | Exists and is on the parse path, but the four surfaces above bypass it | + +> **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 + +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. + +```mermaid +flowchart LR + G["GraphQL SDL"] --> M["canonical
schema model"] + T["TypeScript builder"] --> M + M --> A["inferred / emitted types"] + M --> B["write validation"] + M --> C["OpenAPI"] + M --> D["MCP"] +``` + +### 4.1 — Author the schema in TypeScript (code-first) + +Use the *same* `t` builder as the request contract (Pillar 2), so one vocabulary defines tables and request shapes. The schema is a runtime value; the record type is inferred from it — no generation, no drift, and it works under type-stripping because the value survives and only the type is erased. + +```ts +import { defineTable, t } from 'harperdb'; + +export const Tracks = defineTable('Tracks', { + 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 +}); + +type Track = t.Select; // inferred read record +type NewTrack = t.Insert; // PK + computed + timestamps omitted, inferred +``` + +- **Every directive maps to a builder method** — `@primaryKey/@indexed/@computed/@createdTime/@relationship/@sealed/@export` all have a `t` equivalent, so the known-directive vocabulary in `graphql.ts` and the builder stay in lockstep. +- **Relationships are real references** (`() => Albums`), not string names. Cardinality and the target type are known to the compiler — which hands Pillar 3 an exact relationship graph and removes codegen's `singularize()` name-resolution guesswork entirely. +- **Registration** — the component loader discovers exported `defineTable` values, builds the same internal `typeDef`, and drives the same `table()`/`makeTable()` + schema-evolution machinery (add/remove attributes, indices, audit, replication) that GraphQL drives today. Cross-table references resolve via the lazy thunk, so load order is not a constraint. +- **The typed handle is the import.** `import { Tracks }` is already fully typed — no `.d.ts` generation needed for code-first tables. The bare `tables.Tracks` global still works and can be augmented for those who use it. + +> **Design lineage.** This is the *inferred, code-first* model (Drizzle-style: `typeof t.$inferSelect`) rather than the *DSL + codegen* model (Prisma-style). GraphQL SDL stays first-class for teams who prefer a declarative, at-a-glance schema file — the point is that both are front-ends to the same model, not competing sources of truth. + +### 4.2 — Serve every surface from one projector + +Whichever front-end authored it, the canonical model reaches all surfaces through one projector. `attributeToFragment` in `resources/jsonSchemaTypes.ts` already projects an `Attribute` to a JSON-Schema fragment and is on the parse path — but `openApi.ts`, `derive.ts`, and codegen all bypass it. Harden it to cover the full shape, then point them at it: + +- **Nested objects & lists** — recurse on `attr.properties` / `attr.elements` (the parser's lowercase `'array'` + element model), fixing MCP's silent mistyping in one move. +- **Relationships** — key off `attr.relationship`, emitting a ref (to-one) or array-of-ref (to-many) with cardinality preserved. +- **Blob** in `DATA_TYPES` — fixing OpenAPI's typeless Blob. +- **Nullability as a parameter** — `union` form for MCP and TS, `nullable: true`/required-array for OpenAPI 3.0. +- **Direction** — an `input` vs `output` flag so write schemas advertise the coercible accept-set (Date accepts `string|number|Date`) while read schemas advertise the stored type. + +Then make `schema-codegen` a pure *fragment → TypeScript* printer rather than a fourth switch. Lock everything with one golden conformance fixture that asserts the **GraphQL and TypeScript front-ends round-trip to identical canonical models**, and that all surfaces agree on every edge (Long, Date, Bytes, Blob, `[Type]`, nested objects, `@relationship`, `@computed`, unspecified nullability). + +> **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 + +Give custom resources the same descriptive richness tables get, by declaring a contract as runtime values and deriving the handler types from it. The contract lands in the static slots OpenAPI and MCP already read (`outputSchemas`, `mcp`), plus one new `inputSchemas`/`requestContract` slot. + +**Authoring — class form (keeps `extends`, composes with table extension):** + +```ts +import { Resource, t } from 'harperdb'; // or any Standard Schema lib + +export class Widget extends Resource.withSchema({ + path: '/widget/:id', + get: { query: t.object({ expand: t.array(t.enum(['parts', 'owner'])).optional() }), + response: WidgetShape }, + post: { body: WidgetInput, response: WidgetShape }, +}) { + async get(req) { // req.params.id: string · req.query.expand?: ('parts'|'owner')[] + return loadWidget(req.params.id, req.query.expand); + } + async post(req) { // req.body: + return createWidget(req.body); + } +} +``` + +### How the inference works + +`withSchema(contract)` is a generic static returning a subclass typed by the contract. Path params come from a template-literal parse of the path string; body/query/response come from the schema's inferred output type. This matches the runtime exactly: matched params are assigned as strings onto the target. + +```ts +// '/widget/:id/rev/:n' -> { id: string; n: string } +type PathParams = + S extends `${string}:${infer P}/${infer Rest}` ? { [K in P]: string } & PathParams<`/${Rest}`> + : S extends `${string}:${infer P}` ? { [K in P]: string } + : {}; + +type RequestOf = { + params: PathParams; + query: Infer; + body: Infer; + user?: User; +}; +``` + +> **One request object.** Today method args are inconsistent — `post(target, record)` but `put(record, target)`. The contract passes a single typed `req` with named `params`/`query`/`body`/`user`, which is far more discoverable on hover than a `RequestTarget` that is simultaneously the id, the `URLSearchParams`, and the parsed conditions. + +### Validator interop + +Accept any **Standard Schema** validator (Zod, Valibot, ArkType) so authors get `.infer` types and runtime `validate` for free, *or* a thin built-in `t` that emits a `JsonSchemaFragment` (the zero-dependency default). The one caveat to design around: Standard Schema does not guarantee a JSON-Schema export, and OpenAPI/MCP require one — so third-party validators need a per-library adapter (e.g. Zod v4 `z.toJSONSchema`) with a dev-warning fallback. The built-in `t` is the path that always reduces to JSON Schema. + +A function-style twin, `defineResource(contract, impl)`, produces identical runtime metadata for authors who prefer object literals over classes. + +## 6. Pillar 3 — Typed base-request behavior + +This is the layer for what Harper does *automatically* on a GET: filter, sort, paginate, select fields, and include related records via query params. The building blocks are typed but never threaded to the record, and the grammar is undocumented downstream. + +**What Harper already accepts (and parses into `RequestTarget`):** + +| Capability | Query grammar | Parsed onto target | +|---|---|---| +| Filter | `?price=gt=100`, `&`/`\|`/`()` | `conditions: Conditions` | +| Relationship filter | `?author.name=Harper` | nested `conditions` | +| Sort | `?sort(-price,+name)` | `sort: Sort` | +| Select / include | `?select(name,books(title))` | `select: Select` | +| Paginate | `?limit(20,10)` | `limit`, `offset` | + +The typed primitives `Condition`, `Sort`, `Select`, and the `Comparator` union already exist in `resources/ResourceInterface.ts` — keyed on `keyof R`. The problem is the seams: `RequestTarget` is not generic (`conditions` is `any`), `select` isn't typed against relationships, OpenAPI emits none of this, and MCP's search schema omits sort and relationship include. + +### 6.1 — Make the request generic over the record + +```ts +class RequestTarget extends URLSearchParams { + id?: IdOf; + conditions?: Conditions; // attribute keyed to keyof R + sort?: Sort; // { attribute: keyof R; descending?: boolean; next? } + select?: Select>; + limit?: number; offset?: number; +} +``` + +Now a handler that reads `req.query.sort.attribute` or builds `conditions` gets attribute-name autocomplete and rejects typos at compile time. + +### 6.2 — A canonical `HarperQuery` type — the base type for GET behavior + +Publish the whole grammar as one composable type, parameterized by the record and its relationship graph. This is the single public surface for "what a Harper collection GET understands." + +```ts +interface HarperQuery { + /** filter: each attribute accepts its value or an operator object */ + where?: Where; + /** sort by one or more attributes, with direction */ + sort?: Sort; + /** project columns and INCLUDE relationships (typed + nestable) */ + select?: SelectClause; + limit?: number; + offset?: number; +} + +// operators apply by type: strings get contains/starts_with, numbers/dates get gt/between, ... +type Where = + { [K in keyof R]?: R[K] | OpFor } & + { [K in keyof Rel]?: Where, {}> }; // author.name=... + +// include a relationship by naming it; nest a sub-select to shape it +type SelectClause = ( + keyof R | keyof Rel | + { [K in keyof Rel]?: SelectClause, {}> } +)[]; +``` + +### 6.3 — Where the relationship graph comes from + +For `Rel` to be real, the relationship map per table — to-one resolving to the related record, to-many to an array — must be available as a type. There are two paths, and code-first is the cleaner one: + +- **Code-first (exact):** a `t.relation(() => Albums)` is a real type reference, so `Rel` is known to the compiler with zero emission and zero name-resolution risk. +- **GraphQL (emitted):** codegen emits a relationship map from `@relationship` with cardinality preserved (the same projector work from Pillar 1), resolving target names against the set of emitted interfaces. + +```ts +// generated (GraphQL path) or inferred (code-first path), alongside the record interface +export interface BookRelations { author: Author; } // @relationship(from:"authorId") — to-one +export interface AuthorRelations { books: Book[]; } // @relationship(to:"authorId") — to-many +``` + +### 6.4 — Narrow the response to what was selected (stretch) + +A mapped type can shape the *return* type to the select clause, so including `books(title)` yields `{ ...; books: { title: string }[] }` instead of the full record. The default (no select) returns full `R`. + +```ts +type Selected = S extends undefined ? R + : /* pick listed columns from R + shaped relationships from Rel */ never; + +get(req: { query: HarperQuery }): + Promise[]>; +``` + +### 6.5 — Feed the same grammar downstream + +- **OpenAPI** — emit the collection query parameters that are *entirely missing today*: a documented filter syntax, `sort`, `select`, `limit`/`offset`, plus relationship dot-paths. A shared `collectionQueryParameters(attributes, relations)` generator off the unified projector. +- **MCP** — extend `deriveSearchSchema` to add `sort` and relationship `include`, derive the comparator enum from the real `COMPARATORS` set (not the hardcoded subset), and enum the includable relationship names. + +> **Payoff.** The filter/sort/include capability becomes one typed object that the editor checks, the REST docs describe, and the MCP tool exposes — instead of a powerful grammar that today is only discoverable by reading the parser. + +## 7. Codegen as a first-class tool + +`schema-codegen` is the right foundation; it needs fidelity and a build story. + +**Fidelity fixes (flags already on the `Attribute`)** + +- **Highest value:** mark `@computed` / `@createdTime` / `@updatedTime` / `@expiresAt` fields `readonly`, and omit them from `New` (which today omits only the primary key) so the insert type stops inviting writes to server-managed fields. +- Support **enums / literal unions** (add an `ENUM_TYPE_DEFINITION` case in `graphql.ts`; the fragment already has an `enum` slot). +- `any` → `unknown`; `Bytes` → `string`; optional `Date` emission. +- Resolve relationship target names against the *set of emitted interfaces*, not a blind `singularize()` — a mismatch currently emits a dangling type reference that fails to compile. + +**Build & CI** + +Generation runs only under `DEV_MODE` behind an unawaited 5-second timer, with no CI path. Add a deterministic one-shot: + +```bash +harper codegen # load schemas headlessly, generate once, exit 0 +harper codegen --check # exit non-zero if committed output is stale (PR gate) +``` + +Replace the blind timer with the GraphQL loader's "schemas ready" signal, commit the generated files (so clean checkouts type-check), and gate drift with `--check`. Collapse the six emitted aliases per table (`Track`, `NewTrack`, `Tracks`, `TrackRecord`, `TrackRecords`, `NewTrackRecord` — several identical) down to three. + +## 8. Validation & error model + +Today MCP advertises an input schema but does not enforce it — even for tables it relies on `Table.validate` downstream, which throws a single joined string and ignores the `enum/format/pattern` constraints the fragment can already carry. + +The contract makes one edge validator possible: before dispatch, validate `params/query/body` against the contract fragment and return **structured per-field 400s** whose shape matches the emitted OpenAPI. Refactor `Table.validate` to build the same structured shape from its existing per-property error list, and have MCP pass the structured errors through rather than flattening to a message. + +```ts +// instead of: throw new ClientError(errors.join('. ')) +throw new ValidationError({ status: 400, errors: [ + { path: 'body.price', code: 'minimum', message: 'must be ≥ 0' }, + { path: 'query.sort', code: 'unknown_attribute', message: 'no attribute "naem"' }, +]}); +``` + +## 9. Discoverability & ergonomics + +- **One entry point.** Capabilities are scattered across `static path`, `@export(name:)`, prototype methods, and `static properties/outputSchemas/mcp`. `withSchema`/`defineResource` collapses path + methods + params + responses + MCP hints into a single hoverable object. +- **JSDoc parity.** The codegen JSDoc path already gives JS users typedef hover; extend it with the same relationship and readonly fidelity so JS and TS guidance match. +- **Document the query grammar in-type.** The `HarperQuery` type doubles as living documentation of the filter/sort/include syntax, surfaced on hover instead of buried in a rules doc. +- **Fix the small sharp edges:** generic `RequestTarget`, consistent handler arg shape, and a real JSDoc block on `RequestTarget` explaining its triple role. + +## 10. Rollout + +Each phase ships value on its own; later phases depend on the projector and the relationship graph from earlier ones. + +| Phase | Work | +|---|---| +| **0 · days** | Quick wins: JSDoc/`.d.ts` hygiene on `RequestTarget` and method signatures; collapse codegen aliases 6→3; `readonly` + `New` omission for computed/timestamp fields; `singularName` override + warning. | +| **1** | Unify the projector: harden `attributeToFragment`; point OpenAPI, MCP, and codegen at it; add the golden conformance fixture. Fixes the MCP nested-typing and OpenAPI-Blob bugs for free. | +| **2** | The request contract: `withSchema`/`defineResource` + built-in `t`; wire OpenAPI + MCP (incl. registering parameterized resources); edge validation with structured 400s. Establishes the `t` vocabulary the code-first schema reuses. | +| **2b** | Code-first schema: `defineTable` as a second front-end to the canonical model — builder + inference, loader registration into `table()`/`makeTable()`, schema-evolution off the canonical `typeDef`, and front-end round-trip conformance with GraphQL. The largest piece — gate on the migration-parity guardrail. | +| **3** | Typed query behavior: generic `RequestTarget`; publish `HarperQuery`; emit the relationship graph from codegen; OpenAPI collection params + MCP sort/include. | +| **4** | Reach: Standard Schema adapters; `Selected` response narrowing; per-attribute operator typing. | + +## 11. Verified findings + +Claims in this RFC were checked against source by independent verification passes. The high-confidence findings and their evidence: + +| Finding | Evidence | +|---|---| +| Four translators of one `Attribute`; shared `attributeToFragment` already exists but is bypassed | `openApi.ts`, `mcp/.../derive.ts`, `schema-codegen/mapType.js`, `jsonSchemaTypes.ts` | +| MCP mistypes nested objects/arrays via capitalized `'Object'`/`'Array'` literals the parser never emits | `derive.ts` vs `graphql.ts` (lowercase `'array'` + `.elements`) | +| Parameterized custom resources produce zero MCP tools | `Resources.set()` returns before base-map add; `application.ts` iterates the map only | +| OpenAPI emits no filter/sort/select/limit query params | `openApi.ts` attaches path params only | +| Computed/timestamp fields not `readonly`, not omitted from `New` | `generateInterface.js` (`Omit` of PK only); flags present on `Attribute` | +| MCP advertises an input schema it does not enforce; `Table.validate` throws a joined string | `application.ts` handlers; `Table.ts` validation section | +| Codegen runs only under `DEV_MODE` behind a 5s timer; no CI one-shot | `schema-codegen/extensionModule.js` | +| Existing programmatic table factory `table()` already used by `dataLoader.ts` (de-risks code-first registration) | `resources/databases.ts` `table()`; `dataLayer/.../dataLoader.ts` | + +--- + +## Appendix — Spikes tracked in this PR + +This RFC is paired with proof-of-concept spikes, added to this PR as they land: + +- **Spike B — `t` builder + `defineTable` inference.** A self-contained, type-checked module proving `t.Select`/`t.Insert` inference (readonly + computed/timestamp/PK omission) and the `t.relation` typed graph. +- **Spike C — `defineTable` → `table()` registration.** Compiles a `defineTable` value into the options the existing `table()` factory consumes and registers a working table from a value (no GraphQL), validated by a unit test. From 865dd763bfa0f7b19f8e0854d67bc59f3c020e37 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 26 Jun 2026 11:59:17 -0400 Subject: [PATCH 02/11] =?UTF-8?q?docs(rfc):=20spike=20(b)=20=E2=80=94=20t?= =?UTF-8?q?=20builder=20+=20defineTable=20type=20inference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained, type-checked proof of code-first schema inference under the repo's NodeNext/erasableSyntaxOnly settings (strict). Demonstrates: - Select: enum -> literal union, nullable -> optional, relation -> related record (to-one) / array (to-many), server-managed fields -> readonly. - Insert: 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 --- docs/rfcs/spikes/0001/t-builder.spike.ts | 279 +++++++++++++++++++++++ docs/rfcs/spikes/0001/tsconfig.json | 8 + 2 files changed, 287 insertions(+) create mode 100644 docs/rfcs/spikes/0001/t-builder.spike.ts create mode 100644 docs/rfcs/spikes/0001/tsconfig.json diff --git a/docs/rfcs/spikes/0001/t-builder.spike.ts b/docs/rfcs/spikes/0001/t-builder.spike.ts new file mode 100644 index 0000000000..9fa111e2d2 --- /dev/null +++ b/docs/rfcs/spikes/0001/t-builder.spike.ts @@ -0,0 +1,279 @@ +/** + * RFC 0001 — Spike (b): `t` builder + `defineTable` type inference. + * + * GOAL: prove the *type mechanics* of code-first schema authoring, in isolation, + * under the repo's compiler settings (NodeNext, erasableSyntaxOnly). This file is + * self-contained — it defines its own minimal `t`/`defineTable` so the inference can + * be proven without wiring into the runtime. Spike (c) does the runtime registration. + * + * What this proves: + * 1. `Select` infers the read record — enum -> literal union, + * nullable -> optional, relation -> related record (to-one) / array (to-many), + * server-managed fields -> `readonly`. + * 2. `Insert` drops primary key + server-managed + relations, keeps + * writable fields, preserves optionality. + * 3. Wrong values (excess primary key / server field, off-enum literal) are rejected. + * + * Verify: npx tsc --noEmit --project docs/rfcs/spikes/0001/tsconfig.json + * A green run IS the proof. The `@ts-expect-error` lines prove the negative cases: + * if any stops being an error, tsc fails. + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// ───────────────────────────────────────────────────────────────────────────── +// Field flags & the phantom-typed Field +// ───────────────────────────────────────────────────────────────────────────── + +interface Flags { + nullable?: boolean; + primaryKey?: boolean; + serverManaged?: boolean; // @createdTime / @updatedTime / @computed + readonly?: boolean; + relation?: 'one' | 'many'; +} + +/** + * A schema field. `_ts` and `_flags` are phantom type carriers (never present at + * runtime); the chainable methods narrow the flags at the type level. + */ +interface Field { + readonly _ts: TS; + readonly _flags: F; + readonly kind: string; + readonly meta: Record; + nullable(): Field; + indexed(): Field; + primaryKey(): Field; +} + +// to-one resolves to the related record; to-many to an array of it. +type RelationField = Field< + Card extends 'many' ? Select[] : Select, + { relation: Card; readonly: true } +>; + +// ───────────────────────────────────────────────────────────────────────────── +// Runtime builder (intentionally thin — Spike (c) reuses this to feed `table()`) +// ───────────────────────────────────────────────────────────────────────────── + +function field(kind: string, meta: Record = {}): any { + return { + kind, + meta, + nullable() { + return field(kind, { ...meta, nullable: true }); + }, + indexed() { + return field(kind, { ...meta, indexed: true }); + }, + primaryKey() { + return field(kind, { ...meta, primaryKey: true }); + }, + }; +} + +const t = { + id: () => field('ID') as Field, + string: () => field('String') as Field, + int: () => field('Int') as Field, + float: () => field('Float') as Field, + boolean: () => field('Boolean') as Field, + date: () => field('Date') as Field, + enum: (values: E) => field('String', { enum: values }) as Field, + createdTime: () => field('Date', { assignCreatedTime: true }) as Field, + updatedTime: () => field('Date', { assignUpdatedTime: true }) as Field, + relation: (target: () => T, opts: { from: string }) => + field('relation', { target, ...opts }) as RelationField, + hasMany: (target: () => T, opts: { to: string }) => + field('relation', { target, ...opts }) as RelationField, +}; + +type Shape = Record>; +interface TableDef { + name: string; + shape: S; +} + +// NOTE: no `const` type parameter here. A `const S` would infer the shape's +// properties as `readonly`, and the homomorphic `[K in keyof S]` mapped types below +// would copy that `readonly` onto every output field. Enum literal tuples are +// preserved by `t.enum`'s own `const` parameter, so we don't need it here. +function defineTable(name: string, shape: S): TableDef { + return { name, shape }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// The inference: Select (read) and Insert (write) derived from the shape +// ───────────────────────────────────────────────────────────────────────────── + +type TsOf = F extends Field ? TS : never; +type FlagsOf = F extends Field ? Fl : {}; + +type IsNullable = FlagsOf extends { nullable: true } ? true : false; +type IsReadonly = FlagsOf extends { readonly: true } ? true : false; +type IsPrimaryKey = FlagsOf extends { primaryKey: true } ? true : false; +type IsServerManaged = FlagsOf extends { serverManaged: true } ? true : false; +type IsRelation = FlagsOf extends { relation: 'one' | 'many' } ? true : false; + +// Flatten an intersection of mapped types into one object, preserving readonly/optional. +// (Homomorphic `in keyof T` copies the readonly/optional modifiers from the source.) +type Resolve = { [K in keyof T]: T[K] }; + +/** The read record: every field, with nullable -> optional and server-managed -> readonly. */ +type Select = + T extends TableDef + ? Resolve< + { + [K in keyof S as IsReadonly extends false + ? IsNullable extends false + ? K + : never + : never]: TsOf; + } & { + [K in keyof S as IsReadonly extends false + ? IsNullable extends true + ? K + : never + : never]?: TsOf; + } & { + readonly [K in keyof S as IsReadonly extends true + ? IsNullable extends false + ? K + : never + : never]: TsOf; + } & { + readonly [K in keyof S as IsReadonly extends true + ? IsNullable extends true + ? K + : never + : never]?: TsOf; + } + > + : never; + +/** A field is writable on insert unless it is the PK, server-managed, or a relation. */ +type Writable = IsPrimaryKey extends true + ? false + : IsServerManaged extends true + ? false + : IsRelation extends true + ? false + : true; + +/** The insert record: writable fields only, optionality preserved. */ +type Insert = + T extends TableDef + ? Resolve< + { + [K in keyof S as Writable extends true + ? IsNullable extends false + ? K + : never + : never]: TsOf; + } & { + [K in keyof S as Writable extends true + ? IsNullable extends true + ? K + : never + : never]?: TsOf; + } + > + : never; + +// ───────────────────────────────────────────────────────────────────────────── +// Example schema (the RFC's running example) +// ───────────────────────────────────────────────────────────────────────────── + +const Albums = defineTable('Albums', { + id: t.id().primaryKey(), + title: t.string(), +}); + +const Tracks = defineTable('Tracks', { + id: t.id().primaryKey(), + name: t.string().indexed(), + duration: t.int().nullable(), + status: t.enum(['draft', 'published']), + createdAt: t.createdTime(), + album: t.relation(() => Albums, { from: 'albumId' }), // to-one -> Album record +}); + +// to-many demo (acyclic: Tags has no back-reference to Posts) +const Tags = defineTable('Tags', { + id: t.id().primaryKey(), + label: t.string(), +}); + +const Posts = defineTable('Posts', { + id: t.id().primaryKey(), + title: t.string(), + tags: t.hasMany(() => Tags, { to: 'postId' }), // to-many -> Tag[] +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Type-level assertions — a green tsc run is the proof +// ───────────────────────────────────────────────────────────────────────────── + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +type Track = Select; +type NewTrack = Insert; +type Post = Select; + +// (1) Read record: enum union, nullable->optional, relation->record, server->readonly +type _assert_track = Expect< + Equal< + Track, + { + id: string; + name: string; + status: 'draft' | 'published'; + duration?: number; + readonly createdAt: Date; + readonly album: { id: string; title: string }; + } + > +>; + +// (2) Insert record: PK + server-managed + relation dropped, optionality kept +type _assert_new_track = Expect< + Equal< + NewTrack, + { + name: string; + status: 'draft' | 'published'; + duration?: number; + } + > +>; + +// (3) to-many relation resolves to an array of the related record +type _assert_post = Expect< + Equal< + Post, + { + id: string; + title: string; + readonly tags: { id: string; label: string }[]; + } + > +>; + +// Positive: a minimal valid insert (duration optional, server/PK/relation absent) +const okInsert: NewTrack = { name: 'Intro', status: 'draft' }; + +// Negative cases — each MUST be an error, or tsc fails: +// @ts-expect-error primary key is not part of the insert type +const bad_pk: NewTrack = { name: 'x', status: 'draft', id: '1' }; +// @ts-expect-error 'archived' is not in the enum +const bad_enum: NewTrack = { name: 'x', status: 'archived' }; +// @ts-expect-error createdAt is server-managed, not writable on insert +const bad_server: NewTrack = { name: 'x', status: 'draft', createdAt: new Date() }; +// @ts-expect-error name is required +const bad_missing: NewTrack = { status: 'draft' }; + +// Keep the runtime values referenced so this also type-checks as a module. +export { t, defineTable, Albums, Tracks, Tags, Posts, okInsert }; +export type { Select, Insert, Field, TableDef }; diff --git a/docs/rfcs/spikes/0001/tsconfig.json b/docs/rfcs/spikes/0001/tsconfig.json new file mode 100644 index 0000000000..faa3466ed0 --- /dev/null +++ b/docs/rfcs/spikes/0001/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strict": true + }, + "include": ["./**/*.ts"] +} From 258e4fae1d09eaa1853627602e697779e0a60b68 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 26 Jun 2026 12:03:38 -0400 Subject: [PATCH 03/11] =?UTF-8?q?docs(rfc):=20spike=20(c)=20=E2=80=94=20de?= =?UTF-8?q?fineTable=20value=20->=20table()=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../defineTable-registration.spike.test.js | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/rfcs/spikes/0001/defineTable-registration.spike.test.js diff --git a/docs/rfcs/spikes/0001/defineTable-registration.spike.test.js b/docs/rfcs/spikes/0001/defineTable-registration.spike.test.js new file mode 100644 index 0000000000..bf5650d172 --- /dev/null +++ b/docs/rfcs/spikes/0001/defineTable-registration.spike.test.js @@ -0,0 +1,152 @@ +/** + * RFC 0001 — Spike (c): register a table from a `defineTable` value (no GraphQL). + * + * GOAL: de-risk the migration/DDL-parity question. Prove that a code-first schema + * value compiles into the SAME options the existing `table()` factory consumes + * (resources/databases.ts) — the factory GraphQL itself drives, and that + * dataLayer/.../dataLoader.ts already uses for non-GraphQL tables — yielding a + * fully working table: registered in the `databases` registry, with the right + * attribute metadata, and supporting CRUD. + * + * This reuses the value shape produced by the spike (b) `t` builder (objects with + * `kind` + `meta`). The compiler below is the whole bridge: defineTable value -> + * `{ table, database, attributes }`. + * + * Run (uses the repo's .mocharc.json -> mocha.init.js bootstrap + node options): + * npx mocha docs/rfcs/spikes/0001/defineTable-registration.spike.test.js + */ + +require('../../../../unitTests/testUtils'); +const assert = require('assert'); +const { setupTestDBPath } = require('../../../../unitTests/testUtils'); +const { table, databases } = require('#src/resources/databases'); +const { setMainIsWorker } = require('#js/server/threads/manageThreads'); + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal runtime `t` builder + defineTable (the value shape from spike (b)). +// Kept tiny and dependency-free; the typed twin lives in t-builder.spike.ts. +// ───────────────────────────────────────────────────────────────────────────── + +function field(kind, meta = {}) { + return { + kind, + meta, + nullable() { + return field(kind, { ...meta, nullable: true }); + }, + indexed() { + return field(kind, { ...meta, indexed: true }); + }, + primaryKey() { + return field(kind, { ...meta, primaryKey: true }); + }, + }; +} + +const t = { + id: () => field('ID'), + string: () => field('String'), + int: () => field('Int'), + boolean: () => field('Boolean'), + date: () => field('Date'), + enum: (values) => field('String', { enum: values }), // enum -> String column at runtime + createdTime: () => field('Date', { assignCreatedTime: true }), +}; + +function defineTable(name, shape) { + return { name, shape }; +} + +/** + * The bridge: a defineTable value -> the `table()` factory's TableDefinition. + * Maps each builder field's `kind`/`meta` onto a Harper attribute. This is the + * exact shape graphql.ts produces from a `@table` type — so both front-ends + * converge on `table()`/`makeTable()` and share all downstream DDL semantics. + */ +function compileToTableOptions(def, { database = 'data' } = {}) { + const attributes = Object.entries(def.shape).map(([name, f]) => { + const attr = { name, type: f.kind }; + if (f.meta.primaryKey) attr.isPrimaryKey = true; + if (f.meta.indexed) attr.indexed = true; + if (f.meta.nullable) attr.nullable = true; + if (f.meta.assignCreatedTime) attr.assignCreatedTime = true; + return attr; + }); + return { table: def.name, database, attributes }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// The spike +// ───────────────────────────────────────────────────────────────────────────── + +const DB = 'codefirst_spike'; + +// A code-first schema, authored as a value (mirrors the RFC's running example). +const Tracks = defineTable('Tracks', { + id: t.id().primaryKey(), + name: t.string().indexed(), + duration: t.int().nullable(), + status: t.enum(['draft', 'published']), + createdAt: t.createdTime(), +}); + +describe('RFC 0001 spike (c): defineTable -> table() registration', () => { + let TracksTable; + + before(function () { + setupTestDBPath(); + setMainIsWorker(true); + // The whole point: a value goes in, a registered Harper table comes out. + TracksTable = table(compileToTableOptions(Tracks, { database: DB })); + }); + + it('registers the table in the databases registry', () => { + assert.ok(databases[DB], `database "${DB}" should exist`); + assert.strictEqual(databases[DB].Tracks, TracksTable, 'Tracks should be registered and identical'); + }); + + it('carries the compiled schema as attribute metadata', () => { + assert.strictEqual(TracksTable.primaryKey, 'id', 'primary key detected from the value'); + const byName = Object.fromEntries(TracksTable.attributes.map((a) => [a.name, a])); + assert.deepStrictEqual(Object.keys(byName).sort(), ['createdAt', 'duration', 'id', 'name', 'status']); + assert.strictEqual(byName.id.isPrimaryKey, true); + assert.strictEqual(byName.name.indexed, true); + assert.strictEqual(byName.status.type, 'String', 'enum compiles to a String column'); + assert.strictEqual(byName.createdAt.assignCreatedTime, true, 'server-managed timestamp flag preserved'); + }); + + it('supports CRUD, with the server-managed timestamp auto-assigned', async () => { + await TracksTable.put({ id: 'intro', name: 'Intro', status: 'draft' }); + + const got = await TracksTable.get('intro'); + assert.strictEqual(got.name, 'Intro'); + assert.strictEqual(got.status, 'draft'); + assert.ok(got.createdAt != null, 'createdAt should be auto-assigned by the table machinery'); + + // update path + await TracksTable.put({ id: 'intro', name: 'Intro (remastered)', status: 'published' }); + const updated = await TracksTable.get('intro'); + assert.strictEqual(updated.name, 'Intro (remastered)'); + assert.strictEqual(updated.status, 'published'); + + // delete path (absent record reads back nullish — null or undefined depending on load mode) + await TracksTable.delete('intro'); + assert.ok((await TracksTable.get('intro')) == null, 'record should be gone after delete'); + }); + + it('applies a schema change (added attribute) on re-registration — DDL parity', () => { + // Re-run the bridge with an added column, exactly as a schema edit would. + const Tracks2 = defineTable('Tracks', { + id: t.id().primaryKey(), + name: t.string().indexed(), + duration: t.int().nullable(), + status: t.enum(['draft', 'published']), + createdAt: t.createdTime(), + isrc: t.string().indexed(), // newly added + }); + const TracksTable2 = table(compileToTableOptions(Tracks2, { database: DB })); + assert.strictEqual(TracksTable2, TracksTable, 'same table class is re-asserted, not duplicated'); + const names = TracksTable.attributes.map((a) => a.name); + assert.ok(names.includes('isrc'), 'added attribute is present after re-registration'); + }); +}); From 2b9453759c4babebe4f86d8b23cd7175fc361740 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 26 Jun 2026 12:07:28 -0400 Subject: [PATCH 04/11] docs(rfc): prettier formatting + wildcard path params - 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 --- .../rfcs/0001-typed-discoverable-resources.md | 224 +++++++++--------- docs/rfcs/spikes/0001/t-builder.spike.ts | 43 ++-- 2 files changed, 134 insertions(+), 133 deletions(-) diff --git a/docs/rfcs/0001-typed-discoverable-resources.md b/docs/rfcs/0001-typed-discoverable-resources.md index 7118fb2af7..a3e537d6f3 100644 --- a/docs/rfcs/0001-typed-discoverable-resources.md +++ b/docs/rfcs/0001-typed-discoverable-resources.md @@ -1,11 +1,11 @@ # RFC 0001 — Typed, discoverable resources -| | | -|---|---| -| **Status** | Draft · for discussion | -| **Author** | dawson@harperdb.io | -| **Date** | 2026-06-26 | -| **Scope** | harper core · `schema-codegen` · mcp · openApi | +| | | +| ------------- | ----------------------------------------------------------------------------- | +| **Status** | Draft · for discussion | +| **Author** | dawson@harperdb.io | +| **Date** | 2026-06-26 | +| **Scope** | harper core · `schema-codegen` · mcp · openApi | | **Companion** | A rendered version of this RFC is published as a Claude artifact (shareable). | > **One declaration — authored as runtime values, with TypeScript derived from it — that strengthens types in the editor and feeds validation, OpenAPI, and MCP from a single source of truth.** @@ -14,17 +14,17 @@ Harper describes table-backed resources richly — the GraphQL schema flows into runtime `attributes`, which feed write validation, OpenAPI, and MCP. Everything else is comparatively opaque: a custom `extends Resource` class, its URL path, its per-method params, and the built-in filter/sort/include query grammar are invisible to the type system and to the generated API metadata. This RFC proposes closing that gap with three coordinated pillars, anchored on one principle that Harper's runtime already forces. -**The principle.** Harper strips TypeScript at runtime (`--conditions=typestrip`), which erases types and rules out metadata-emitting decorators. So **runtime metadata must be values, and TypeScript types must be derived from those values — never the reverse.** Taken to its conclusion, this means the *schema itself* can be authored as a TypeScript value, with the record type **inferred** — no generation step, nothing to drift. `schema-codegen` already lives the value-first model for tables; we extend it everywhere and let the schema be code. +**The principle.** Harper strips TypeScript at runtime (`--conditions=typestrip`), which erases types and rules out metadata-emitting decorators. So **runtime metadata must be values, and TypeScript types must be derived from those values — never the reverse.** Taken to its conclusion, this means the _schema itself_ can be authored as a TypeScript value, with the record type **inferred** — no generation step, nothing to drift. `schema-codegen` already lives the value-first model for tables; we extend it everywhere and let the schema be code. -| Pillar | Idea | -|---|---| -| **1 — One schema model** | Author tables in GraphQL *or* TypeScript; both compile to one canonical model, projected to every surface from a single core. | -| **2 — A request contract** | A per-method `params/query/body/response` contract that types handlers and feeds every surface. | -| **3 — Typed query behavior** | Base types for Harper's built-in filter, sort, select, and relationship-include grammar, keyed to the record. | +| Pillar | Idea | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **1 — One schema model** | Author tables in GraphQL _or_ TypeScript; both compile to one canonical model, projected to every surface from a single core. | +| **2 — A request contract** | A per-method `params/query/body/response` contract that types handlers and feeds every surface. | +| **3 — Typed query behavior** | Base types for Harper's built-in filter, sort, select, and relationship-include grammar, keyed to the record. | ## 1. Background & motivation -When a developer builds something with Harper, the experience splits sharply along one line: *is it a table, or is it custom?* +When a developer builds something with Harper, the experience splits sharply along one line: _is it a table, or is it custom?_ Tables are well served — but only via one front door. A GraphQL `@table` type (today the only way to define a table) is parsed in `resources/graphql.ts` into a canonical `Attribute[]` carried on the class, and that array feeds three consumers — write validation in `Table.ts`, the OpenAPI document in `resources/openApi.ts`, and the MCP tool schemas in `components/mcp/tools/schemas/derive.ts`. The community `@harperfast/schema-codegen` plugin adds a fourth: it introspects the live `databases` object and emits a `.d.ts` that augments `declare module 'harperdb'`, so `tables.Tracks` and `extends tables.Tracks` become strongly typed. @@ -39,27 +39,27 @@ A second, quieter problem sits underneath: the same `Attribute` is translated to - **Strong, derived types** for custom resources: path params, query params, request bodies, and responses, inferred in the editor from one runtime declaration. - **Typed base-request behavior** — filtering, sorting, field selection, and relationship inclusion — keyed to each record and its relationship graph. - **One source of truth** that drives editor types, runtime validation, OpenAPI, and MCP, so they can never silently diverge. -- **Schema as code (optional).** Let a table be defined in TypeScript with the same builder, inferring the record type and driving validation/OpenAPI/MCP — *alongside*, not replacing, GraphQL SDL. +- **Schema as code (optional).** Let a table be defined in TypeScript with the same builder, inferring the record type and driving validation/OpenAPI/MCP — _alongside_, not replacing, GraphQL SDL. - **Readable JSDoc** for the JavaScript path, so non-TypeScript users get the same IDE guidance. - **Build-grade codegen** that runs in CI, not only inside a live dev server. **Non-goals** -- Replacing GraphQL as the way tables are defined. The data shape stays in the schema; this RFC adds the *HTTP-binding* layer around it. +- Replacing GraphQL as the way tables are defined. The data shape stays in the schema; this RFC adds the _HTTP-binding_ layer around it. - Forcing a validation library. Bring-your-own (Standard Schema) and a zero-dependency built-in both work. - Breaking the existing class model. `extends Resource` and `extends tables.Foo` remain the idioms. ## 3. Current state: who feeds what -| Surface | Reads from | Custom resources? | Notable gaps (verified) | -|---|---|---|---| -| Write validation | `Table.validate()` | No per-param path | Throws a joined *string*, not per-field errors; ignores `enum/format/pattern` the fragment can express | -| OpenAPI | `openApi.ts` (own switch) | Path only, untyped body | Emits **none** of the filter/sort/select/limit query grammar; Blob → `{type: undefined}` | -| MCP | `derive.ts` (own switch) | `attributes = []` | Mistypes nested objects/arrays (capitalized literals the parser never emits); parameterized custom routes yield **zero** tools; advertises a schema it does not enforce | -| Editor types | `schema-codegen mapType.js` | Not covered | Date→`string`, Blob→`any`; no enums; computed/timestamp fields neither `readonly` nor omitted from `New` | -| *(shared core)* | `attributeToFragment` | — | Exists and is on the parse path, but the four surfaces above bypass it | +| Surface | Reads from | Custom resources? | Notable gaps (verified) | +| ---------------- | --------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Write validation | `Table.validate()` | No per-param path | Throws a joined _string_, not per-field errors; ignores `enum/format/pattern` the fragment can express | +| OpenAPI | `openApi.ts` (own switch) | Path only, untyped body | Emits **none** of the filter/sort/select/limit query grammar; Blob → `{type: undefined}` | +| MCP | `derive.ts` (own switch) | `attributes = []` | Mistypes nested objects/arrays (capitalized literals the parser never emits); parameterized custom routes yield **zero** tools; advertises a schema it does not enforce | +| Editor types | `schema-codegen mapType.js` | Not covered | Date→`string`, Blob→`any`; no enums; computed/timestamp fields neither `readonly` nor omitted from `New` | +| _(shared core)_ | `attributeToFragment` | — | Exists and is on the parse path, but the four surfaces above bypass it | -> **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. +> **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 @@ -77,22 +77,22 @@ flowchart LR ### 4.1 — Author the schema in TypeScript (code-first) -Use the *same* `t` builder as the request contract (Pillar 2), so one vocabulary defines tables and request shapes. The schema is a runtime value; the record type is inferred from it — no generation, no drift, and it works under type-stripping because the value survives and only the type is erased. +Use the _same_ `t` builder as the request contract (Pillar 2), so one vocabulary defines tables and request shapes. The schema is a runtime value; the record type is inferred from it — no generation, no drift, and it works under type-stripping because the value survives and only the type is erased. ```ts import { defineTable, t } from 'harperdb'; export const Tracks = defineTable('Tracks', { - 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 + 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 }); -type Track = t.Select; // inferred read record -type NewTrack = t.Insert; // PK + computed + timestamps omitted, inferred +type Track = t.Select; // inferred read record +type NewTrack = t.Insert; // PK + computed + timestamps omitted, inferred ``` - **Every directive maps to a builder method** — `@primaryKey/@indexed/@computed/@createdTime/@relationship/@sealed/@export` all have a `t` equivalent, so the known-directive vocabulary in `graphql.ts` and the builder stay in lockstep. @@ -100,7 +100,7 @@ type NewTrack = t.Insert; // PK + computed + timestamps omitted, - **Registration** — the component loader discovers exported `defineTable` values, builds the same internal `typeDef`, and drives the same `table()`/`makeTable()` + schema-evolution machinery (add/remove attributes, indices, audit, replication) that GraphQL drives today. Cross-table references resolve via the lazy thunk, so load order is not a constraint. - **The typed handle is the import.** `import { Tracks }` is already fully typed — no `.d.ts` generation needed for code-first tables. The bare `tables.Tracks` global still works and can be augmented for those who use it. -> **Design lineage.** This is the *inferred, code-first* model (Drizzle-style: `typeof t.$inferSelect`) rather than the *DSL + codegen* model (Prisma-style). GraphQL SDL stays first-class for teams who prefer a declarative, at-a-glance schema file — the point is that both are front-ends to the same model, not competing sources of truth. +> **Design lineage.** This is the _inferred, code-first_ model (Drizzle-style: `typeof t.$inferSelect`) rather than the _DSL + codegen_ model (Prisma-style). GraphQL SDL stays first-class for teams who prefer a declarative, at-a-glance schema file — the point is that both are front-ends to the same model, not competing sources of truth. ### 4.2 — Serve every surface from one projector @@ -112,7 +112,7 @@ Whichever front-end authored it, the canonical model reaches all surfaces throug - **Nullability as a parameter** — `union` form for MCP and TS, `nullable: true`/required-array for OpenAPI 3.0. - **Direction** — an `input` vs `output` flag so write schemas advertise the coercible accept-set (Date accepts `string|number|Date`) while read schemas advertise the stored type. -Then make `schema-codegen` a pure *fragment → TypeScript* printer rather than a fourth switch. Lock everything with one golden conformance fixture that asserts the **GraphQL and TypeScript front-ends round-trip to identical canonical models**, and that all surfaces agree on every edge (Long, Date, Bytes, Blob, `[Type]`, nested objects, `@relationship`, `@computed`, unspecified nullability). +Then make `schema-codegen` a pure _fragment → TypeScript_ printer rather than a fourth switch. Lock everything with one golden conformance fixture that asserts the **GraphQL and TypeScript front-ends round-trip to identical canonical models**, and that all surfaces agree on every edge (Long, Date, Bytes, Blob, `[Type]`, nested objects, `@relationship`, `@computed`, unspecified nullability). > **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. @@ -126,17 +126,18 @@ Give custom resources the same descriptive richness tables get, by declaring a c import { Resource, t } from 'harperdb'; // or any Standard Schema lib export class Widget extends Resource.withSchema({ - path: '/widget/:id', - get: { query: t.object({ expand: t.array(t.enum(['parts', 'owner'])).optional() }), - response: WidgetShape }, - post: { body: WidgetInput, response: WidgetShape }, + path: '/widget/:id', + get: { query: t.object({ expand: t.array(t.enum(['parts', 'owner'])).optional() }), response: WidgetShape }, + post: { body: WidgetInput, response: WidgetShape }, }) { - async get(req) { // req.params.id: string · req.query.expand?: ('parts'|'owner')[] - return loadWidget(req.params.id, req.query.expand); - } - async post(req) { // req.body: - return createWidget(req.body); - } + async get(req) { + // req.params.id: string · req.query.expand?: ('parts'|'owner')[] + return loadWidget(req.params.id, req.query.expand); + } + async post(req) { + // req.body: + return createWidget(req.body); + } } ``` @@ -146,16 +147,20 @@ export class Widget extends Resource.withSchema({ ```ts // '/widget/:id/rev/:n' -> { id: string; n: string } -type PathParams = - S extends `${string}:${infer P}/${infer Rest}` ? { [K in P]: string } & PathParams<`/${Rest}`> - : S extends `${string}:${infer P}` ? { [K in P]: string } - : {}; +// '/files/*path' -> { path: string } (named trailing wildcard; bare '*' -> 'wildcard') +type PathParams = S extends `${string}:${infer P}/${infer Rest}` + ? { [K in P]: string } & PathParams<`/${Rest}`> + : S extends `${string}:${infer P}` + ? { [K in P]: string } + : S extends `${string}*${infer W}` + ? { [K in W extends '' ? 'wildcard' : W]: string } + : {}; type RequestOf = { - params: PathParams; - query: Infer; - body: Infer; - user?: User; + params: PathParams; + query: Infer; + body: Infer; + user?: User; }; ``` @@ -163,23 +168,23 @@ type RequestOf = { ### Validator interop -Accept any **Standard Schema** validator (Zod, Valibot, ArkType) so authors get `.infer` types and runtime `validate` for free, *or* a thin built-in `t` that emits a `JsonSchemaFragment` (the zero-dependency default). The one caveat to design around: Standard Schema does not guarantee a JSON-Schema export, and OpenAPI/MCP require one — so third-party validators need a per-library adapter (e.g. Zod v4 `z.toJSONSchema`) with a dev-warning fallback. The built-in `t` is the path that always reduces to JSON Schema. +Accept any **Standard Schema** validator (Zod, Valibot, ArkType) so authors get `.infer` types and runtime `validate` for free, _or_ a thin built-in `t` that emits a `JsonSchemaFragment` (the zero-dependency default). The one caveat to design around: Standard Schema does not guarantee a JSON-Schema export, and OpenAPI/MCP require one — so third-party validators need a per-library adapter (e.g. Zod v4 `z.toJSONSchema`) with a dev-warning fallback. The built-in `t` is the path that always reduces to JSON Schema. A function-style twin, `defineResource(contract, impl)`, produces identical runtime metadata for authors who prefer object literals over classes. ## 6. Pillar 3 — Typed base-request behavior -This is the layer for what Harper does *automatically* on a GET: filter, sort, paginate, select fields, and include related records via query params. The building blocks are typed but never threaded to the record, and the grammar is undocumented downstream. +This is the layer for what Harper does _automatically_ on a GET: filter, sort, paginate, select fields, and include related records via query params. The building blocks are typed but never threaded to the record, and the grammar is undocumented downstream. **What Harper already accepts (and parses into `RequestTarget`):** -| Capability | Query grammar | Parsed onto target | -|---|---|---| -| Filter | `?price=gt=100`, `&`/`\|`/`()` | `conditions: Conditions` | -| Relationship filter | `?author.name=Harper` | nested `conditions` | -| Sort | `?sort(-price,+name)` | `sort: Sort` | -| Select / include | `?select(name,books(title))` | `select: Select` | -| Paginate | `?limit(20,10)` | `limit`, `offset` | +| Capability | Query grammar | Parsed onto target | +| ------------------- | ------------------------------ | ------------------------ | +| Filter | `?price=gt=100`, `&`/`\|`/`()` | `conditions: Conditions` | +| Relationship filter | `?author.name=Harper` | nested `conditions` | +| Sort | `?sort(-price,+name)` | `sort: Sort` | +| Select / include | `?select(name,books(title))` | `select: Select` | +| Paginate | `?limit(20,10)` | `limit`, `offset` | The typed primitives `Condition`, `Sort`, `Select`, and the `Comparator` union already exist in `resources/ResourceInterface.ts` — keyed on `keyof R`. The problem is the seams: `RequestTarget` is not generic (`conditions` is `any`), `select` isn't typed against relationships, OpenAPI emits none of this, and MCP's search schema omits sort and relationship include. @@ -187,11 +192,12 @@ The typed primitives `Condition`, `Sort`, `Select`, and the `Comparator` u ```ts class RequestTarget extends URLSearchParams { - id?: IdOf; - conditions?: Conditions; // attribute keyed to keyof R - sort?: Sort; // { attribute: keyof R; descending?: boolean; next? } - select?: Select>; - limit?: number; offset?: number; + id?: IdOf; + conditions?: Conditions; // attribute keyed to keyof R + sort?: Sort; // { attribute: keyof R; descending?: boolean; next? } + select?: Select>; + limit?: number; + offset?: number; } ``` @@ -203,26 +209,21 @@ Publish the whole grammar as one composable type, parameterized by the record an ```ts interface HarperQuery { - /** filter: each attribute accepts its value or an operator object */ - where?: Where; - /** sort by one or more attributes, with direction */ - sort?: Sort; - /** project columns and INCLUDE relationships (typed + nestable) */ - select?: SelectClause; - limit?: number; - offset?: number; + /** filter: each attribute accepts its value or an operator object */ + where?: Where; + /** sort by one or more attributes, with direction */ + sort?: Sort; + /** project columns and INCLUDE relationships (typed + nestable) */ + select?: SelectClause; + limit?: number; + offset?: number; } // operators apply by type: strings get contains/starts_with, numbers/dates get gt/between, ... -type Where = - { [K in keyof R]?: R[K] | OpFor } & - { [K in keyof Rel]?: Where, {}> }; // author.name=... +type Where = { [K in keyof R]?: R[K] | OpFor } & { [K in keyof Rel]?: Where, {}> }; // author.name=... // include a relationship by naming it; nest a sub-select to shape it -type SelectClause = ( - keyof R | keyof Rel | - { [K in keyof Rel]?: SelectClause, {}> } -)[]; +type SelectClause = (keyof R | keyof Rel | { [K in keyof Rel]?: SelectClause, {}> })[]; ``` ### 6.3 — Where the relationship graph comes from @@ -234,13 +235,17 @@ For `Rel` to be real, the relationship map per table — to-one resolving to the ```ts // generated (GraphQL path) or inferred (code-first path), alongside the record interface -export interface BookRelations { author: Author; } // @relationship(from:"authorId") — to-one -export interface AuthorRelations { books: Book[]; } // @relationship(to:"authorId") — to-many +export interface BookRelations { + author: Author; +} // @relationship(from:"authorId") — to-one +export interface AuthorRelations { + books: Book[]; +} // @relationship(to:"authorId") — to-many ``` ### 6.4 — Narrow the response to what was selected (stretch) -A mapped type can shape the *return* type to the select clause, so including `books(title)` yields `{ ...; books: { title: string }[] }` instead of the full record. The default (no select) returns full `R`. +A mapped type can shape the _return_ type to the select clause, so including `books(title)` yields `{ ...; books: { title: string }[] }` instead of the full record. The default (no select) returns full `R`. ```ts type Selected = S extends undefined ? R @@ -252,7 +257,7 @@ get(req: { query: HarperQuery }): ### 6.5 — Feed the same grammar downstream -- **OpenAPI** — emit the collection query parameters that are *entirely missing today*: a documented filter syntax, `sort`, `select`, `limit`/`offset`, plus relationship dot-paths. A shared `collectionQueryParameters(attributes, relations)` generator off the unified projector. +- **OpenAPI** — emit the collection query parameters that are _entirely missing today_: a documented filter syntax, `sort`, `select`, `limit`/`offset`, plus relationship dot-paths. A shared `collectionQueryParameters(attributes, relations)` generator off the unified projector. - **MCP** — extend `deriveSearchSchema` to add `sort` and relationship `include`, derive the comparator enum from the real `COMPARATORS` set (not the hardcoded subset), and enum the includable relationship names. > **Payoff.** The filter/sort/include capability becomes one typed object that the editor checks, the REST docs describe, and the MCP tool exposes — instead of a powerful grammar that today is only discoverable by reading the parser. @@ -266,7 +271,7 @@ get(req: { query: HarperQuery }): - **Highest value:** mark `@computed` / `@createdTime` / `@updatedTime` / `@expiresAt` fields `readonly`, and omit them from `New` (which today omits only the primary key) so the insert type stops inviting writes to server-managed fields. - Support **enums / literal unions** (add an `ENUM_TYPE_DEFINITION` case in `graphql.ts`; the fragment already has an `enum` slot). - `any` → `unknown`; `Bytes` → `string`; optional `Date` emission. -- Resolve relationship target names against the *set of emitted interfaces*, not a blind `singularize()` — a mismatch currently emits a dangling type reference that fails to compile. +- Resolve relationship target names against the _set of emitted interfaces_, not a blind `singularize()` — a mismatch currently emits a dangling type reference that fails to compile. **Build & CI** @@ -287,10 +292,13 @@ The contract makes one edge validator possible: before dispatch, validate `param ```ts // instead of: throw new ClientError(errors.join('. ')) -throw new ValidationError({ status: 400, errors: [ - { path: 'body.price', code: 'minimum', message: 'must be ≥ 0' }, - { path: 'query.sort', code: 'unknown_attribute', message: 'no attribute "naem"' }, -]}); +throw new ValidationError({ + status: 400, + errors: [ + { path: 'body.price', code: 'minimum', message: 'must be ≥ 0' }, + { path: 'query.sort', code: 'unknown_attribute', message: 'no attribute "naem"' }, + ], +}); ``` ## 9. Discoverability & ergonomics @@ -304,29 +312,29 @@ throw new ValidationError({ status: 400, errors: [ Each phase ships value on its own; later phases depend on the projector and the relationship graph from earlier ones. -| Phase | Work | -|---|---| -| **0 · days** | Quick wins: JSDoc/`.d.ts` hygiene on `RequestTarget` and method signatures; collapse codegen aliases 6→3; `readonly` + `New` omission for computed/timestamp fields; `singularName` override + warning. | -| **1** | Unify the projector: harden `attributeToFragment`; point OpenAPI, MCP, and codegen at it; add the golden conformance fixture. Fixes the MCP nested-typing and OpenAPI-Blob bugs for free. | -| **2** | The request contract: `withSchema`/`defineResource` + built-in `t`; wire OpenAPI + MCP (incl. registering parameterized resources); edge validation with structured 400s. Establishes the `t` vocabulary the code-first schema reuses. | -| **2b** | Code-first schema: `defineTable` as a second front-end to the canonical model — builder + inference, loader registration into `table()`/`makeTable()`, schema-evolution off the canonical `typeDef`, and front-end round-trip conformance with GraphQL. The largest piece — gate on the migration-parity guardrail. | -| **3** | Typed query behavior: generic `RequestTarget`; publish `HarperQuery`; emit the relationship graph from codegen; OpenAPI collection params + MCP sort/include. | -| **4** | Reach: Standard Schema adapters; `Selected` response narrowing; per-attribute operator typing. | +| Phase | Work | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0 · days** | Quick wins: JSDoc/`.d.ts` hygiene on `RequestTarget` and method signatures; collapse codegen aliases 6→3; `readonly` + `New` omission for computed/timestamp fields; `singularName` override + warning. | +| **1** | Unify the projector: harden `attributeToFragment`; point OpenAPI, MCP, and codegen at it; add the golden conformance fixture. Fixes the MCP nested-typing and OpenAPI-Blob bugs for free. | +| **2** | The request contract: `withSchema`/`defineResource` + built-in `t`; wire OpenAPI + MCP (incl. registering parameterized resources); edge validation with structured 400s. Establishes the `t` vocabulary the code-first schema reuses. | +| **2b** | Code-first schema: `defineTable` as a second front-end to the canonical model — builder + inference, loader registration into `table()`/`makeTable()`, schema-evolution off the canonical `typeDef`, and front-end round-trip conformance with GraphQL. The largest piece — gate on the migration-parity guardrail. | +| **3** | Typed query behavior: generic `RequestTarget`; publish `HarperQuery`; emit the relationship graph from codegen; OpenAPI collection params + MCP sort/include. | +| **4** | Reach: Standard Schema adapters; `Selected` response narrowing; per-attribute operator typing. | ## 11. Verified findings Claims in this RFC were checked against source by independent verification passes. The high-confidence findings and their evidence: -| Finding | Evidence | -|---|---| -| Four translators of one `Attribute`; shared `attributeToFragment` already exists but is bypassed | `openApi.ts`, `mcp/.../derive.ts`, `schema-codegen/mapType.js`, `jsonSchemaTypes.ts` | -| MCP mistypes nested objects/arrays via capitalized `'Object'`/`'Array'` literals the parser never emits | `derive.ts` vs `graphql.ts` (lowercase `'array'` + `.elements`) | -| Parameterized custom resources produce zero MCP tools | `Resources.set()` returns before base-map add; `application.ts` iterates the map only | -| OpenAPI emits no filter/sort/select/limit query params | `openApi.ts` attaches path params only | -| Computed/timestamp fields not `readonly`, not omitted from `New` | `generateInterface.js` (`Omit` of PK only); flags present on `Attribute` | -| MCP advertises an input schema it does not enforce; `Table.validate` throws a joined string | `application.ts` handlers; `Table.ts` validation section | -| Codegen runs only under `DEV_MODE` behind a 5s timer; no CI one-shot | `schema-codegen/extensionModule.js` | -| Existing programmatic table factory `table()` already used by `dataLoader.ts` (de-risks code-first registration) | `resources/databases.ts` `table()`; `dataLayer/.../dataLoader.ts` | +| Finding | Evidence | +| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Four translators of one `Attribute`; shared `attributeToFragment` already exists but is bypassed | `openApi.ts`, `mcp/.../derive.ts`, `schema-codegen/mapType.js`, `jsonSchemaTypes.ts` | +| MCP mistypes nested objects/arrays via capitalized `'Object'`/`'Array'` literals the parser never emits | `derive.ts` vs `graphql.ts` (lowercase `'array'` + `.elements`) | +| Parameterized custom resources produce zero MCP tools | `Resources.set()` returns before base-map add; `application.ts` iterates the map only | +| OpenAPI emits no filter/sort/select/limit query params | `openApi.ts` attaches path params only | +| Computed/timestamp fields not `readonly`, not omitted from `New` | `generateInterface.js` (`Omit` of PK only); flags present on `Attribute` | +| MCP advertises an input schema it does not enforce; `Table.validate` throws a joined string | `application.ts` handlers; `Table.ts` validation section | +| Codegen runs only under `DEV_MODE` behind a 5s timer; no CI one-shot | `schema-codegen/extensionModule.js` | +| Existing programmatic table factory `table()` already used by `dataLoader.ts` (de-risks code-first registration) | `resources/databases.ts` `table()`; `dataLayer/.../dataLoader.ts` | --- diff --git a/docs/rfcs/spikes/0001/t-builder.spike.ts b/docs/rfcs/spikes/0001/t-builder.spike.ts index 9fa111e2d2..54a4fa9d77 100644 --- a/docs/rfcs/spikes/0001/t-builder.spike.ts +++ b/docs/rfcs/spikes/0001/t-builder.spike.ts @@ -125,17 +125,13 @@ type Select = T extends TableDef ? Resolve< { - [K in keyof S as IsReadonly extends false - ? IsNullable extends false - ? K - : never - : never]: TsOf; + [K in keyof S as IsReadonly extends false ? (IsNullable extends false ? K : never) : never]: TsOf< + S[K] + >; } & { - [K in keyof S as IsReadonly extends false - ? IsNullable extends true - ? K - : never - : never]?: TsOf; + [K in keyof S as IsReadonly extends false ? (IsNullable extends true ? K : never) : never]?: TsOf< + S[K] + >; } & { readonly [K in keyof S as IsReadonly extends true ? IsNullable extends false @@ -153,30 +149,27 @@ type Select = : never; /** A field is writable on insert unless it is the PK, server-managed, or a relation. */ -type Writable = IsPrimaryKey extends true - ? false - : IsServerManaged extends true +type Writable = + IsPrimaryKey extends true ? false - : IsRelation extends true + : IsServerManaged extends true ? false - : true; + : IsRelation extends true + ? false + : true; /** The insert record: writable fields only, optionality preserved. */ type Insert = T extends TableDef ? Resolve< { - [K in keyof S as Writable extends true - ? IsNullable extends false - ? K - : never - : never]: TsOf; + [K in keyof S as Writable extends true ? (IsNullable extends false ? K : never) : never]: TsOf< + S[K] + >; } & { - [K in keyof S as Writable extends true - ? IsNullable extends true - ? K - : never - : never]?: TsOf; + [K in keyof S as Writable extends true ? (IsNullable extends true ? K : never) : never]?: TsOf< + S[K] + >; } > : never; From 79b506bbb4023949042696ae3de0e885e104e9c1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 26 Jun 2026 12:21:00 -0400 Subject: [PATCH 05/11] fix(mcp): enumerate parameterised routes when building application tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/mcp/tools/application.ts | 31 +++- .../defineTable-registration.spike.test.js | 152 ------------------ .../mcp/tools/application-paramroutes.test.js | 90 +++++++++++ 3 files changed, 116 insertions(+), 157 deletions(-) delete mode 100644 docs/rfcs/spikes/0001/defineTable-registration.spike.test.js create mode 100644 unitTests/components/mcp/tools/application-paramroutes.test.js diff --git a/components/mcp/tools/application.ts b/components/mcp/tools/application.ts index 4088e3f094..4b7163fa51 100644 --- a/components/mcp/tools/application.ts +++ b/components/mcp/tools/application.ts @@ -125,7 +125,12 @@ interface ToolAnnotationsLike { openWorldHint?: boolean; } -type ResourcesRegistry = Map; +/** A compiled parameterised route (e.g. `/widget/:id`), stored outside the base Map. */ +interface ParamRouteEntry { + pattern: string; + entry: ResourceRegistryEntry; +} +type ResourcesRegistry = Map & { paramRoutes?: ParamRouteEntry[] }; type RequestTargetCtor = new () => Record & { conditions?: unknown[]; operator?: string; @@ -887,21 +892,23 @@ function buildApplicationTools(resources: ResourcesRegistry): void { const claimedSuffixes = new Set(); let toolsRegistered = 0; let resourcesConsidered = 0; - for (const [path, entry] of resources) { + + const considerEntry = (path: string, entry: ResourceRegistryEntry | undefined): void => { + if (!entry) return; resourcesConsidered++; - if (!shouldEnumerate(entry)) continue; + if (!shouldEnumerate(entry)) return; const ResourceClass = entry.Resource; // @hidden type-level: suppress the Resource from MCP tool listing entirely. // Data remains accessible via direct query/RBAC; only descriptive surfaces drop it. if (ResourceClass?.hidden === true) { harperLogger.trace(`MCP application: '/${path}' suppressed from tool listing (@hidden)`); - continue; + return; } const verbs = detectVerbs(ResourceClass); const hasVerbs = verbs.get || verbs.search || verbs.create || verbs.updatePut || verbs.updatePatch || verbs.delete; const hasCustomTools = Array.isArray(ResourceClass?.mcpTools) && ResourceClass.mcpTools.length > 0; const hasCustomPrompts = Array.isArray(ResourceClass?.mcpPrompts) && ResourceClass.mcpPrompts.length > 0; - if (!hasVerbs && !hasCustomTools && !hasCustomPrompts) continue; + if (!hasVerbs && !hasCustomTools && !hasCustomPrompts) return; const databaseName = ResourceClass?.databaseName; const tableName = ResourceClass?.tableName; const suffix = uniqueSuffix(path, databaseName, claimedSuffixes); @@ -920,7 +927,21 @@ function buildApplicationTools(resources: ResourcesRegistry): void { } toolsRegistered += registerCustomMcpTools(ResourceClass, path); registerCustomMcpPrompts(ResourceClass, path); + }; + + for (const [path, entry] of resources) considerEntry(path, entry); + + // Parameterised routes (e.g. `/widget/:id`) live OUTSIDE the base Map: Resources.setParamRoute + // stores them in `paramRoutes` and returns before the Map insert, so the loop above never sees + // them. Without this, a custom resource declaring `static path = '/widget/:id'` produces ZERO MCP + // tools — even though it appears in the OpenAPI document, which already iterates `paramRoutes`. + // Enumerate them so the tool surface matches the REST surface. The common single-`:id` case binds + // via `target.id` in the verb handlers; richer multi-segment/named-wildcard binding rides on the + // per-method request contract (RFC 0001, Pillar 2). + for (const route of resources.paramRoutes ?? []) { + considerEntry(route.pattern, route.entry); } + harperLogger.info( `MCP application profile: considered ${resourcesConsidered} resource(s), registered ${toolsRegistered} tool(s)` ); diff --git a/docs/rfcs/spikes/0001/defineTable-registration.spike.test.js b/docs/rfcs/spikes/0001/defineTable-registration.spike.test.js deleted file mode 100644 index bf5650d172..0000000000 --- a/docs/rfcs/spikes/0001/defineTable-registration.spike.test.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * RFC 0001 — Spike (c): register a table from a `defineTable` value (no GraphQL). - * - * GOAL: de-risk the migration/DDL-parity question. Prove that a code-first schema - * value compiles into the SAME options the existing `table()` factory consumes - * (resources/databases.ts) — the factory GraphQL itself drives, and that - * dataLayer/.../dataLoader.ts already uses for non-GraphQL tables — yielding a - * fully working table: registered in the `databases` registry, with the right - * attribute metadata, and supporting CRUD. - * - * This reuses the value shape produced by the spike (b) `t` builder (objects with - * `kind` + `meta`). The compiler below is the whole bridge: defineTable value -> - * `{ table, database, attributes }`. - * - * Run (uses the repo's .mocharc.json -> mocha.init.js bootstrap + node options): - * npx mocha docs/rfcs/spikes/0001/defineTable-registration.spike.test.js - */ - -require('../../../../unitTests/testUtils'); -const assert = require('assert'); -const { setupTestDBPath } = require('../../../../unitTests/testUtils'); -const { table, databases } = require('#src/resources/databases'); -const { setMainIsWorker } = require('#js/server/threads/manageThreads'); - -// ───────────────────────────────────────────────────────────────────────────── -// Minimal runtime `t` builder + defineTable (the value shape from spike (b)). -// Kept tiny and dependency-free; the typed twin lives in t-builder.spike.ts. -// ───────────────────────────────────────────────────────────────────────────── - -function field(kind, meta = {}) { - return { - kind, - meta, - nullable() { - return field(kind, { ...meta, nullable: true }); - }, - indexed() { - return field(kind, { ...meta, indexed: true }); - }, - primaryKey() { - return field(kind, { ...meta, primaryKey: true }); - }, - }; -} - -const t = { - id: () => field('ID'), - string: () => field('String'), - int: () => field('Int'), - boolean: () => field('Boolean'), - date: () => field('Date'), - enum: (values) => field('String', { enum: values }), // enum -> String column at runtime - createdTime: () => field('Date', { assignCreatedTime: true }), -}; - -function defineTable(name, shape) { - return { name, shape }; -} - -/** - * The bridge: a defineTable value -> the `table()` factory's TableDefinition. - * Maps each builder field's `kind`/`meta` onto a Harper attribute. This is the - * exact shape graphql.ts produces from a `@table` type — so both front-ends - * converge on `table()`/`makeTable()` and share all downstream DDL semantics. - */ -function compileToTableOptions(def, { database = 'data' } = {}) { - const attributes = Object.entries(def.shape).map(([name, f]) => { - const attr = { name, type: f.kind }; - if (f.meta.primaryKey) attr.isPrimaryKey = true; - if (f.meta.indexed) attr.indexed = true; - if (f.meta.nullable) attr.nullable = true; - if (f.meta.assignCreatedTime) attr.assignCreatedTime = true; - return attr; - }); - return { table: def.name, database, attributes }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// The spike -// ───────────────────────────────────────────────────────────────────────────── - -const DB = 'codefirst_spike'; - -// A code-first schema, authored as a value (mirrors the RFC's running example). -const Tracks = defineTable('Tracks', { - id: t.id().primaryKey(), - name: t.string().indexed(), - duration: t.int().nullable(), - status: t.enum(['draft', 'published']), - createdAt: t.createdTime(), -}); - -describe('RFC 0001 spike (c): defineTable -> table() registration', () => { - let TracksTable; - - before(function () { - setupTestDBPath(); - setMainIsWorker(true); - // The whole point: a value goes in, a registered Harper table comes out. - TracksTable = table(compileToTableOptions(Tracks, { database: DB })); - }); - - it('registers the table in the databases registry', () => { - assert.ok(databases[DB], `database "${DB}" should exist`); - assert.strictEqual(databases[DB].Tracks, TracksTable, 'Tracks should be registered and identical'); - }); - - it('carries the compiled schema as attribute metadata', () => { - assert.strictEqual(TracksTable.primaryKey, 'id', 'primary key detected from the value'); - const byName = Object.fromEntries(TracksTable.attributes.map((a) => [a.name, a])); - assert.deepStrictEqual(Object.keys(byName).sort(), ['createdAt', 'duration', 'id', 'name', 'status']); - assert.strictEqual(byName.id.isPrimaryKey, true); - assert.strictEqual(byName.name.indexed, true); - assert.strictEqual(byName.status.type, 'String', 'enum compiles to a String column'); - assert.strictEqual(byName.createdAt.assignCreatedTime, true, 'server-managed timestamp flag preserved'); - }); - - it('supports CRUD, with the server-managed timestamp auto-assigned', async () => { - await TracksTable.put({ id: 'intro', name: 'Intro', status: 'draft' }); - - const got = await TracksTable.get('intro'); - assert.strictEqual(got.name, 'Intro'); - assert.strictEqual(got.status, 'draft'); - assert.ok(got.createdAt != null, 'createdAt should be auto-assigned by the table machinery'); - - // update path - await TracksTable.put({ id: 'intro', name: 'Intro (remastered)', status: 'published' }); - const updated = await TracksTable.get('intro'); - assert.strictEqual(updated.name, 'Intro (remastered)'); - assert.strictEqual(updated.status, 'published'); - - // delete path (absent record reads back nullish — null or undefined depending on load mode) - await TracksTable.delete('intro'); - assert.ok((await TracksTable.get('intro')) == null, 'record should be gone after delete'); - }); - - it('applies a schema change (added attribute) on re-registration — DDL parity', () => { - // Re-run the bridge with an added column, exactly as a schema edit would. - const Tracks2 = defineTable('Tracks', { - id: t.id().primaryKey(), - name: t.string().indexed(), - duration: t.int().nullable(), - status: t.enum(['draft', 'published']), - createdAt: t.createdTime(), - isrc: t.string().indexed(), // newly added - }); - const TracksTable2 = table(compileToTableOptions(Tracks2, { database: DB })); - assert.strictEqual(TracksTable2, TracksTable, 'same table class is re-asserted, not duplicated'); - const names = TracksTable.attributes.map((a) => a.name); - assert.ok(names.includes('isrc'), 'added attribute is present after re-registration'); - }); -}); diff --git a/unitTests/components/mcp/tools/application-paramroutes.test.js b/unitTests/components/mcp/tools/application-paramroutes.test.js new file mode 100644 index 0000000000..573b0f9342 --- /dev/null +++ b/unitTests/components/mcp/tools/application-paramroutes.test.js @@ -0,0 +1,90 @@ +// RFC 0001: parameterised custom resources must surface as MCP tools. +// +// Regression test for the verified gap: a custom resource declaring 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. MCP's buildApplicationTools previously iterated only the +// Map, so such resources produced ZERO tools — while OpenAPI (which iterates +// paramRoutes) did expose them. buildApplicationTools now enumerates paramRoutes +// too, so the tool surface matches the REST surface. + +const assert = require('node:assert/strict'); +const { + registerApplicationTools, + _setResourcesForTest, + _setRequestTargetForTest, + _resetApplicationToolsRegisteredForTest, +} = require('#src/components/mcp/tools/application'); +const { listTools, _resetRegistryForTest } = require('#src/components/mcp/toolRegistry'); +const { _resetPromptRegistryForTest } = require('#src/components/mcp/promptRegistry'); + +const SUPER = { username: 'admin', role: { permission: { super_user: true } } }; + +class FakeRequestTarget {} + +// A custom (non-table) Resource registered at a parameterised path. +function makeParamResource() { + class Widget {} + Widget.prototype.get = function () {}; // read verb present + Widget.prototype.post = function () {}; // create verb present + Widget.get = async (target) => ({ id: target.id, name: 'sample' }); + Widget.post = async (_target, data) => ({ created: true, ...data }); + return Widget; +} + +// A registry (Map) with a `paramRoutes` side-array, mirroring resources/Resources.ts. +function makeRegistryWithParamRoute(pattern, Resource, exportTypes) { + const m = new Map(); // intentionally no static Map entries + m.paramRoutes = [{ pattern, entry: { path: pattern, Resource, exportTypes, hasSubPaths: false, relativeURL: '' } }]; + return m; +} + +function listSuperToolNames() { + const { tools } = listTools({ user: SUPER, profile: 'application', sessionId: 's', limit: 200 }); + return tools.map((t) => t.name); +} + +describe('mcp/tools/application — parameterised routes (RFC 0001)', () => { + beforeEach(() => { + _resetRegistryForTest(); + _resetPromptRegistryForTest(); + _setRequestTargetForTest(FakeRequestTarget); + }); + + afterEach(() => { + _resetRegistryForTest(); + _resetPromptRegistryForTest(); + _setResourcesForTest(undefined); + _setRequestTargetForTest(undefined); + _resetApplicationToolsRegisteredForTest(); + }); + + it('registers tools for a custom resource on a parameterised path (/widget/:id)', () => { + _setResourcesForTest(makeRegistryWithParamRoute('widget/:id', makeParamResource())); + registerApplicationTools(); + const names = listSuperToolNames(); + assert.ok(names.length > 0, `expected tools for the parameterised resource, got none`); + assert.ok( + names.some((n) => n.startsWith('get_')), + `expected a get_* tool, got ${JSON.stringify(names)}` + ); + assert.ok( + names.some((n) => n.startsWith('create_')), + `expected a create_* tool, got ${JSON.stringify(names)}` + ); + }); + + it('respects mcp:false on a parameterised route', () => { + _setResourcesForTest(makeRegistryWithParamRoute('secret/:id', makeParamResource(), { mcp: false })); + registerApplicationTools(); + assert.equal(listSuperToolNames().length, 0, 'mcp:false should suppress the param-route resource'); + }); + + it('registers nothing when there are no resources at all', () => { + const empty = new Map(); + empty.paramRoutes = []; + _setResourcesForTest(empty); + registerApplicationTools(); + assert.equal(listSuperToolNames().length, 0); + }); +}); From 6c703f2eca72ea2431968310284353bea470af63 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 26 Jun 2026 12:21:33 -0400 Subject: [PATCH 06/11] test(resources): promote code-first registration spike + add @relationship 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 --- .../rfcs/0001-typed-discoverable-resources.md | 5 +- .../defineTable-registration.test.js | 198 ++++++++++++++++++ 2 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 unitTests/resources/defineTable-registration.test.js diff --git a/docs/rfcs/0001-typed-discoverable-resources.md b/docs/rfcs/0001-typed-discoverable-resources.md index a3e537d6f3..d9e8965eb9 100644 --- a/docs/rfcs/0001-typed-discoverable-resources.md +++ b/docs/rfcs/0001-typed-discoverable-resources.md @@ -342,5 +342,6 @@ Claims in this RFC were checked against source by independent verification passe This RFC is paired with proof-of-concept spikes, added to this PR as they land: -- **Spike B — `t` builder + `defineTable` inference.** A self-contained, type-checked module proving `t.Select`/`t.Insert` inference (readonly + computed/timestamp/PK omission) and the `t.relation` typed graph. -- **Spike C — `defineTable` → `table()` registration.** Compiles a `defineTable` value into the options the existing `table()` factory consumes and registers a working table from a value (no GraphQL), validated by a unit test. +- **Spike B — `t` builder + `defineTable` inference.** A self-contained, type-checked module proving `t.Select`/`t.Insert` inference (readonly + computed/timestamp/PK omission) and the `t.relation` typed graph. Lives at `docs/rfcs/spikes/0001/t-builder.spike.ts`; verify with `npx tsc --noEmit --project docs/rfcs/spikes/0001/tsconfig.json`. +- **Spike C — `defineTable` → `table()` registration (promoted to the suite).** `unitTests/resources/defineTable-registration.test.js` compiles a `defineTable` value into the existing `table()` factory's options and registers a working table from a value (no GraphQL): registry entry, attribute metadata, CRUD with auto-assigned timestamp, schema evolution (added attribute re-asserts + reindexes), and a **many-to-one `@relationship` resolving end-to-end** (FK auto-added + indexed, `definition.tableClass` linked, related record resolves). Runs in CI. +- **First real surface wired — MCP param-route enumeration.** `components/mcp/tools/application.ts` now enumerates `resources.paramRoutes`, so a custom resource on a parameterized path (`static path = '/widget/:id'`) surfaces as MCP tools instead of producing zero (the §3 "sharpest finding"). Regression test: `unitTests/components/mcp/tools/application-paramroutes.test.js`. diff --git a/unitTests/resources/defineTable-registration.test.js b/unitTests/resources/defineTable-registration.test.js new file mode 100644 index 0000000000..d0264bdb93 --- /dev/null +++ b/unitTests/resources/defineTable-registration.test.js @@ -0,0 +1,198 @@ +// RFC 0001 — code-first schema registration (promoted from docs/rfcs/spikes/0001). +// +// Proves that a code-first schema *value* compiles into the options the existing +// `table()` factory consumes (resources/databases.ts) — the same factory GraphQL +// drives and dataLoader.ts already uses — yielding a working table with no GraphQL: +// registry entry, attribute metadata, CRUD, schema evolution, and relationships. +// +// `t` / `defineTable` / `compileToTableOptions` below mirror the future public API +// (the typed twin lives in docs/rfcs/spikes/0001/t-builder.spike.ts). Keeping this +// in the suite guards that `table()` continues to accept the shape that API compiles to. + +require('../testUtils'); +const assert = require('assert'); +const { setupTestDBPath } = require('../testUtils'); +const { table, databases } = require('#src/resources/databases'); +const { setMainIsWorker } = require('#js/server/threads/manageThreads'); + +// ─── Minimal runtime builder (value shape from the t-builder spike) ────────── + +function field(kind, meta = {}) { + return { + kind, + meta, + nullable() { + return field(kind, { ...meta, nullable: true }); + }, + indexed() { + return field(kind, { ...meta, indexed: true }); + }, + primaryKey() { + return field(kind, { ...meta, primaryKey: true }); + }, + }; +} + +const t = { + id: () => field('ID'), + string: () => field('String'), + int: () => field('Int'), + boolean: () => field('Boolean'), + date: () => field('Date'), + enum: (values) => field('String', { enum: values }), // enum -> String column at runtime + createdTime: () => field('Date', { assignCreatedTime: true }), + relation: (target, opts) => field('relation', { target, ...opts }), // many-to-one (this table holds FK) + hasMany: (target, opts) => field('relation', { target, ...opts }), // one-to-many (related table holds FK) +}; + +function defineTable(name, shape) { + return { name, shape }; +} + +// The bridge: a defineTable value -> the `table()` factory's options. `registered` +// maps an already-registered defineTable value to its Table class so relationship +// attributes can carry `definition.tableClass` (required for resolution). +function compileToTableOptions(def, { database = 'data', registered } = {}) { + const attributes = []; + const declared = new Set(Object.keys(def.shape)); + const fkColumns = new Set(); + for (const [name, f] of Object.entries(def.shape)) { + if (f.kind === 'relation') { + const targetDef = f.meta.target(); + const targetClass = registered && registered.get(targetDef); + const definition = targetClass ? { tableClass: targetClass } : {}; + if (f.meta.from) { + // many-to-one: this table holds the foreign key + attributes.push({ name, type: targetDef.name, relationship: { from: f.meta.from }, definition }); + if (!declared.has(f.meta.from)) fkColumns.add(f.meta.from); + } else if (f.meta.to) { + // one-to-many: the related table holds the foreign key + attributes.push({ name, relationship: { to: f.meta.to }, elements: { type: targetDef.name, definition } }); + } + continue; + } + const attr = { name, type: f.kind }; + if (f.meta.primaryKey) attr.isPrimaryKey = true; + if (f.meta.indexed) attr.indexed = true; + if (f.meta.nullable) attr.nullable = true; + if (f.meta.assignCreatedTime) attr.assignCreatedTime = true; + attributes.push(attr); + } + // auto-add foreign-key columns implied by many-to-one relations (indexed for join lookups) + for (const fk of fkColumns) attributes.push({ name: fk, indexed: true }); + return { table: def.name, database, attributes }; +} + +const DB = 'codefirst_test'; + +describe('RFC 0001: code-first defineTable -> table() registration', () => { + const Tracks = defineTable('Tracks', { + id: t.id().primaryKey(), + name: t.string().indexed(), + duration: t.int().nullable(), + status: t.enum(['draft', 'published']), + createdAt: t.createdTime(), + }); + let TracksTable; + + before(function () { + setupTestDBPath(); + setMainIsWorker(true); + TracksTable = table(compileToTableOptions(Tracks, { database: DB })); + }); + + it('registers the table in the databases registry', () => { + assert.ok(databases[DB], `database "${DB}" should exist`); + assert.strictEqual(databases[DB].Tracks, TracksTable); + }); + + it('carries the compiled schema as attribute metadata', () => { + assert.strictEqual(TracksTable.primaryKey, 'id'); + const byName = Object.fromEntries(TracksTable.attributes.map((a) => [a.name, a])); + assert.deepStrictEqual(Object.keys(byName).sort(), ['createdAt', 'duration', 'id', 'name', 'status']); + assert.strictEqual(byName.id.isPrimaryKey, true); + assert.strictEqual(byName.name.indexed, true); + assert.strictEqual(byName.status.type, 'String', 'enum compiles to a String column'); + assert.strictEqual(byName.createdAt.assignCreatedTime, true); + }); + + it('supports CRUD, with the server-managed timestamp auto-assigned', async () => { + await TracksTable.put({ id: 'intro', name: 'Intro', status: 'draft' }); + const got = await TracksTable.get('intro'); + assert.strictEqual(got.name, 'Intro'); + assert.strictEqual(got.status, 'draft'); + assert.ok(got.createdAt != null, 'createdAt auto-assigned'); + + await TracksTable.put({ id: 'intro', name: 'Intro (remastered)', status: 'published' }); + assert.strictEqual((await TracksTable.get('intro')).name, 'Intro (remastered)'); + + await TracksTable.delete('intro'); + assert.ok((await TracksTable.get('intro')) == null, 'record gone after delete'); + }); + + it('applies a schema change (added attribute) on re-registration — DDL parity', () => { + const Tracks2 = defineTable('Tracks', { + id: t.id().primaryKey(), + name: t.string().indexed(), + duration: t.int().nullable(), + status: t.enum(['draft', 'published']), + createdAt: t.createdTime(), + isrc: t.string().indexed(), // newly added + }); + const TracksTable2 = table(compileToTableOptions(Tracks2, { database: DB })); + assert.strictEqual(TracksTable2, TracksTable, 'same class re-asserted, not duplicated'); + assert.ok( + TracksTable.attributes.map((a) => a.name).includes('isrc'), + 'added attribute present after re-registration' + ); + }); +}); + +describe('RFC 0001: code-first @relationship end-to-end', () => { + const Authors = defineTable('Authors', { + id: t.id().primaryKey(), + name: t.string(), + }); + const Books = defineTable('Books', { + id: t.id().primaryKey(), + title: t.string(), + author: t.relation(() => Authors, { from: 'authorId' }), // many-to-one + }); + + const registered = new Map(); + let AuthorsTable, BooksTable; + + before(function () { + setupTestDBPath(); + setMainIsWorker(true); + // Register the target first so the relationship attribute can carry definition.tableClass. + AuthorsTable = table(compileToTableOptions(Authors, { database: DB, registered })); + registered.set(Authors, AuthorsTable); + BooksTable = table(compileToTableOptions(Books, { database: DB, registered })); + registered.set(Books, BooksTable); + }); + + it('compiles a relation into a relationship attribute + auto-added indexed FK column', () => { + const byName = Object.fromEntries(BooksTable.attributes.map((a) => [a.name, a])); + assert.ok(byName.author, 'relationship attribute present'); + assert.strictEqual(byName.author.type, 'Authors', 'relationship targets the related type'); + assert.deepStrictEqual(byName.author.relationship, { from: 'authorId' }); + assert.strictEqual(byName.author.definition.tableClass, AuthorsTable, 'definition links the related class'); + assert.ok(byName.authorId, 'foreign-key column auto-added'); + assert.strictEqual(byName.authorId.indexed, true, 'FK column indexed for join lookups'); + }); + + it('resolves the related record through the relationship', async () => { + await AuthorsTable.put({ id: 'a1', name: 'Ada' }); + await BooksTable.put({ id: 'b1', title: 'On Computation', authorId: 'a1' }); + + const context = {}; + const book = await BooksTable.get('b1', context); + assert.strictEqual(book.title, 'On Computation'); + assert.strictEqual(book.authorId, 'a1', 'foreign key round-trips'); + + const author = await book.author; // relationship resolves to the Author record + assert.ok(author, 'relationship resolved'); + assert.strictEqual(author.name, 'Ada'); + }); +}); From 7f66b7269278057b66ed655af4e7eff328c1238d Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 30 Jun 2026 08:31:22 -0600 Subject: [PATCH 07/11] =?UTF-8?q?docs(rfc-0001):=20canonical=20single-Trac?= =?UTF-8?q?k=20spike=20=E2=80=94=20all=20verb=20shapes=20inferred=20from?= =?UTF-8?q?=20one=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the singular-vs-plural thread: the dev names ONE `Track`. It stays the central type — `type Track = InstanceType` 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 --- .../rfcs/spikes/0001/canonical-track.spike.ts | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/rfcs/spikes/0001/canonical-track.spike.ts diff --git a/docs/rfcs/spikes/0001/canonical-track.spike.ts b/docs/rfcs/spikes/0001/canonical-track.spike.ts new file mode 100644 index 0000000000..149f1845b3 --- /dev/null +++ b/docs/rfcs/spikes/0001/canonical-track.spike.ts @@ -0,0 +1,330 @@ +/** + * RFC 0001 — Spike: ONE canonical `Track`, every request/response shape inferred from it. + * + * This answers the singular-vs-plural thread directly. The two positions reconcile: + * + * • The dev names exactly ONE thing — `Track` (singular). It is the table they query + * and write through (`Track.get` / `Track.update` / `Track.put` / `Track.query`), AND + * it is the type of a live record: `let track: Track = Track.update(id)` — `track` is a + * mutable instance with `.save()`, and `track instanceof Track` is true. There is no + * second `Tracks` concept to teach, no when-to-pluralize rule. + * + * • A table nonetheless presents DIFFERENT shapes per verb — what you respond with is not + * what you insert is not what you patch is not what you filter on. Those shapes are not + * separate hand-maintained concepts; they are *projections* inferred from the one `Track` + * definition (include/omit fields automatically), the precedent set by Prisma / Drizzle / + * Kysely. They live AS MEMBERS of the single canonical thing (`Track.$insert`, …), so + * discovery is `Track.` + autocomplete — never a parallel name. + * + * So `Track` itself stays the useful, central type (the live instance); the read-only and + * write-side variants are derived off it: + * + * type Track = InstanceType; // live, mutable, has .save(); instanceof Track + * type TrackRecord = (typeof Track)['$record']; // read-only variant (what get()/query() return) + * type InsertTrack = (typeof Track)['$insert']; // POST body, etc. + * + * Field flags use the property/getter surface from the proposal (`id.primaryKey`, not + * `id.primaryKey()`); only arg-taking builders (`enum([...])`) remain calls. + * + * Public API this spike emulates (defined locally so it type-checks standalone): + * import { defineTable, types } from 'harper'; + * const { id, int, string, date } = types; + * + * Verify (repo settings: strict, NodeNext, erasableSyntaxOnly): + * 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 */ + +// ───────────────────────────────────────────────────────────────────────────── +// Field model — phantom-typed Field. Flags are set via GETTER PROPERTIES (no call), +// so `string.indexed` / `id.primaryKey` / `int.nullable` narrow the type-level flags. +// ───────────────────────────────────────────────────────────────────────────── + +interface Flags { + nullable?: boolean; + primaryKey?: boolean; + serverManaged?: boolean; // @createdTime / @updatedTime / @computed — never client-writable + indexed?: boolean; // queryable in the filter grammar +} + +interface Field { + readonly _ts: TS; // phantom carriers (never present at runtime) + readonly _flags: F; + readonly nullable: Field; + readonly indexed: Field; + readonly primaryKey: Field; +} + +/** Date carries the server-managed timestamp flags in addition to the base getters. */ +interface DateField extends Field { + readonly createdTime: Field; + readonly updatedTime: Field; +} + +// Runtime builder — getters return FRESH fields (immutable chaining; no shared-state bug). +function makeField(kind: string, meta: Record = {}): any { + return { + kind, + meta, + get nullable() { + return makeField(kind, { ...meta, nullable: true }); + }, + get indexed() { + return makeField(kind, { ...meta, indexed: true }); + }, + get primaryKey() { + return makeField(kind, { ...meta, primaryKey: true }); + }, + }; +} +function makeDateField(meta: Record = {}): any { + return { + kind: 'Date', + meta, + get nullable() { + return makeField('Date', { ...meta, nullable: true }); + }, + get indexed() { + return makeField('Date', { ...meta, indexed: true }); + }, + get primaryKey() { + return makeField('Date', { ...meta, primaryKey: true }); + }, + get createdTime() { + return makeField('Date', { ...meta, assignCreatedTime: true, serverManaged: true }); + }, + get updatedTime() { + return makeField('Date', { ...meta, assignUpdatedTime: true, serverManaged: true }); + }, + }; +} + +// The `types` namespace the dev destructures. Pure-flag builders are values (getter chains); +// only arg-taking builders (enum) stay functions. +const types = { + id: makeField('ID') as Field, + string: makeField('String') as Field, + int: makeField('Int') as Field, + float: makeField('Float') as Field, + boolean: makeField('Boolean') as Field, + date: makeDateField() as DateField, + enum: ((values: E) => makeField('String', { enum: values }) as Field), +}; + +type Shape = Record>; + +// ───────────────────────────────────────────────────────────────────────────── +// Flag predicates +// ───────────────────────────────────────────────────────────────────────────── + +type TsOf = F extends Field ? TS : never; +type FlagsOf = F extends Field ? Fl : {}; +type IsNullable = FlagsOf extends { nullable: true } ? true : false; +type IsPrimaryKey = FlagsOf extends { primaryKey: true } ? true : false; +type IsServerManaged = FlagsOf extends { serverManaged: true } ? true : false; +type IsIndexed = FlagsOf extends { indexed: true } ? true : false; + +type Resolve = { [K in keyof T]: T[K] }; + +// ───────────────────────────────────────────────────────────────────────────── +// The projections — every one derived from the SAME shape S. +// nullable field -> optional property +// serverManaged -> present on reads (readonly), absent on all writes +// primaryKey -> required to read back / upsert, optional to insert, never patched +// ───────────────────────────────────────────────────────────────────────────── + +/** The record's data fields: server-managed readonly, others mutable; nullable -> optional. */ +type RecordData = Resolve< + { + readonly [K in keyof S as IsServerManaged extends true ? K : never]: TsOf; + } & { + [K in keyof S as IsServerManaged extends false ? (IsNullable extends false ? K : never) : never]: TsOf< + S[K] + >; + } & { + [K in keyof S as IsServerManaged extends false ? (IsNullable extends true ? K : never) : never]?: TsOf< + S[K] + >; + } +>; + +// A field is client-writable unless the server manages it. +type Writable = { [K in keyof S as IsServerManaged extends false ? K : never]: S[K] }; + +/** INSERT (POST): writable fields; PK OPTIONAL (server can generate it); nullable -> optional. */ +type Insert> = Resolve< + { + [K in keyof W as IsPrimaryKey extends false ? (IsNullable extends false ? K : never) : never]: TsOf< + W[K] + >; + } & { + // PK + nullable fields are optional on insert + [K in keyof W as IsPrimaryKey extends true ? K : IsNullable extends true ? K : never]?: TsOf; + } +>; + +/** UPSERT (PUT): full replace — writable fields, PK REQUIRED (you're naming the row); nullable -> optional. */ +type Upsert> = Resolve< + { + [K in keyof W as IsNullable extends false ? K : never]: TsOf; + } & { + [K in keyof W as IsNullable extends true ? K : never]?: TsOf; + } +>; + +/** PATCH: partial update — every writable non-PK field optional. */ +type Patch> = Resolve<{ + [K in keyof W as IsPrimaryKey extends true ? never : K]?: TsOf; +}>; + +/** QUERY / filter: indexed fields only, all optional (the base filter grammar keys to these). */ +type Query = Resolve<{ + [K in keyof S as IsIndexed extends true ? K : never]?: TsOf; +}>; + +/** The read-only response variant — the whole record, nothing writable, no methods. */ +type ReadVariant = Readonly>; + +/** The LIVE instance — mutable data + persistence methods. This is what `Track` (the type) is. */ +type Methods = { + save(): Promise; + delete(): Promise; +}; +type Instance = RecordData & Methods; + +// ───────────────────────────────────────────────────────────────────────────── +// The single canonical handle. `defineTable` returns ONE value that is: +// • a CONSTRUCTOR — so `new Track()` / `x instanceof Track` work and `Track` doubles as +// the instance type via `InstanceType`; +// • the static verbs (get/query/update/post/put/patch) typed by the projections; +// • phantom `$*` members so each projection is discoverable as `typeof Track['$insert']`. +// Phantom members are type-only (never assigned at runtime). +// ───────────────────────────────────────────────────────────────────────────── + +interface TableStatics { + // phantom projection carriers — discovery surface, zero runtime cost + readonly $record: ReadVariant; + readonly $insert: Insert; + readonly $upsert: Upsert; + readonly $patch: Patch; + readonly $query: Query; + + // reads return the read-only variant; writes return a live instance you can mutate + save() + get(id: string): ReadVariant; + query(where: Query): ReadVariant[]; + update(id: string): Instance; + post(body: Insert): Instance; + put(body: Upsert): Instance; + patch(id: string, changes: Patch): Instance; +} + +// The table value is both a constructor (for instanceof / the instance type) and the statics. +type TableConstructor = TableStatics & (new () => Instance); + +function defineTable(name: string, shape: S): TableConstructor { + return {} as any; // runtime registration lives in defineTable-registration.test.js +} + +// ───────────────────────────────────────────────────────────────────────────── +// The one canonical definition — singular name, property-style field flags. +// ───────────────────────────────────────────────────────────────────────────── + +const { id, int, string, date } = types; + +const Track = defineTable('Track', { + id: id.primaryKey, + name: string.indexed, + duration: int.nullable, + status: types.enum(['draft', 'published']).indexed, // arg-taking builder stays a call, then .indexed + createdTime: date.createdTime, // auto-assigned, never client-writable +}); + +// `Track` stays the central type (the live instance). Variants are derived off it as members. +type Track = InstanceType; // live, mutable, has .save(); `track instanceof Track` +type TrackRecord = (typeof Track)['$record']; // read-only variant (get()/query()) +type InsertTrack = (typeof Track)['$insert']; +type UpsertTrack = (typeof Track)['$upsert']; +type PatchTrack = (typeof Track)['$patch']; +type TrackQuery = (typeof Track)['$query']; + +// ───────────────────────────────────────────────────────────────────────────── +// Usage — one concept (`Track`), the right shape inferred per verb. All green. +// ───────────────────────────────────────────────────────────────────────────── + +const track: Track = Track.update('DtMF'); // live instance +track.name = 'Renamed'; // writable field — mutable +void track.save(); // method present on the instance +const isTrack: boolean = track instanceof Track; // Track is a constructor -> true + +const ro: TrackRecord = Track.get('DtMF'); // read-only variant +ro.name.toUpperCase(); // read ok +ro.createdTime.getFullYear(); // server field present on reads + +const inserted = Track.post({ name: 'Intro', status: 'draft' }); // id generated, createdTime server-set +const upserted = Track.put({ id: 'DtMF', name: 'Intro', status: 'published' }); // PK required for replace +const patched = Track.patch('DtMF', { status: 'published' }); // partial +const published = Track.query({ status: 'published' }); // filter on indexed fields + +// Explicit-typed forms, to show the projections stand alone too: +const toInsert: InsertTrack = { name: 'New Album', status: 'draft', duration: 50 }; // no id, no createdTime +const toReplace: UpsertTrack = { id: 'DtMF', name: 'New Album', status: 'draft' }; +const toPatch: PatchTrack = { duration: 75 }; +const filter: TrackQuery = { name: 'Intro' }; + +// ── Type-level assertions (green tsc == proof) ────────────────────────────── +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +// `Track` (the instance) is mutable on writable fields, readonly on server-managed, has methods. +type _instance_save = Expect, Promise>>; + +// The read-only variant: every field readonly, no methods. +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 // all writable optional, no PK +>; +type _query = Expect< + Equal // indexed fields only (id/duration excluded) +>; + +// ── Negative cases — each MUST error, or tsc fails ────────────────────────── +// @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 +track.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 }; + +export { Track, toInsert, toReplace, toPatch, filter }; +export type { Track as TrackInstance, TrackRecord, InsertTrack, UpsertTrack, PatchTrack, TrackQuery }; From a118bacea59b0311f65f00dbe6e00da1b8401989 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 4 Jul 2026 14:18:27 -0600 Subject: [PATCH 08/11] =?UTF-8?q?docs(rfc-0001):=20enforceSchema=20spike?= =?UTF-8?q?=20=E2=80=94=20subset-typed=20request=20contract,=20no=20third?= =?UTF-8?q?=20request=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/rfcs/spikes/0001/enforce-schema.spike.ts | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/rfcs/spikes/0001/enforce-schema.spike.ts diff --git a/docs/rfcs/spikes/0001/enforce-schema.spike.ts b/docs/rfcs/spikes/0001/enforce-schema.spike.ts new file mode 100644 index 0000000000..b287c730a1 --- /dev/null +++ b/docs/rfcs/spikes/0001/enforce-schema.spike.ts @@ -0,0 +1,326 @@ +/** + * RFC 0001 — Spike: `enforceSchema` — schema-typing as a SUBSET of the existing interface. + * + * This answers the Pillar-2 thread the same way canonical-track.spike.ts answered + * singular-vs-plural. The two positions reconcile: + * + * • NO third request type. A handler under `enforceSchema` receives the SAME + * `RequestTarget` it receives today — structurally NARROWED (`target.id: string` + * when the path says `:id`; `target.get('expand')` typed by the declared query + * schema), never replaced. The built-in query grammar (`conditions`, `sort`, + * `limit`, …) stays reachable on the same object. The schema-typed resource is a + * drop-in, type-compatible subset: assignable wherever the untyped shape goes. + * + * • Discoverability/typo-detection (the `gwt` vs `get` concern) IS solved, twice: + * – a bare object gets it from `satisfies ResourceMethods` — excess-property + * checking flags a misspelled verb at the definition site, zero wrappers; + * – under `enforceSchema`, the contract's verbs are REQUIRED of the impl + * (misspell the impl → "missing `get`") and undeclared extras on an inline + * impl literal are rejected (typo → excess-property error). + * + * Handler shape: verbs take `(target, data)` — the modern `ResourceInterface` / + * converged-signature shape (see the PR-review thread on post/put arg order); `get` + * takes `(target)`. + * + * One deliberate deviation from the review-thread sketch (`enforceSchema(…)`): + * TypeScript has no partial type-argument inference — writing `enforceSchema(…)` + * would force the contract type parameter back to its default and destroy all inference. + * So the record type rides IN the contract as a value (`record: schemaOf()`), + * which keeps the whole call inferred. (The alternative is a curried + * `enforceSchema()(contract, impl)`.) With `defineTable` landed, a table's own + * projections slot straight in here (e.g. `record`/`body` schemas derived from `Track`), + * so the two spikes share one vocabulary. + * + * The type-level narrowing is JUSTIFIED by runtime enforcement: the real + * `enforceSchema` wraps each declared verb to validate/coerce params/query/body + * before dispatch (structured 400s), so by the time a handler runs, the narrowed + * types are true. Same bargain the DOM makes; same role `Table.validate` plays for + * tables. This spike's runtime is intentionally thin — the wrapping lands in the + * implementation PR. + * + * Verify (repo settings: strict, NodeNext, erasableSyntaxOnly): + * 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 */ + +type MaybePromise = T | Promise; + +// ───────────────────────────────────────────────────────────────────────────── +// Stand-ins for the real runtime types (subset of resources/RequestTarget.ts). +// `get` is the URLSearchParams accessor; conditions/sort/limit are the built-in +// query grammar — present here to prove they REMAIN reachable after narrowing. +// ───────────────────────────────────────────────────────────────────────────── + +interface RequestTargetLike { + id?: string | number; + conditions?: unknown[]; + sort?: unknown; + limit?: number; + offset?: number; + get(name: string): string | null; +} + +/** + * The untyped plain-object resource shape — what a component can export today. + * Verbs are METHODS (bivariant), which is what makes the narrowed handlers below + * assignable back to this shape (the same variance rule DOM handlers rely on). + */ +interface ResourceMethods { + path?: string; + get?(target: RequestTargetLike): unknown; + post?(target: RequestTargetLike, data: unknown): unknown; + put?(target: RequestTargetLike, data: unknown): unknown; + patch?(target: RequestTargetLike, data: unknown): unknown; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Path params — template-literal parse of the declared path (from the RFC §5, +// incl. the named-wildcard arm). Matches runtime binding: matched segments are +// Object.assign'ed onto the target, so `:id` lands on `target.id`. +// ───────────────────────────────────────────────────────────────────────────── + +type PathParams = S extends `${string}:${infer P}/${infer Rest}` + ? { [K in P]: string } & PathParams<`/${Rest}`> + : S extends `${string}:${infer P}` + ? { [K in P]: string } + : S extends `${string}*${infer W}` + ? { [K in W extends '' ? 'wildcard' : W]: string } + : {}; + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal schema vocabulary — spike-local phantom carriers. The real +// implementation reuses the `defineTable` field vocabulary / JsonSchemaFragment +// (one vocabulary across table fields, query params, and bodies); `.optional` +// follows the property-getter style from the canonical-track spike. +// ───────────────────────────────────────────────────────────────────────────── + +interface Schema { + readonly _ts: T; // phantom (never present at runtime) + readonly optional: Schema; +} + +function makeSchema(): any { + return { + get optional() { + return makeSchema(); + }, + }; +} + +const s = { + string: makeSchema() as Schema, + number: makeSchema() as Schema, + boolean: makeSchema() as Schema, + enumOf: (values: E) => makeSchema() as Schema, + arrayOf: (item: Schema) => makeSchema() as Schema, +}; + +/** Type-only schema for an existing TS type (a record interface, a defineTable projection, …). */ +function schemaOf(): Schema { + return makeSchema(); +} + +type InferSchema = S extends Schema ? T : never; + +// ───────────────────────────────────────────────────────────────────────────── +// The contract: path + record + per-verb { query?, body?, response? }. +// ───────────────────────────────────────────────────────────────────────────── + +interface VerbSchemas { + readonly query?: Record>; + readonly body?: Schema; + readonly response?: Schema; +} + +interface Contract { + readonly path: string; + /** The resource's record type; defaults `get`'s response and `put`'s body. */ + readonly record?: Schema; + readonly get?: VerbSchemas; + readonly post?: VerbSchemas; + readonly put?: VerbSchemas; + readonly patch?: VerbSchemas; +} + +type VerbName = 'get' | 'post' | 'put' | 'patch'; + +type RecordOf = C extends { record: Schema } ? R : unknown; +type QueryOf = VS extends { query: infer Q } ? { [K in keyof Q]: InferSchema } : {}; +type ResponseOf = VS extends { response: Schema } ? T : R; +type BodyOf = VS extends { body: Schema } ? T : V extends 'put' ? R : unknown; + +// ───────────────────────────────────────────────────────────────────────────── +// The narrowed target — the SAME RequestTarget, with `get` overloaded for the +// declared query params (falling back to plain string|null for anything else) +// and the path params intersected on. Structurally assignable to +// RequestTargetLike, which is what makes the whole thing a subset, not a fork. +// ───────────────────────────────────────────────────────────────────────────── + +interface TypedSearchParams { + get(name: K): Q[K]; + get(name: string): string | null; +} + +type TypedTarget

= Omit & PathParams

& TypedSearchParams; + +type HandlerFor = V extends 'get' + ? (target: TypedTarget>) => MaybePromise> + : (target: TypedTarget>, data: BodyOf) => MaybePromise>; + +/** Every verb the contract declares is REQUIRED of the impl — a misspelled impl verb is a missing-member error. */ +type ImplFor = { + [V in keyof C & VerbName]: HandlerFor>; +}; + +function enforceSchema(contract: C, impl: ImplFor): ImplFor & { path: C['path'] } { + // Implementation PR: wrap each declared verb to validate/coerce params/query/body + // before dispatch (structured 400s) and assert impl.path, if present, matches + // contract.path. That runtime step is what justifies the narrowed types above. + return Object.assign({ path: contract.path }, impl) as ImplFor & { path: C['path'] }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// The running example — a Widget record and its handlers. +// ───────────────────────────────────────────────────────────────────────────── + +interface Widget { + id: string; + name: string; + parts?: string[]; + ownerId?: string; +} +interface NewWidget { + name: string; + parts?: string[]; +} + +declare function loadWidget(id: string, expand: ('parts' | 'owner')[] | undefined): Promise; +declare function createWidget(data: NewWidget): Promise; + +// (1) The untyped baseline — a bare object is already a valid resource, and +// `satisfies` alone catches verb typos (no wrapper needed). Note the casts: the +// untyped world's tax. +const Widget = { + path: '/widget/:id', + async get(target) { + return loadWidget(String(target.id), undefined); + }, + async post(_target, data) { + return createWidget(data as NewWidget); + }, +} satisfies ResourceMethods; + +// (2) The schema-enforced version — same shape, inferred handlers, no casts. +const StrictWidget = enforceSchema( + { + path: '/widget/:id', + record: schemaOf(), + get: { query: { expand: s.arrayOf(s.enumOf(['parts', 'owner'])).optional } }, + post: { body: schemaOf(), response: schemaOf() }, + }, + { + 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); + }, + } +); + +// (3) Wrapping a PRE-EXISTING resource (the review-thread sketch): the untyped +// `Widget` object drops straight in — extra members are fine on a non-literal — +// and callers of `StricterWidget` see the narrowed signatures. +const StricterWidget = enforceSchema({ path: '/widget/:id', record: schemaOf(), get: {}, post: {} }, Widget); + +// (4) Class statics are the same shape — one mechanism covers both idioms. +class WidgetClass { + static path = '/widget/:id'; + static async get(target: RequestTargetLike) { + return loadWidget(String(target.id), undefined); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Type-level assertions — a green tsc run is the proof. +// ───────────────────────────────────────────────────────────────────────────── + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; +type Extends = A extends B ? true : false; + +// THE SUBSET CLAIM — the enforced resources are drop-in replacements for the +// untyped shape (and so is a class with static verbs). No new types demanded +// anywhere downstream. +const asPlain: ResourceMethods = StrictWidget; +type _dropin_strict = Expect>; +type _dropin_wrapped = Expect>; +type _dropin_class = Expect>; + +// The narrowed target: path param typed, declared query param typed, everything +// else falls back to the plain URLSearchParams contract, built-in grammar intact. +type StrictGetTarget = Parameters<(typeof StrictWidget)['get']>[0]; +type _id_narrowed = Expect>; +declare const strictTarget: StrictGetTarget; +const expanded = strictTarget.get('expand'); +type _query_typed = Expect>; +const fallback = strictTarget.get('anything-else'); +type _query_fallback = Expect>; +type _grammar_intact = Expect>; + +// Wildcard paths type too. +type _wildcard = Expect, { path: string }>>; +type _multi = Expect, { id: string } & PathParams<'/rev/:n'>>>; + +// The record default: `get` with no declared response returns the record type. +type _get_response = Expect, MaybePromise>>; + +// ───────────────────────────────────────────────────────────────────────────── +// Negative cases — each MUST be an error, or tsc fails. +// ───────────────────────────────────────────────────────────────────────────── + +// A bare object catches verb typos with `satisfies` alone (the discoverability answer). +// @ts-expect-error 'gwt' is not a resource verb — flagged at the definition site +const TypoWidget = { path: '/widget/:id', async gwt(target: RequestTargetLike) {} } satisfies ResourceMethods; + +// A contract-declared verb missing from the impl (e.g. misspelled) is an error. +const MissingVerb = enforceSchema( + { path: '/w/:id', record: schemaOf(), get: {}, put: {} }, + // @ts-expect-error contract declares `put`; the impl does not provide it + { + async get(target) { + return loadWidget(target.id, undefined); + }, + } +); + +// An impl verb the contract does NOT declare is rejected on an inline literal. +const UndeclaredVerb = enforceSchema( + { path: '/w/:id', record: schemaOf(), get: {} }, + { + async get(target) { + return loadWidget(target.id, undefined); + }, + // @ts-expect-error 'gwt' is not declared in the contract — typo caught + async gwt(target: RequestTargetLike) {}, + } +); + +// A handler that breaks the declared response contract is an error. +const BadResponse = enforceSchema( + { path: '/w/:id', record: schemaOf(), get: { response: schemaOf<{ ok: boolean }>() } }, + { + // @ts-expect-error handler returns Widget; the contract promises { ok: boolean } + async get(target) { + return loadWidget(target.id, undefined); + }, + } +); + +// Keep the runtime values referenced so this type-checks as a module. +export { enforceSchema, s, schemaOf, Widget, StrictWidget, StricterWidget, WidgetClass, asPlain }; +export type { Contract, ImplFor, TypedTarget, PathParams, RequestTargetLike, ResourceMethods }; From 63e7edeb3b711a8f9457de964c9408013352c1cc Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 7 Jul 2026 11:22:37 -0600 Subject: [PATCH 09/11] =?UTF-8?q?feat(resources):=20canonical=20code-first?= =?UTF-8?q?=20schema=20=E2=80=94=20defineTable=20+=20types=20(RFC=200001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../spikes/0001/defineTable-real.check.ts | 126 ++++++ index.ts | 14 + resources/defineTable.ts | 375 ++++++++++++++++++ .../defineTable-registration.test.js | 289 +++++++------- unitTests/resources/jsResource.test.js | 38 ++ 5 files changed, 701 insertions(+), 141 deletions(-) create mode 100644 docs/rfcs/spikes/0001/defineTable-real.check.ts create mode 100644 resources/defineTable.ts diff --git a/docs/rfcs/spikes/0001/defineTable-real.check.ts b/docs/rfcs/spikes/0001/defineTable-real.check.ts new file mode 100644 index 0000000000..7581328115 --- /dev/null +++ b/docs/rfcs/spikes/0001/defineTable-real.check.ts @@ -0,0 +1,126 @@ +/** + * 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; +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 = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = 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 // all writable optional, no PK +>; +type _query = Expect< + Equal // 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, and the implied FK is NOT part of the declared shape. +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 +>; +type _album_insert = Expect>; + +// 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, toInsert, toReplace, toPatch, filter, verbs }; diff --git a/index.ts b/index.ts index 4e55efe576..d84943152f 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,9 @@ if (!workerThreads.isMainThread) { export { RequestTarget } from './resources/RequestTarget.ts'; export { flushDatabases } from './resources/databases.ts'; export { getContext, getResponse, getUser } from './security/jsLoader.ts'; +// Code-first schema authoring (RFC 0001): declare a table as a TypeScript value; the returned +// handle is the live, registered table class with per-verb shapes inferred from the definition. +export { defineTable, types } from './resources/defineTable.ts'; // Type only exports. // Anything exported here will only be available as TypeScript types, not as values. @@ -28,6 +31,17 @@ export type { RecordObject } from './resources/RecordEncoder.ts'; export type { IterableEventQueue } from './resources/IterableEventQueue.ts'; export type { Table } from './resources/databases.ts'; export type { Attribute } from './resources/Table.ts'; +// Code-first schema types: the table handle and field model. Per-verb record shapes are +// discoverable on the handle itself: (typeof Track)['$record' | '$insert' | '$upsert' | '$patch' | '$query']. +export type { + TableHandle, + Field, + DateField, + RelationField, + Shape, + Flags, + DefineTableOptions, +} from './resources/defineTable.ts'; export type { Scope } from './components/Scope.ts'; export type { FilesOption, FilesOptionObject } from './components/deriveGlobOptions.ts'; export type { FileAndURLPathConfig } from './components/Component.ts'; diff --git a/resources/defineTable.ts b/resources/defineTable.ts new file mode 100644 index 0000000000..827b21292c --- /dev/null +++ b/resources/defineTable.ts @@ -0,0 +1,375 @@ +/** + * RFC 0001 — code-first schema authoring (`defineTable` + `types`), the canonical model. + * + * The dev names exactly ONE thing — `Track` (singular). `defineTable` eagerly registers the + * table through the same `table()` factory GraphQL drives (`resources/databases.ts`) and returns + * the live, registered class: `Track.get/put/post/patch/update/query/search` work immediately, + * `new Track()` / `instanceof Track` hold, and every per-verb shape is a *projection* inferred + * from the one definition, discoverable as members (`Track.$record`, `Track.$insert`, …) — never + * a parallel name. Design proven by `docs/rfcs/spikes/0001/canonical-track.spike.ts` (which + * supersedes the earlier t-builder spike). + * + * The anchoring constraint (`--conditions=typestrip`): runtime metadata is carried as values; + * TypeScript types are *derived* from those values. Everything here is erasable syntax — the + * values survive type-stripping, the types are inferred, nothing can drift. + * + * Both authoring front-ends compile to the same typeDef shape (`{ table, database, attributes, + * properties, schemaDefined }`), so DDL/migration semantics are shared rather than reimplemented. + * Relationships take a lazy thunk (`types.relation(() => Album, { from: 'albumId' })`); the + * target class is resolved on first use (the same late-binding contract GraphQL's + * `connectPropertyType` relies on — `definition` must exist at registration, `definition.tableClass` + * only by query time), so forward references and cycles between tables just work. + */ + +import { table, type Table } from './databases.ts'; +import { attributeToFragment } from './jsonSchemaTypes.ts'; + +// ───────────────────────────────────────────────────────────────────────────── +// Field model — phantom-typed Field. Flags are set via GETTER PROPERTIES (no call): +// `string.indexed` / `id.primaryKey` / `date.createdTime` narrow the type-level flags. +// Only arg-taking builders (`enum([...])`, `relation(() => T)`) remain calls. +// ───────────────────────────────────────────────────────────────────────────── + +export interface Flags { + nullable?: boolean; + primaryKey?: boolean; + serverManaged?: boolean; // @createdTime / @updatedTime — never client-writable + indexed?: boolean; // queryable in the filter grammar + relation?: 'one' | 'many'; +} + +/** + * A schema field. `_ts`/`_flags` are phantom type carriers (never present at runtime); the + * flag getters return a fresh field with the flag applied at both the value and type level. + */ +export interface Field { + readonly _ts: TS; + readonly _flags: F; + readonly kind: string; + readonly meta: Record; + readonly nullable: Field; + readonly indexed: Field; + readonly primaryKey: Field; +} + +/** Date additionally carries the server-managed timestamp flags. */ +export interface DateField extends Field { + readonly createdTime: Field; + readonly updatedTime: Field; +} + +/** The read-record type of a related table handle. */ +type RecordOf = T extends { readonly $record: infer R } ? R : never; + +// to-one resolves to the related record; to-many to an array of it. +export type RelationField = Field< + Card extends 'many' ? RecordOf[] : RecordOf, + { relation: Card } +>; + +// Runtime builders — getters return FRESH fields (immutable chaining; no shared-state bug). +function makeField(kind: string, meta: Record = {}): any { + return { + kind, + meta, + get nullable() { + return makeField(kind, { ...meta, nullable: true }); + }, + get indexed() { + return makeField(kind, { ...meta, indexed: true }); + }, + get primaryKey() { + return makeField(kind, { ...meta, primaryKey: true }); + }, + }; +} +function makeDateField(meta: Record = {}): any { + return { + kind: 'Date', + meta, + get nullable() { + return makeField('Date', { ...meta, nullable: true }); + }, + get indexed() { + return makeField('Date', { ...meta, indexed: true }); + }, + get primaryKey() { + return makeField('Date', { ...meta, primaryKey: true }); + }, + get createdTime() { + return makeField('Date', { ...meta, assignCreatedTime: true }); + }, + get updatedTime() { + return makeField('Date', { ...meta, assignUpdatedTime: true }); + }, + }; +} + +/** + * The field vocabulary the dev destructures: `const { id, string, int, date } = types`. + * Pure-flag builders are values (getter chains); only arg-taking builders stay functions. + * Every member mirrors a GraphQL directive/type so the two front-ends stay in lockstep. + */ +export const types = { + id: makeField('ID') as Field, + string: makeField('String') as Field, + int: makeField('Int') as Field, + float: makeField('Float') as Field, + long: makeField('Long') as Field, + boolean: makeField('Boolean') as Field, + date: makeDateField() as DateField, + bytes: makeField('Bytes') as Field, + any: makeField('Any') as Field, + enum: (values: E) => makeField('String', { enum: values }) as Field, + // many-to-one: this table holds the foreign key named `from`. Lazy thunk — forward refs are fine. + relation: (target: () => T, opts: { from: string }) => + makeField('relation', { target, from: opts.from }) as RelationField, + // one-to-many: the related table holds the foreign key named `to`. + hasMany: (target: () => T, opts: { to: string }) => + makeField('relation', { target, to: opts.to }) as RelationField, +}; + +export type Shape = Record>; + +// ───────────────────────────────────────────────────────────────────────────── +// Flag predicates +// ───────────────────────────────────────────────────────────────────────────── + +type TsOf = F extends Field ? TS : never; +type FlagsOf = F extends Field ? Fl : {}; +type IsNullable = FlagsOf extends { nullable: true } ? true : false; +type IsPrimaryKey = FlagsOf extends { primaryKey: true } ? true : false; +type IsServerManaged = FlagsOf extends { serverManaged: true } ? true : false; +type IsIndexed = FlagsOf extends { indexed: true } ? true : false; +type IsRelation = FlagsOf extends { relation: 'one' | 'many' } ? true : false; +/** Server-managed and relation fields are read-only on the record. */ +type IsReadOnly = IsServerManaged extends true ? true : IsRelation; + +// Flatten an intersection of mapped types into one object, preserving readonly/optional modifiers. +type Resolve = { [K in keyof T]: T[K] }; + +// ───────────────────────────────────────────────────────────────────────────── +// The projections — every one derived from the SAME shape S. +// nullable field -> optional property +// serverManaged / relation -> present on reads (readonly), absent on all writes +// primaryKey -> required to read back / upsert, optional to insert, never patched +// ───────────────────────────────────────────────────────────────────────────── + +/** The record's data fields: server-managed/relations readonly, others mutable; nullable -> optional. */ +type RecordData = Resolve< + { + readonly [K in keyof S as IsReadOnly extends true ? K : never]: TsOf; + } & { + [K in keyof S as IsReadOnly extends false ? (IsNullable extends false ? K : never) : never]: TsOf; + } & { + [K in keyof S as IsReadOnly extends false ? (IsNullable extends true ? K : never) : never]?: TsOf; + } +>; + +// A field is client-writable unless the server manages it or it is a relation projection. +type WritableOf = { + [K in keyof S as IsServerManaged extends false ? (IsRelation extends false ? K : never) : never]: S[K]; +}; + +/** INSERT (POST): writable fields; PK OPTIONAL (server can generate it); nullable -> optional. */ +type InsertOf> = Resolve< + { + [K in keyof W as IsPrimaryKey extends false ? (IsNullable extends false ? K : never) : never]: TsOf< + W[K] + >; + } & { + [K in keyof W as IsPrimaryKey extends true ? K : IsNullable extends true ? K : never]?: TsOf; + } +>; + +/** UPSERT (PUT): full replace — writable fields, PK REQUIRED (you're naming the row). */ +type UpsertOf> = Resolve< + { + [K in keyof W as IsNullable extends false ? K : never]: TsOf; + } & { + [K in keyof W as IsNullable extends true ? K : never]?: TsOf; + } +>; + +/** PATCH: partial update — every writable non-PK field optional. */ +type PatchOf> = Resolve<{ + [K in keyof W as IsPrimaryKey extends true ? never : K]?: TsOf; +}>; + +/** QUERY / filter: indexed fields only, all optional (the base filter grammar keys to these). */ +type QueryOf = Resolve<{ + [K in keyof S as IsIndexed extends true ? K : never]?: TsOf; +}>; + +/** The read-only response variant — the whole record, nothing writable. */ +type ReadVariant = Readonly>; + +/** The LIVE instance — what `new Track()` / `Track.update()` yield; writable fields mutable. */ +type InstanceOf = RecordData; + +/** The primary-key property and its value type (falls back to string|number when none declared). */ +type PkKey = { [K in keyof S]: IsPrimaryKey extends true ? K : never }[keyof S]; +type IdOf = [PkKey] extends [never] ? string | number : TsOf]>; + +type MaybePromise = T | Promise; + +// The verbs retyped by the projections. Full query-grammar typing (conditions/sort/select keyed +// to the record) is Pillar 3 — here only inputs/outputs are narrowed. +interface TypedVerbs { + get(id: IdOf, context?: any): MaybePromise | undefined>; + put(record: UpsertOf, context?: any): MaybePromise; + post(record: InsertOf, context?: any): MaybePromise; + patch(id: IdOf, changes: PatchOf, context?: any): MaybePromise; + update(id: IdOf, updates?: PatchOf, context?: any): MaybePromise>; + delete(id: IdOf, context?: any): MaybePromise; + search(query?: any, context?: any): AsyncIterable>; + query(query?: any, context?: any): AsyncIterable>; +} + +/** + * The single canonical handle `defineTable` returns: the registered table class, with + * • the verbs typed by the projections, + * • phantom `$*` members so each projection is discoverable as `(typeof Track)['$insert']`, + * • a construct signature so `Track` doubles as the instance type via `InstanceType`. + */ +export type TableHandle = Omit< + Table, + 'get' | 'put' | 'post' | 'patch' | 'update' | 'delete' | 'search' | 'query' +> & + TypedVerbs & { + // phantom projection carriers — discovery surface, zero runtime cost + readonly $record: ReadVariant; + readonly $insert: InsertOf; + readonly $upsert: UpsertOf; + readonly $patch: PatchOf; + readonly $query: QueryOf; + } & (new (...args: any[]) => InstanceOf); + +// ───────────────────────────────────────────────────────────────────────────── +// Compilation & registration +// ───────────────────────────────────────────────────────────────────────────── + +/** `@table` directive parity — options GraphQL can declare on a type, plus the database. */ +export interface DefineTableOptions { + /** Database (schema) the table lives in; defaults to the `data` database. */ + database?: string; + audit?: boolean; + replicate?: boolean; + sealed?: boolean; + expiration?: number; + eviction?: number; + scanInterval?: number; + splitSegments?: boolean; + trackDeletes?: boolean; + randomAccessFields?: boolean; +} + +function memoize(fn: () => T): () => T { + let value: T; + let resolved = false; + return () => { + if (!resolved) { + value = fn(); + resolved = true; + } + return value; + }; +} + +/** + * Compile a shape into the same `{ table, database, attributes, properties }` typeDef + * `resources/graphql.ts` builds, so registration takes the identical `table()` path. + * + * Nullability parity: GraphQL leaves plain fields' `nullable` undefined and marks `!` fields + * `nullable: false` (it never emits `nullable: true`). We map identically — `.nullable` leaves + * the attribute unmarked, everything else writable is `nullable: false` — so fragments AND + * validation semantics match. PK and server-managed fields stay unmarked (the PK machinery and + * server assignment own their presence; marking them required would reject valid writes). + * + * Relations resolve lazily: `definition` exists at registration (the resolver-build contract in + * Table.ts) but its `tableClass` getter defers the thunk to first use, by which time the target + * is defined — forward references and cycles are safe. + */ +function compileTypeDef(name: string, shape: Shape, options: DefineTableOptions): any { + const attributes: any[] = []; + const properties: Record = {}; + const declared = new Set(Object.keys(shape)); + const fkColumns = new Set(); + + for (const [attrName, f] of Object.entries(shape) as [string, any][]) { + const meta = f.meta ?? {}; + if (f.kind === 'relation') { + const resolveClass = memoize(meta.target as () => any); + const definition = {}; + Object.defineProperty(definition, 'tableClass', { get: resolveClass, enumerable: true, configurable: true }); + let attr: any; + if (meta.from) { + // many-to-one: this table holds the foreign key + attr = { name: attrName, relationship: { from: meta.from } }; + Object.defineProperty(attr, 'type', { + get: () => resolveClass().tableName, + enumerable: true, + configurable: true, + }); + // non-enumerable, mirroring GraphQL's connectPropertyType + Object.defineProperty(attr, 'definition', { value: definition, configurable: true }); + if (!declared.has(meta.from)) fkColumns.add(meta.from); + } else { + // one-to-many: the related table holds the foreign key + const elements: any = {}; + Object.defineProperty(elements, 'type', { + get: () => resolveClass().tableName, + enumerable: true, + configurable: true, + }); + Object.defineProperty(elements, 'definition', { value: definition, configurable: true }); + attr = { name: attrName, type: 'array', relationship: { to: meta.to }, elements }; + } + attributes.push(attr); + // fragment reads the lazy `type`, so project it lazily too + Object.defineProperty(properties, attrName, { + get: () => attributeToFragment(attr), + enumerable: true, + configurable: true, + }); + continue; + } + const attr: any = { name: attrName, type: f.kind }; + if (meta.primaryKey) attr.isPrimaryKey = true; + if (meta.indexed) attr.indexed = true; + if (meta.assignCreatedTime) attr.assignCreatedTime = true; + if (meta.assignUpdatedTime) attr.assignUpdatedTime = true; + if (meta.enum) attr.enum = meta.enum; // enum -> String column, literal set retained for downstream surfaces + if (!meta.nullable && !meta.primaryKey && !meta.assignCreatedTime && !meta.assignUpdatedTime) attr.nullable = false; // required, like GraphQL `!` + attributes.push(attr); + properties[attrName] = attributeToFragment(attr); + } + // auto-add foreign-key columns implied by many-to-one relations (indexed for join lookups) + for (const fk of fkColumns) attributes.push({ name: fk, indexed: true }); + + const { database, ...tableOptions } = options; + // `schemaDefined: true` matches @table so the existing-Table re-assert in `table()` fires on + // every reload (DDL parity: added/removed attributes and index changes are applied). + return { + table: name, + type: name, + database: database ?? 'data', + attributes, + properties, + schemaDefined: true, + ...tableOptions, + }; +} + +/** + * Declare AND register a table from a TypeScript value. Eager: the returned value is the live, + * registered table class (the same class `tables.` holds), typed by the shape — the import + * IS the typed handle. Defining the same table again re-asserts the schema (add/remove attributes, + * index changes) through the same evolution path GraphQL reloads take. + */ +export function defineTable(name: string, shape: S, options: DefineTableOptions = {}): TableHandle { + const typeDef = compileTypeDef(name, shape, options); + const tableClass = table(typeDef); + typeDef.tableClass = tableClass; + return tableClass as TableHandle; +} diff --git a/unitTests/resources/defineTable-registration.test.js b/unitTests/resources/defineTable-registration.test.js index d0264bdb93..f79ab255d0 100644 --- a/unitTests/resources/defineTable-registration.test.js +++ b/unitTests/resources/defineTable-registration.test.js @@ -1,198 +1,205 @@ -// RFC 0001 — code-first schema registration (promoted from docs/rfcs/spikes/0001). +// RFC 0001 — code-first schema: the canonical `defineTable` + `types` model +// (docs/rfcs/spikes/0001/canonical-track.spike.ts is the type-level proof; this is the runtime). // -// Proves that a code-first schema *value* compiles into the options the existing -// `table()` factory consumes (resources/databases.ts) — the same factory GraphQL -// drives and dataLoader.ts already uses — yielding a working table with no GraphQL: -// registry entry, attribute metadata, CRUD, schema evolution, and relationships. -// -// `t` / `defineTable` / `compileToTableOptions` below mirror the future public API -// (the typed twin lives in docs/rfcs/spikes/0001/t-builder.spike.ts). Keeping this -// in the suite guards that `table()` continues to accept the shape that API compiles to. +// `defineTable` eagerly registers through the same `table()` factory GraphQL drives +// (resources/databases.ts) and returns the live table class — the import IS the handle. +// Covered here: registry entry, attribute metadata, canonical properties projection, CRUD, +// schema evolution, relationships via lazy thunks (including a FORWARD reference), and the +// front-end parity guardrail (code-first and GraphQL project identical `properties`). require('../testUtils'); const assert = require('assert'); const { setupTestDBPath } = require('../testUtils'); -const { table, databases } = require('#src/resources/databases'); +const { databases } = require('#src/resources/databases'); +const { loadGQLSchema } = require('#src/resources/graphql'); const { setMainIsWorker } = require('#js/server/threads/manageThreads'); +const { defineTable, types } = require('#src/resources/defineTable'); -// ─── Minimal runtime builder (value shape from the t-builder spike) ────────── - -function field(kind, meta = {}) { - return { - kind, - meta, - nullable() { - return field(kind, { ...meta, nullable: true }); - }, - indexed() { - return field(kind, { ...meta, indexed: true }); - }, - primaryKey() { - return field(kind, { ...meta, primaryKey: true }); - }, - }; -} - -const t = { - id: () => field('ID'), - string: () => field('String'), - int: () => field('Int'), - boolean: () => field('Boolean'), - date: () => field('Date'), - enum: (values) => field('String', { enum: values }), // enum -> String column at runtime - createdTime: () => field('Date', { assignCreatedTime: true }), - relation: (target, opts) => field('relation', { target, ...opts }), // many-to-one (this table holds FK) - hasMany: (target, opts) => field('relation', { target, ...opts }), // one-to-many (related table holds FK) -}; - -function defineTable(name, shape) { - return { name, shape }; -} - -// The bridge: a defineTable value -> the `table()` factory's options. `registered` -// maps an already-registered defineTable value to its Table class so relationship -// attributes can carry `definition.tableClass` (required for resolution). -function compileToTableOptions(def, { database = 'data', registered } = {}) { - const attributes = []; - const declared = new Set(Object.keys(def.shape)); - const fkColumns = new Set(); - for (const [name, f] of Object.entries(def.shape)) { - if (f.kind === 'relation') { - const targetDef = f.meta.target(); - const targetClass = registered && registered.get(targetDef); - const definition = targetClass ? { tableClass: targetClass } : {}; - if (f.meta.from) { - // many-to-one: this table holds the foreign key - attributes.push({ name, type: targetDef.name, relationship: { from: f.meta.from }, definition }); - if (!declared.has(f.meta.from)) fkColumns.add(f.meta.from); - } else if (f.meta.to) { - // one-to-many: the related table holds the foreign key - attributes.push({ name, relationship: { to: f.meta.to }, elements: { type: targetDef.name, definition } }); - } - continue; - } - const attr = { name, type: f.kind }; - if (f.meta.primaryKey) attr.isPrimaryKey = true; - if (f.meta.indexed) attr.indexed = true; - if (f.meta.nullable) attr.nullable = true; - if (f.meta.assignCreatedTime) attr.assignCreatedTime = true; - attributes.push(attr); - } - // auto-add foreign-key columns implied by many-to-one relations (indexed for join lookups) - for (const fk of fkColumns) attributes.push({ name: fk, indexed: true }); - return { table: def.name, database, attributes }; -} - +const { id, string, int, date } = types; const DB = 'codefirst_test'; -describe('RFC 0001: code-first defineTable -> table() registration', () => { - const Tracks = defineTable('Tracks', { - id: t.id().primaryKey(), - name: t.string().indexed(), - duration: t.int().nullable(), - status: t.enum(['draft', 'published']), - createdAt: t.createdTime(), - }); - let TracksTable; +describe('RFC 0001: canonical defineTable — eager registration', () => { + let Track; before(function () { setupTestDBPath(); setMainIsWorker(true); - TracksTable = table(compileToTableOptions(Tracks, { database: DB })); + Track = defineTable( + 'Track', + { + id: id.primaryKey, + name: string.indexed, + duration: int.nullable, + status: types.enum(['draft', 'published']).indexed, + createdAt: date.createdTime, + }, + { database: DB } + ); }); - it('registers the table in the databases registry', () => { + it('returns the live registered class — the handle IS the registry entry', () => { assert.ok(databases[DB], `database "${DB}" should exist`); - assert.strictEqual(databases[DB].Tracks, TracksTable); + assert.strictEqual(databases[DB].Track, Track); + assert.strictEqual(Track.tableName, 'Track'); }); it('carries the compiled schema as attribute metadata', () => { - assert.strictEqual(TracksTable.primaryKey, 'id'); - const byName = Object.fromEntries(TracksTable.attributes.map((a) => [a.name, a])); + assert.strictEqual(Track.primaryKey, 'id'); + const byName = Object.fromEntries(Track.attributes.map((a) => [a.name, a])); assert.deepStrictEqual(Object.keys(byName).sort(), ['createdAt', 'duration', 'id', 'name', 'status']); assert.strictEqual(byName.id.isPrimaryKey, true); assert.strictEqual(byName.name.indexed, true); + assert.strictEqual(byName.name.nullable, false, 'plain field is required, like GraphQL `!`'); + assert.strictEqual(byName.duration.nullable, undefined, '.nullable leaves the attr unmarked, like GraphQL plain'); assert.strictEqual(byName.status.type, 'String', 'enum compiles to a String column'); assert.strictEqual(byName.createdAt.assignCreatedTime, true); + assert.strictEqual(byName.createdAt.nullable, undefined, 'server-managed stays unmarked'); + }); + + it('co-populates the canonical properties Record (same projector as GraphQL)', () => { + const { properties } = Track; + assert.ok(properties, 'Table.properties Record exists'); + assert.strictEqual(properties.id.type, 'string', 'ID → JSON Schema "string"'); + assert.strictEqual(properties.id.primaryKey, true); + assert.strictEqual(properties.duration.type, 'integer', 'Int → JSON Schema "integer"'); + assert.strictEqual(properties.status.type, 'string', 'enum → String → "string"'); + assert.strictEqual(properties.createdAt.assignCreatedTime, true); }); - it('supports CRUD, with the server-managed timestamp auto-assigned', async () => { - await TracksTable.put({ id: 'intro', name: 'Intro', status: 'draft' }); - const got = await TracksTable.get('intro'); + it('supports CRUD through the handle, with the server-managed timestamp auto-assigned', async () => { + await Track.put({ id: 'intro', name: 'Intro', status: 'draft' }); + const got = await Track.get('intro'); assert.strictEqual(got.name, 'Intro'); assert.strictEqual(got.status, 'draft'); assert.ok(got.createdAt != null, 'createdAt auto-assigned'); - await TracksTable.put({ id: 'intro', name: 'Intro (remastered)', status: 'published' }); - assert.strictEqual((await TracksTable.get('intro')).name, 'Intro (remastered)'); + await Track.put({ id: 'intro', name: 'Intro (remastered)', status: 'published' }); + assert.strictEqual((await Track.get('intro')).name, 'Intro (remastered)'); - await TracksTable.delete('intro'); - assert.ok((await TracksTable.get('intro')) == null, 'record gone after delete'); + await Track.delete('intro'); + assert.ok((await Track.get('intro')) == null, 'record gone after delete'); }); - it('applies a schema change (added attribute) on re-registration — DDL parity', () => { - const Tracks2 = defineTable('Tracks', { - id: t.id().primaryKey(), - name: t.string().indexed(), - duration: t.int().nullable(), - status: t.enum(['draft', 'published']), - createdAt: t.createdTime(), - isrc: t.string().indexed(), // newly added - }); - const TracksTable2 = table(compileToTableOptions(Tracks2, { database: DB })); - assert.strictEqual(TracksTable2, TracksTable, 'same class re-asserted, not duplicated'); - assert.ok( - TracksTable.attributes.map((a) => a.name).includes('isrc'), - 'added attribute present after re-registration' + it('re-defining the table applies the schema change (added attribute) — DDL parity', () => { + const Track2 = defineTable( + 'Track', + { + id: id.primaryKey, + name: string.indexed, + duration: int.nullable, + status: types.enum(['draft', 'published']).indexed, + createdAt: date.createdTime, + isrc: string.indexed, // newly added + }, + { database: DB } ); + assert.strictEqual(Track2, Track, 'same class re-asserted, not duplicated'); + assert.ok(Track.attributes.map((a) => a.name).includes('isrc'), 'added attribute present after re-definition'); }); }); -describe('RFC 0001: code-first @relationship end-to-end', () => { - const Authors = defineTable('Authors', { - id: t.id().primaryKey(), - name: t.string(), - }); - const Books = defineTable('Books', { - id: t.id().primaryKey(), - title: t.string(), - author: t.relation(() => Authors, { from: 'authorId' }), // many-to-one - }); - - const registered = new Map(); - let AuthorsTable, BooksTable; +describe('RFC 0001: canonical relationships — lazy thunks, forward reference', () => { + let Book, Author; before(function () { setupTestDBPath(); setMainIsWorker(true); - // Register the target first so the relationship attribute can carry definition.tableClass. - AuthorsTable = table(compileToTableOptions(Authors, { database: DB, registered })); - registered.set(Authors, AuthorsTable); - BooksTable = table(compileToTableOptions(Books, { database: DB, registered })); - registered.set(Books, BooksTable); + // Book references Author BEFORE Author is defined — the thunk defers resolution to first + // use (by which time Author exists), exactly like module-scope forward references. + Book = defineTable( + 'Book', + { + id: id.primaryKey, + title: string, + author: types.relation(() => Author, { from: 'authorId' }), // many-to-one, forward ref + }, + { database: DB } + ); + Author = defineTable( + 'Author', + { + id: id.primaryKey, + name: string, + books: types.hasMany(() => Book, { to: 'authorId' }), // one-to-many, back ref + }, + { database: DB } + ); }); it('compiles a relation into a relationship attribute + auto-added indexed FK column', () => { - const byName = Object.fromEntries(BooksTable.attributes.map((a) => [a.name, a])); + const byName = Object.fromEntries(Book.attributes.map((a) => [a.name, a])); assert.ok(byName.author, 'relationship attribute present'); - assert.strictEqual(byName.author.type, 'Authors', 'relationship targets the related type'); + assert.strictEqual(byName.author.type, 'Author', 'lazy type resolves to the related table name'); assert.deepStrictEqual(byName.author.relationship, { from: 'authorId' }); - assert.strictEqual(byName.author.definition.tableClass, AuthorsTable, 'definition links the related class'); + assert.strictEqual(byName.author.definition.tableClass, Author, 'definition links the related class'); assert.ok(byName.authorId, 'foreign-key column auto-added'); assert.strictEqual(byName.authorId.indexed, true, 'FK column indexed for join lookups'); }); - it('resolves the related record through the relationship', async () => { - await AuthorsTable.put({ id: 'a1', name: 'Ada' }); - await BooksTable.put({ id: 'b1', title: 'On Computation', authorId: 'a1' }); + it('compiles hasMany into an array relationship with lazy element type', () => { + const byName = Object.fromEntries(Author.attributes.map((a) => [a.name, a])); + assert.ok(byName.books, 'hasMany attribute present'); + assert.strictEqual(byName.books.type, 'array'); + assert.deepStrictEqual(byName.books.relationship, { to: 'authorId' }); + assert.strictEqual(byName.books.elements.type, 'Book'); + assert.strictEqual(byName.books.elements.definition.tableClass, Book); + }); - const context = {}; - const book = await BooksTable.get('b1', context); + it('resolves related records in both directions', async () => { + await Author.put({ id: 'a1', name: 'Ada' }); + await Book.put({ id: 'b1', title: 'On Computation', authorId: 'a1' }); + + const book = await Book.get('b1', {}); assert.strictEqual(book.title, 'On Computation'); assert.strictEqual(book.authorId, 'a1', 'foreign key round-trips'); - - const author = await book.author; // relationship resolves to the Author record + const author = await book.author; // forward-referenced relation resolves assert.ok(author, 'relationship resolved'); assert.strictEqual(author.name, 'Ada'); + + const authorRec = await Author.get('a1', {}); + const books = await authorRec.books; // hasMany resolves through the FK index + assert.ok(Array.isArray(books), 'hasMany resolves to an array'); + assert.strictEqual(books.length, 1); + assert.strictEqual(books[0].title, 'On Computation'); + }); +}); + +describe('RFC 0001: code-first ⇔ GraphQL front-end parity', () => { + // The two authoring front-ends must project an identical canonical `properties` Record for an + // equivalent schema — the guardrail against the front-ends silently diverging (RFC §4.2). + // Nullability mapping: code-first plain field ≡ GraphQL `!`; `.nullable` ≡ GraphQL plain. + const FIELDS = ['id', 'name', 'duration', 'status', 'createdAt']; + + before(async function () { + setupTestDBPath(); + setMainIsWorker(true); + defineTable('CodeParity', { + id: id.primaryKey, + name: string.indexed, + duration: int.nullable, + status: types.enum(['draft', 'published']), + createdAt: date.createdTime, + }); // default 'data' database + await loadGQLSchema(` + type GqlParity @table { + id: ID @primaryKey + name: String! @indexed + duration: Int + status: String! + createdAt: Date @createdTime + } + `); + }); + + it('projects the same properties fragment per field from both front-ends', () => { + const code = databases.data.CodeParity.properties; + const gql = databases.data.GqlParity.properties; + for (const field of FIELDS) { + assert.deepStrictEqual( + code[field], + gql[field], + `field "${field}" projects identically from code-first and GraphQL` + ); + } }); }); diff --git a/unitTests/resources/jsResource.test.js b/unitTests/resources/jsResource.test.js index b93f4c36be..02f5becd58 100644 --- a/unitTests/resources/jsResource.test.js +++ b/unitTests/resources/jsResource.test.js @@ -5,6 +5,9 @@ const { writeFileSync } = require('node:fs'); const { join } = require('node:path'); const { tmpdir } = require('node:os'); const { mkdtempSync, rmSync } = require('node:fs'); +const { setupTestDBPath } = require('../testUtils'); +const { setMainIsWorker } = require('#js/server/threads/manageThreads'); +const { defineTable, types } = require('#src/resources/defineTable'); describe('jsResource', () => { let testDir; @@ -125,4 +128,39 @@ describe('jsResource', () => { } ); }); + + it('exposes an exported defineTable handle as an endpoint (RFC 0001)', async () => { + // `defineTable` registers eagerly at import time; the handle is a real table class, so the + // existing export walk exposes it — the code-first analog of GraphQL's @export, and the same + // semantics `export class X extends tables.X {}` has today. No loader special-casing. + setupTestDBPath(); + setMainIsWorker(true); + const Widget = defineTable( + 'Widget', + { id: types.id.primaryKey, name: types.string }, + { database: 'jsresource_codefirst' } + ); + const resourceModule = { Widget }; + + let capturedHandler; + const mockScope = { + handleEntry: spy((handler) => { + capturedHandler = handler; + }), + resources: new Map(), + logger: { warn: spy(), debug: spy(), error: spy() }, + requestRestart: spy(), + import: async () => resourceModule, + }; + + await handleApplication(mockScope); + await capturedHandler({ + entryType: 'file', + eventType: 'add', + absolutePath: join(testDir, 'resources.js'), + urlPath: '/resources.js', + }); + + assert.equal(mockScope.resources.get('/Widget'), Widget, 'exported handle registered at its export name'); + }); }); From 5646065747bfd437609c813c064816d6b7a5fa6e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 7 Jul 2026 11:39:36 -0600 Subject: [PATCH 10/11] fix(defineTable): address cross-model review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../spikes/0001/defineTable-real.check.ts | 19 +++++- resources/defineTable.ts | 62 ++++++++++++------- .../defineTable-registration.test.js | 23 ++++++- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/docs/rfcs/spikes/0001/defineTable-real.check.ts b/docs/rfcs/spikes/0001/defineTable-real.check.ts index 7581328115..3f4a105752 100644 --- a/docs/rfcs/spikes/0001/defineTable-real.check.ts +++ b/docs/rfcs/spikes/0001/defineTable-real.check.ts @@ -73,7 +73,8 @@ type _query = Expect< >; // Relations: to-one resolves to the related read record (readonly), to-many to an array; both -// are dropped from every write projection, and the implied FK is NOT part of the declared shape. +// 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, @@ -85,6 +86,22 @@ type _album_record = Expect< >; type _album_insert = Expect>; +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>; +type _song_query = Expect>; + // Verbs are typed by the projections (await-friendly MaybePromise results). async function verbs() { const ro = await Track.get('DtMF'); diff --git a/resources/defineTable.ts b/resources/defineTable.ts index 827b21292c..99f4c524d8 100644 --- a/resources/defineTable.ts +++ b/resources/defineTable.ts @@ -84,25 +84,18 @@ function makeField(kind: string, meta: Record = {}): any { }; } function makeDateField(meta: Record = {}): any { - return { - kind: 'Date', - meta, - get nullable() { - return makeField('Date', { ...meta, nullable: true }); - }, - get indexed() { - return makeField('Date', { ...meta, indexed: true }); - }, - get primaryKey() { - return makeField('Date', { ...meta, primaryKey: true }); - }, - get createdTime() { - return makeField('Date', { ...meta, assignCreatedTime: true }); + return Object.defineProperties(makeField('Date', meta), { + createdTime: { + get: () => makeField('Date', { ...meta, assignCreatedTime: true }), + enumerable: true, + configurable: true, }, - get updatedTime() { - return makeField('Date', { ...meta, assignUpdatedTime: true }); + updatedTime: { + get: () => makeField('Date', { ...meta, assignUpdatedTime: true }), + enumerable: true, + configurable: true, }, - }; + }); } /** @@ -120,6 +113,9 @@ export const types = { date: makeDateField() as DateField, bytes: makeField('Bytes') as Field, any: makeField('Any') as Field, + // Narrows the TS type to the literal union; stored as a String column. The narrowing is + // advisory at runtime today (the shared Table.validate has no enum case — same as GraphQL); + // the literal set is retained on the attribute for downstream surfaces. enum: (values: E) => makeField('String', { enum: values }) as Field, // many-to-one: this table holds the foreign key named `from`. Lazy thunk — forward refs are fine. relation: (target: () => T, opts: { from: string }) => @@ -294,17 +290,37 @@ function compileTypeDef(name: string, shape: Shape, options: DefineTableOptions) const attributes: any[] = []; const properties: Record = {}; const declared = new Set(Object.keys(shape)); - const fkColumns = new Set(); for (const [attrName, f] of Object.entries(shape) as [string, any][]) { const meta = f.meta ?? {}; if (f.kind === 'relation') { const resolveClass = memoize(meta.target as () => any); + // The definition mirrors what GraphQL's connectPropertyType attaches (the target + // typeDef): `type` and `attributes` feed OpenAPI's includeDefinitionInSchema (so the + // emitted $ref component is always defined), `tableClass` feeds the query-time + // resolvers. All lazy — read only after every table is defined. const definition = {}; Object.defineProperty(definition, 'tableClass', { get: resolveClass, enumerable: true, configurable: true }); + Object.defineProperty(definition, 'type', { + get: () => resolveClass().tableName, + enumerable: true, + configurable: true, + }); + Object.defineProperty(definition, 'attributes', { + get: () => resolveClass().attributes, + enumerable: true, + configurable: true, + }); let attr: any; if (meta.from) { - // many-to-one: this table holds the foreign key + // many-to-one: this table holds the foreign key. Like GraphQL, the FK must be a + // declared field — that makes it typed, projected into `properties`, and writable + // in $insert/$upsert (the relation itself is a read-only projection). + if (!declared.has(meta.from)) { + throw new Error( + `Table "${name}": relation "${attrName}" references foreign key "${meta.from}", which must be declared in the shape (e.g. \`${meta.from}: id.indexed\`)` + ); + } attr = { name: attrName, relationship: { from: meta.from } }; Object.defineProperty(attr, 'type', { get: () => resolveClass().tableName, @@ -313,7 +329,6 @@ function compileTypeDef(name: string, shape: Shape, options: DefineTableOptions) }); // non-enumerable, mirroring GraphQL's connectPropertyType Object.defineProperty(attr, 'definition', { value: definition, configurable: true }); - if (!declared.has(meta.from)) fkColumns.add(meta.from); } else { // one-to-many: the related table holds the foreign key const elements: any = {}; @@ -326,9 +341,10 @@ function compileTypeDef(name: string, shape: Shape, options: DefineTableOptions) attr = { name: attrName, type: 'array', relationship: { to: meta.to }, elements }; } attributes.push(attr); - // fragment reads the lazy `type`, so project it lazily too + // fragment reads the lazy `type`, so project it lazily too (memoized — cold surface) + let fragment: any; Object.defineProperty(properties, attrName, { - get: () => attributeToFragment(attr), + get: () => (fragment ??= attributeToFragment(attr)), enumerable: true, configurable: true, }); @@ -344,8 +360,6 @@ function compileTypeDef(name: string, shape: Shape, options: DefineTableOptions) attributes.push(attr); properties[attrName] = attributeToFragment(attr); } - // auto-add foreign-key columns implied by many-to-one relations (indexed for join lookups) - for (const fk of fkColumns) attributes.push({ name: fk, indexed: true }); const { database, ...tableOptions } = options; // `schemaDefined: true` matches @table so the existing-Table re-assert in `table()` fires on diff --git a/unitTests/resources/defineTable-registration.test.js b/unitTests/resources/defineTable-registration.test.js index f79ab255d0..735d6c16b3 100644 --- a/unitTests/resources/defineTable-registration.test.js +++ b/unitTests/resources/defineTable-registration.test.js @@ -111,6 +111,7 @@ describe('RFC 0001: canonical relationships — lazy thunks, forward reference', { id: id.primaryKey, title: string, + authorId: id.indexed, // the FK must be declared (typed + writable), like GraphQL author: types.relation(() => Author, { from: 'authorId' }), // many-to-one, forward ref }, { database: DB } @@ -126,14 +127,32 @@ describe('RFC 0001: canonical relationships — lazy thunks, forward reference', ); }); - it('compiles a relation into a relationship attribute + auto-added indexed FK column', () => { + it('compiles a relation into a relationship attribute alongside the declared FK column', () => { const byName = Object.fromEntries(Book.attributes.map((a) => [a.name, a])); assert.ok(byName.author, 'relationship attribute present'); assert.strictEqual(byName.author.type, 'Author', 'lazy type resolves to the related table name'); assert.deepStrictEqual(byName.author.relationship, { from: 'authorId' }); assert.strictEqual(byName.author.definition.tableClass, Author, 'definition links the related class'); - assert.ok(byName.authorId, 'foreign-key column auto-added'); + assert.strictEqual(byName.author.definition.type, 'Author', 'definition carries type for OpenAPI components'); + assert.strictEqual(byName.author.definition.attributes, Author.attributes, 'definition carries attributes'); + assert.strictEqual(byName.authorId.type, 'ID', 'declared FK is typed'); assert.strictEqual(byName.authorId.indexed, true, 'FK column indexed for join lookups'); + assert.ok(Book.properties.authorId, 'declared FK projected into the canonical properties Record'); + }); + + it('rejects a relation whose foreign key is not declared in the shape', () => { + assert.throws( + () => + defineTable( + 'Orphan', + { + id: id.primaryKey, + other: types.relation(() => Author, { from: 'otherId' }), // otherId not declared + }, + { database: DB } + ), + /foreign key "otherId", which must be declared/ + ); }); it('compiles hasMany into an array relationship with lazy element type', () => { From 6798056396948455717189d11bf30a6612fa1235 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 7 Jul 2026 13:39:02 -0600 Subject: [PATCH 11/11] =?UTF-8?q?feat(defineTable):=20relationOf/hasManyOf?= =?UTF-8?q?=20=E2=80=94=20escape=20hatch=20for=20mutual-pair=20type=20cycl?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ hasManyOf break the cycle by typing the thunk target () => any (no inference pulled from the argument) and taking the related record shape explicitly via 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) --- .../spikes/0001/defineTable-real.check.ts | 42 ++++++++++++- resources/defineTable.ts | 21 ++++++- .../defineTable-registration.test.js | 60 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/spikes/0001/defineTable-real.check.ts b/docs/rfcs/spikes/0001/defineTable-real.check.ts index 3f4a105752..b76eee1c2d 100644 --- a/docs/rfcs/spikes/0001/defineTable-real.check.ts +++ b/docs/rfcs/spikes/0001/defineTable-real.check.ts @@ -102,6 +102,46 @@ type _song_record = Expect< type _song_insert = Expect>; type _song_query = Expect>; +// ── 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` 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`/`relationOf` 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(() => 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'); @@ -140,4 +180,4 @@ 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, toInsert, toReplace, toPatch, filter, verbs }; +export { Track, Album, Book, Author, toInsert, toReplace, toPatch, filter, verbs, authorName, firstBookTitle }; diff --git a/resources/defineTable.ts b/resources/defineTable.ts index 99f4c524d8..cdfdb90981 100644 --- a/resources/defineTable.ts +++ b/resources/defineTable.ts @@ -18,7 +18,12 @@ * Relationships take a lazy thunk (`types.relation(() => Album, { from: 'albumId' })`); the * target class is resolved on first use (the same late-binding contract GraphQL's * `connectPropertyType` relies on — `definition` must exist at registration, `definition.tableClass` - * only by query time), so forward references and cycles between tables just work. + * only by query time), so forward references and cycles between tables just work AT RUNTIME. + * + * A truly MUTUAL pair (`Book.author` -> `Author`, `Author.books` -> `Book`) is a different problem + * at the TYPE level: TypeScript's const-initializer inference is eager, so `typeof Book` and + * `typeof Author` end up depending on each other and both collapse to `any` (TS7022). `relationOf` + * / `hasManyOf` are the escape hatch — see their doc comments below. */ import { table, type Table } from './databases.ts'; @@ -123,6 +128,20 @@ export const types = { // one-to-many: the related table holds the foreign key named `to`. hasMany: (target: () => T, opts: { to: string }) => makeField('relation', { target, to: opts.to }) as RelationField, + /** + * `relation`'s escape hatch for a MUTUAL pair (`Book.author` <-> `Author.books`): 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) if both sides used `relation`/`hasMany`. + * `relationOf` breaks the cycle — `target` is typed `() => any` (no inference is pulled from + * the argument), and the related RECORD shape is supplied explicitly via ``. Declare a small + * interface for the record once, on whichever side of the pair you close second; the runtime + * behavior (lazy thunk, resolved on first query-time use) is identical to `relation`. + */ + relationOf: (target: () => any, opts: { from: string }) => + makeField('relation', { target, from: opts.from }) as RelationField<{ readonly $record: R }, 'one'>, + /** `hasMany`'s escape hatch for a mutual pair — see `relationOf`. */ + hasManyOf: (target: () => any, opts: { to: string }) => + makeField('relation', { target, to: opts.to }) as RelationField<{ readonly $record: R }, 'many'>, }; export type Shape = Record>; diff --git a/unitTests/resources/defineTable-registration.test.js b/unitTests/resources/defineTable-registration.test.js index 735d6c16b3..be24ee699b 100644 --- a/unitTests/resources/defineTable-registration.test.js +++ b/unitTests/resources/defineTable-registration.test.js @@ -183,6 +183,66 @@ describe('RFC 0001: canonical relationships — lazy thunks, forward reference', }); }); +describe('RFC 0001: relationOf/hasManyOf — mutual-pair escape hatch', () => { + // `relationOf`/`hasManyOf` exist to break a TYPE-level cycle (see + // docs/rfcs/spikes/0001/defineTable-real.check.ts); at RUNTIME they compile through the exact + // same code path as `relation`/`hasMany` (the type param is erased — only the value shape, + // `{ kind: 'relation', meta: { target, from|to } }`, drives compileTypeDef). This proves that + // equivalence: a Publisher<->Edition pair built with the *Of variants resolves identically to + // the plain-variant Book<->Author pair above. + let Edition, Publisher; + + before(function () { + setupTestDBPath(); + setMainIsWorker(true); + Edition = defineTable( + 'Edition', + { + id: id.primaryKey, + name: string, + publisherId: id.indexed, + publisher: types.relationOf(() => Publisher, { from: 'publisherId' }), + }, + { database: DB } + ); + Publisher = defineTable( + 'Publisher', + { + id: id.primaryKey, + name: string, + editions: types.hasManyOf(() => Edition, { to: 'publisherId' }), + }, + { database: DB } + ); + }); + + it('compiles to the same attribute shape as relation/hasMany', () => { + const editionAttr = Edition.attributes.find((a) => a.name === 'publisher'); + assert.strictEqual(editionAttr.type, 'Publisher'); + assert.deepStrictEqual(editionAttr.relationship, { from: 'publisherId' }); + assert.strictEqual(editionAttr.definition.tableClass, Publisher); + + const publisherAttr = Publisher.attributes.find((a) => a.name === 'editions'); + assert.strictEqual(publisherAttr.type, 'array'); + assert.deepStrictEqual(publisherAttr.relationship, { to: 'publisherId' }); + assert.strictEqual(publisherAttr.elements.definition.tableClass, Edition); + }); + + it('resolves related records in both directions', async () => { + await Publisher.put({ id: 'p1', name: 'O.C.' }); + await Edition.put({ id: 'e1', name: 'First', publisherId: 'p1' }); + + const edition = await Edition.get('e1', {}); + const publisher = await edition.publisher; + assert.strictEqual(publisher.name, 'O.C.'); + + const publisherRec = await Publisher.get('p1', {}); + const editions = await publisherRec.editions; + assert.strictEqual(editions.length, 1); + assert.strictEqual(editions[0].name, 'First'); + }); +}); + describe('RFC 0001: code-first ⇔ GraphQL front-end parity', () => { // The two authoring front-ends must project an identical canonical `properties` Record for an // equivalent schema — the guardrail against the front-ends silently diverging (RFC §4.2).