feat(tailordb,resolver)!: object-literal descriptor API and record-level hooks/validate#905
feat(tailordb,resolver)!: object-literal descriptor API and record-level hooks/validate#905dqn wants to merge 75 commits into
Conversation
…ver descriptor support Add createTable() and timestampFields() as an alternative to the fluent db.type() API for defining TailorDB types using plain object literals. This is a reworked version of the closed PR #645 (createType), renamed to createTable. Extend createResolver() to accept object-literal field descriptors ({ kind: "string" }) alongside the existing fluent t.string() API in both input and output parameters. Fluent and descriptor styles can be mixed freely.
…date decimal scale - Tighten isResolverFieldDescriptor to check kind is a known string value, preventing false positives when output records contain a field named "kind" - Add decimal scale validation (integer 0-12) in createTable to match db.decimal()
…lverFieldMap, add boundary tests - Export KindToFieldType from descriptor.ts, remove duplicate in resolver.ts - Move isTailorField from closure to module-level function - Replace two-pass iteration in resolveResolverFieldMap with single-pass loop - Add decimal scale boundary value tests (0 and 12) for createTable
Add runtime guards so that untyped callers (JS, JSON-driven schemas) get a clear error instead of silently producing fields with undefined type when passing an invalid kind like "strng".
…hook typing trade-off Reject enum descriptors that omit the required `values` array at runtime, preventing permissive fields from being silently created by untyped callers. Document the accepted trade-off that descriptor hook callbacks receive the base scalar type rather than the final output type adjusted for optional/array.
…sthrough fields ValidateHookTypes now checks against DescriptorBaseOutput (base scalar) instead of DescriptorOutput (with array/optional applied), matching the IndexableOptions typing contract. Also reject plain objects without `kind` or `type` that would silently pass through as TailorDBField.
…and metadata Strengthen the passthrough field check to verify both `type` (string) and `metadata` (object) properties, catching plain objects that are neither descriptors nor real field instances. Apply the same guard to both resolver and tailordb descriptor paths.
…vious comments Delegate field resolution in resolveResolverFieldMap to resolveResolverField instead of inlining the same validation logic. Remove self-evident WHAT comments from createResolver and resolveOutput. Also fix pre-existing import order in processOrder.ts test fixture.
The import-x/order rule changed after merging main, making the original order (date-fns before @tailor-platform/sdk) correct again.
🦋 Changeset detectedLatest commit: 532df7f The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
|
Docs Consistency Check No inconsistencies found between documentation and implementation. Checked areas: TailorDB Documentation (packages/sdk/docs/services/tailordb.md):
Resolver Documentation (packages/sdk/docs/services/resolver.md):
CLAUDE.md:
Examples:
Changeset:
Implementation Verification:
Re-run this check by adding the docs-check label to the PR. |
📖 Docs Consistency Check
|
| File | Issue | Suggested Fix |
|---|---|---|
packages/sdk/docs/services/tailordb.md |
Missing createTable() API documentation |
Add section documenting the object-literal descriptor API as an alternative to db.type() |
packages/sdk/docs/services/tailordb.md |
Missing timestampFields() helper |
Document the timestampFields() helper function and its usage |
packages/sdk/docs/services/resolver.md |
Missing descriptor syntax for input/output fields | Add section showing { kind: "string" } syntax alongside fluent API examples |
CLAUDE.md |
Code Patterns section only mentions db.type() |
Add createTable() as an alternative pattern with reference to examples |
example/ directory |
No examples using new APIs | Add at least one example file demonstrating createTable() and resolver descriptor syntax |
Details
1. TailorDB createTable() API (packages/sdk/docs/services/tailordb.md)
What the implementation does:
packages/sdk/src/configure/services/tailordb/createTable.ts:469-504- ExportscreateTable()function that accepts object-literal field descriptors as an alternative to the fluentdb.type()APIpackages/sdk/src/configure/services/index.ts:4- ExportscreateTablefrom the main SDK package- JSDoc example in the implementation shows:
export const user = createTable("User", { name: { kind: "string" }, email: { kind: "string", unique: true }, role: { kind: "enum", values: ["MANAGER", "STAFF"] }, ...timestampFields(), });
What the documentation says:
- The TailorDB documentation only documents
db.type()with the fluent API - No mention of
createTable()anywhere in the docs - The "Type Definition" section (lines 17-61) only shows the
db.type()pattern
Impact:
Users won't know that the object-literal descriptor API exists as an alternative style for defining TailorDB types.
2. TailorDB timestampFields() helper (packages/sdk/docs/services/tailordb.md)
What the implementation does:
packages/sdk/src/configure/services/tailordb/createTable.ts:516-530- ExportstimestampFields()helper that returns standardcreatedAt/updatedAtfields with auto-hookspackages/sdk/src/configure/services/index.ts:5- ExportstimestampFieldsfrom the main SDK package- Can be used with both
createTable()anddb.type()via spread syntax
What the documentation says:
- The docs show
...db.fields.timestamps()(line 357-358) as the pattern for timestamp fields - No mention of
timestampFields()helper
Impact:
Users using createTable() won't know about the timestampFields() helper designed for the descriptor API. The existing db.fields.timestamps() is for the fluent API.
3. Resolver descriptor syntax (packages/sdk/docs/services/resolver.md)
What the implementation does:
packages/sdk/src/configure/services/resolver/descriptor.ts:1-212- ImplementsResolverFieldDescriptortype system supporting{ kind: "string" }syntaxpackages/sdk/src/configure/services/resolver/resolver.ts:79-82- Documents that input/output fields accept both fluent API and object-literal descriptors, and both can be mixed- JSDoc example in resolver.ts:106-116 shows:
input: { a: { kind: "int", description: "First number" }, b: { kind: "int", description: "Second number" }, }, output: { kind: "int", description: "Sum" },
What the documentation says:
packages/sdk/docs/services/resolver.md:84-142- Only shows fluent API examples witht.string(),t.int(), etc.- No mention that descriptor syntax
{ kind: "int" }is supported - No examples mixing both styles
Impact:
Users won't know they can use descriptor syntax in resolvers, which provides a more concise alternative for simple fields and matches the TailorDB createTable() style.
4. CLAUDE.md Code Patterns section
What the implementation does:
- Adds
createTableas a new exported API for defining TailorDB types
What CLAUDE.md says:
- Lines 43: "Model definitions with
db.type()" - only mentions the fluent API - No mention of
createTable()as an alternative pattern
Impact:
Claude Code won't know about the new API when helping users write TailorDB models, and won't suggest it as an option.
5. Example files
What the implementation does:
- Adds fully functional
createTable()and descriptor APIs
What the examples show:
example/tailordb/*.ts- All examples usedb.type()fluent API onlyexample/resolvers/*.ts- All examples uset.string()fluent API only- No examples demonstrate the new descriptor syntax
Impact:
Users learning from examples won't see the descriptor API in action. The example/ directory is specifically referenced in CLAUDE.md as the source for "working implementations of all patterns."
Recommended Actions
-
Add
createTable()section to TailorDB docs - Document the object-literal API with full examples showing all field descriptor types (scalar, enum, object, relations, hooks, validation, etc.) -
Document
timestampFields()helper - Add to TailorDB docs, likely in a "Common Fields" or "Helper Functions" section, showing it works with bothcreateTable()and spread intodb.type() -
Add descriptor syntax section to Resolver docs - Show examples of
{ kind: "string" }syntax for input/output fields, demonstrate mixing with fluent API -
Update CLAUDE.md - Add
createTableto the Code Patterns section as an alternative todb.type(), update the reference to mention both APIs -
Add example files - Create at least one TailorDB type using
createTable()and one resolver using descriptor syntax in theexample/directory
This comment has been minimized.
This comment has been minimized.
Cover pluralForm (string and tuple), description, features, and gqlPermission options that were missing from the test suite.
This comment has been minimized.
This comment has been minimized.
Document the object-literal API (createTable, timestampFields) in tailordb.md and resolver field descriptors in resolver.md. Update CLAUDE.md code patterns to mention both API styles.
Demonstrate the object-literal descriptor API with a Product model that includes enum, relation, timestamps, and permissions.
This is a major issue... |
Descriptor inline hooks now receive the array output type for array fields (e.g. Hook<unknown, string[]> instead of Hook<unknown, string>). - Introduce ScalarOrArrayHooks<O> discriminated union that narrows hooks to Hook<unknown, O> for scalar and Hook<unknown, O[]> for array - Unify ValidatedDescriptors into a single mapped type to avoid combinatorial type explosion with the doubled descriptor union - Compute DescriptorHookOutput directly from field properties instead of intersecting with the FieldDescriptor union - Keep validate callbacks at base scalar type to preserve contextual typing for inline lambdas
…ping TailorAnyDBField in FieldEntry union prevented TypeScript from narrowing FieldDescriptor during generic inference, causing inline hook callbacks to lose contextual typing (value resolved to any). Add a FieldDescriptor-only overload that TypeScript tries first, restoring correct type resolution for inline scalar, array, and datetime hooks.
…nd tests Add tests showing that inline enum descriptor hooks cannot narrow value to the literal union (TS reverse-inference limitation), and document the two working workarounds: fluent API db.enum().hooks() and type-level options.hooks.<field>.
Status: Inline hook typing improvementsWhat changed
Known limitation: inline enum hooksInline enum descriptor hooks ( Working alternatives (both tested):
createTable(
"Test",
{ role: { kind: "enum", values: ["ADMIN", "USER"] } },
{
hooks: {
role: {
create: ({ value }) => {
// value: "ADMIN" | "USER" | null ✅
return value ?? "USER";
},
},
},
},
);
createTable("Test", {
role: db.enum(["ADMIN", "USER"]).hooks({
create: ({ value }) => {
// value: "ADMIN" | "USER" | null ✅
return value ?? "USER";
},
}),
}); |
The warning-tier `field_removed` flow was already re-inserting the underlying FK column so `migrate.ts` could read it, but the forward / backward relationship entries on the type's `TypeConfig.relationships` map were still emitted as removed in the Pre-phase request. A `migrate.ts` that wanted to `innerJoin` through the dropped FK by its relationship name therefore could not resolve it. Add a parallel `relationship_removed` adjuster (`buildPreMigrationRelationshipChangesMap` + `applyPreMigrationRelationshipAdjustments`) that re-inserts the removed relationship in the cloned Pre-phase request, honoring the forward / backward refField / srcField swap used by `toProtoTypeMessage`. The physical drop still happens together with the FK in Post-phase. While in here, fold the three nearly-identical clone-and-apply blocks in `executeSingleMigrationPrePhase` into one helper so field- and relationship-side adjustments are applied uniformly.
…ralForm conflict
- Reject `createTable(["Name","Plural"], …, { pluralForm: "Other" })` at
runtime; previously the tuple silently won.
- Cover descriptor `validate: [fn1, fn2]` (bare predicate array) and
`validate: [[fn1, m1], [fn2, m2]]` (mixed tuple array) for resolver
inputs — the existing coverage stopped at a single tuple.
- Cover `relation.toward.as`, `relation.backward`, and the enum
`[{value, description}]` form on `createTable` so the rawRelation /
allowedValues passthroughs don't regress unnoticed.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ip-only Pre-phase - normalizeComparableTailorDBType now keeps schema.typeValidate, so validator-only changes are surfaced as updates instead of unchanged. - executeSingleMigrationPostPhase now unions field- and relationship-level pre-migration maps so a relationship-only adjustment from Pre-phase is reverted on the platform during Post-phase.
The deploy manifest strips the synthetic `id` field from per-field hooks,
so a hook returning `{ id: ... }` was applied locally (in seeds/tests)
but never reached the platform. Exclude `id` from the public
`RecordHookFn` return type and throw at parse time if it slips through,
so local and deployed behavior stay aligned.
Consolidate repeated `descriptor.array !== true` checks into a local `isArray` flag, derive a single `relation` reference for the uuid relation handling block, and move the enum values validation into a single guarded branch so we no longer test `descriptor.kind === "enum"` twice. Also clarify the overload-1 comment now that field-level inline hooks have moved to record level.
Replace the inline tuple-detection branch in `runRecordValidators` with a dedicated `isRecordValidatorTuple` type-guard so the destructure of `[rawFn, message]` no longer needs five separate `as` casts and the helper-local `FnTuple` type is gone. Behavior is unchanged.
Simplify `isPassthroughField` to a pure `"kind" in entry` check and move the unknown-kind validation into `buildResolverField` so the throw sits beside the kind use (mirroring `createTable.ts:buildField`). Track `hasDescriptor` in `resolveResolverFieldMap` via the same cheap check instead of re-running the full `isResolverFieldDescriptor` predicate per entry, and consolidate the enum descriptor's `values` extraction with its non-empty validation into a single guarded block.
…aths Widen `isResolverFieldDescriptor` to accept `unknown` so callers no longer need an `as ResolverFieldEntry` lie just to satisfy the predicate signature, and drop the residual `as ResolverFieldDescriptor` cast in `resolveResolverField` (the `!isPassthroughField` narrowing already produces that type). The predicate now also rejects null/primitive inputs explicitly, matching the broader `unknown` contract.
…dler The record-level and field-level branches of `collectScriptTargets` repeated the same `Function | [Function, string]` destructure-and-push loop with only the target kind varying. Lift the loop into a `pushValidateTargets(...)` helper so both call sites read as a one-liner and the destructure logic lives in one place.
TS structural typing accepts extra keys on inferred descriptors, so field-level `hooks`/`validate` (removed in this branch) and typos like `uniqe` would compile silently with no effect. Validate descriptor keys against a per-kind allowlist in both `createTable` and resolver field descriptors, and report a path-qualified error.
The previous commit added `as { ... }` casts to satisfy what looked
like an excess-property check, but TS does not actually error on these
extra keys (the same structural typing that necessitates the runtime
check also lets the literal compile cleanly). Use plain literals.
The platform synthesizes a `new Date()` create hook for required generated datetime fields (e.g. `createdAt` from `db.fields.timestamps()` / `timestampFields()`) via `metadata.generated`, but the test/seed helper only consulted `metadata.hooks.create`. Seed records and tests were therefore missing `createdAt`, drifting from deployed behavior. Mirror the synthesis in `createTailorDBHook` so seed/test data matches the platform.
compareRemoteWithSnapshot only compared fields, so a remote whose `type_validate.create`/`update` script was modified or dropped out-of-band would report no drift even when validators in the snapshot no longer matched the platform. Compare the canonical combined expression (shared with the deploy and snapshot-manifest emitters via `buildCombinedTypeValidateExpr`) against both remote scripts and emit a `type_validate_mismatch` drift on disagreement.
`metadata.generated` alone is only honored server-side for datetime fields — the parser synthesizes `new Date()` create/update hooks for generated datetime, but the deploy manifest never sends `generated` to the platform for non-datetime kinds. Previously the kysely type marked any `generated: true` field as Generated<T>, letting inserts omit a column the platform still required and fail at runtime. Restrict Generated<T> to fields with either an explicit create hook or generated datetime.
…en map writes - Drop the duplicate toProtoTypeValidate in deploy/tailordb and route manifest emission through the canonical toProtoSnapshotTypeValidate exported from migrate/snapshot-manifest, so the two callers can no longer drift. - Replace the `map.set(...).get(...) ?? new Map` idiom in buildPreMigrationChangesMap / buildPreMigrationRelationshipChangesMap with a single get-then-set per typeName.
…hema export - Export assertValidDecimalScale from tailordb/schema and route the fluent db.decimal() factory and the createTable decimal descriptor branch through it, so both APIs share one error path. - Extract the repeated outer.get/inner-new-Map pattern in buildPreMigrationChangesMap and buildPreMigrationRelationshipChangesMap into a getOrCreateInnerMap helper. - Extract the shared defineSchema(createStandardSchema(...)) trailer in generateLinesDbSchemaFile and generateLinesDbSchemaFileWithPluginAPI into buildSchemaExportCode.
…onverter The deploy path duplicated the entire snapshot->proto conversion (generateTailorDBTypeManifest re-implemented field/relationship/index/ permission/hooks/validate conversion already covered by generateTailorDBTypeManifestFromSnapshot in migrate/snapshot-manifest). Drop the duplicate body — the deploy wrapper now only resolves publishRecordEvents from the executor set and delegates, which also picks up the snapshot-manifest's stricter conversions (BigInt for serial.start, enum value spread, etc.). Also: merge a duplicated ./diff-calculator import in pre-migration-schema.test.ts and fill in missing JSDoc @param descriptions on compareTypeHooksValidate.
- Introduce compileScriptExpr(fn, argMap) plus a SCRIPT_ARG_MAPS table in parser/tailordb/field.ts covering field, recordHook, and recordValidate binding contexts. convertHookToExpr is now an alias, parseFieldConfig's validate fallback uses it, and type-parser's buildRecordHookFieldExpr / convertRecordValidators drop their own precompiled-or-stringify copies — also fixes the validate fallback to go through stringifyFunction (method-shorthand normalization). - Export convertRelationshipToProto from migrate/snapshot-manifest and delete the duplicated forward/backward switch in applyPreMigrationRelationshipAdjustments. - processNestedFieldsFromSnapshot now delegates to convertFieldConfigToProto and only clears the sub-field-illegal flags afterward, removing the duplicated 50-line branch.
…lidators field.precompiled.test.ts exercised the precompiled-expression mechanism via the removed field-level .hooks()/.validate() APIs. The same lookup now backs compileScriptExpr's recordHook and recordValidate branches but had no test coverage. Assert the precompiled expression flows through to both emitted field-level hooks and wrapped type_validate scripts.
Export SCRIPT_ARG_MAPS from parser/tailordb/field and route the bundler's scriptInvocationArgs through it, so the field-level / record-hook / record-validate argument-map strings live in one place. Drop the now-redundant convertHookToExpr alias — all in-file callers go straight through compileScriptExpr (which defaults argMap to "field").
…position
parseFieldConfig (field-level validate) and convertRecordValidators
(record-level validate) both decomposed validator entries with the
same `typeof v === "function" ? { fn, message: \`failed by ...\` }
: { fn: v[0], message: v[1] }` block. Centralize the normalization in
field.ts so both call sites share one source of truth and drop the
as-cast at the use sites.
…escriptor-api # Conflicts: # packages/sdk/src/cli/commands/deploy/tailordb/index.ts
Code Metrics Report (packages/sdk)
Details | | main (e1c64f7) | #905 (041177c) | +/- |
|--------------------|----------------|----------------|-------|
+ | Coverage | 63.6% | 64.4% | +0.7% |
| Files | 367 | 378 | +11 |
| Lines | 12946 | 13309 | +363 |
+ | Covered | 8241 | 8576 | +335 |
+ | Code to Test Ratio | 1:0.4 | 1:0.4 | +0.0 |
| Code | 85641 | 88727 | +3086 |
+ | Test | 36442 | 38665 | +2223 |Code coverage of files in pull request scope (71.3% → 74.2%)SDK Configure Bundle Size
Runtime Performance
Type Performance (instantiations)
Reported by octocov |
|
Converting to draft to reconsider whether the object-literal API is actually an improvement over the current fluent API before moving forward. |
Add
createTableand resolver field descriptors as an alternative object-literal syntax for defining TailorDB types and resolver input/output fields. Move TailorDB hooks and validators from field level to record level, emitted per-field at the wire layer so override-populated fields are optional in GraphQL inputs.Usage
Main Changes
createTableobject-literal API for TailorDB type definitions with full descriptor support (all scalar kinds, enum, nested object, serial, relation, permissions, indices)ResolverFieldDescriptorfor resolverinput/outputfields, allowing{ kind: "string" }syntax alongside fluentt.string()fields.hooks()and.validate()from TailorDB field builders and descriptors. Configure hooks/validators at the record level via the.hooks(...)/.validate(...)builder methods (or via the thirdoptionsargument ofcreateTable).FieldHookon the affected field, so the platform-generated GraphQLCreateInputtreats those fields as optional.FieldHook(bound to_data); keeprecord-validateas a single type-leveltype_validatereturning a map (bound to_input), per platform contractgeneratedmetadata flag to timestamp fields so the Kysely plugin emitsGenerated<Timestamp>(insert-generated only) and the seed hook auto-fills valuesresolver/descriptor.tsforResolverFieldDescriptorresolution;createTablecarries its ownkindToFieldTypetable aligned with the resolver oneidoverrides in record-level hookstype_validatedrift in the snapshot check, restore removed relationships during the Pre-phase, and tighten migration-script subcommand + deploy loggingProducttype toexample/using the newcreateTableAPI, and regenerate migrations (0004 / 0005) to match the new emit shapeNotes
({ data }) => ({ k1: v1, k2: v2 })) so the override keys can be statically resolved viaoxc-parser. Branched returns, spread (...data), computed keys, getter/setter properties, and method shorthand all throw at parse time — the user must list the overridden keys explicitly with plainkey: valueform.idfield; the deploy manifest stripsidfrom per-field hooks, so a hook returning{ id: ... }is rejected at parse time to keep local and deployed behavior aligned.type_validateis still emitted as a single type-level script returning a map; onlytype_hookwas replaced by per-field hooks.