Skip to content

feat(tailordb): derive CRUD input types from field definitions - #1979

Merged
dqn merged 5 commits into
mainfrom
feat/kysely-type
Aug 7, 2026
Merged

feat(tailordb): derive CRUD input types from field definitions#1979
dqn merged 5 commits into
mainfrom
feat/kysely-type

Conversation

@toiroakr

@toiroakr toiroakr commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add TailorDBInsertable / TailorDBSelectable / TailorDBUpdateable to @tailor-platform/sdk/kysely. They accept either a table (typeof myTable) or a bare field collection, so code that is generic over the fields can derive create/read/update inputs instead of re-deriving the population rules per consumer.
  • Export IsReadOnlyDBField and IsAutoFilledDBField from the main entry point, for asking whether callers can never write a single field, or may omit it on create and let the platform fill it in.
  • Give TailorDBField an optional third type parameter carrying a nested object's own fields, so a date or datetime inside db.object() resolves to Timestamp like every other position.
  • Make Serial carry the reason in its insert and update types, so supplying a value for a serial column fails with a message that says why.
  • Flatten the intersections Kysely composes its input types out of, so assignability errors name a shape instead of object & A & B.

Why

Deriving a create input from a field collection cannot be done from the read type alone. A .serial(), .default() or .hooks({ create }) field always has a value once the record exists, yet the caller must not (or need not) supply it on create. Consumers that only had output<F> to work with had to re-derive this per project, and got it wrong in ways that only showed up as a required field the caller could not fill.

Parity with the generated tables

A field resolves to the same column type kyselyTypePlugin writes for it, so both surfaces agree on which keys are required and on what each column reads back as — date / datetime to Timestamp, a nested object to ObjectColumnType, an array of either to ArrayColumnType, .serial() to Serial, .default() and .hooks({ create }) to Generated.

example/tests/kysely-parity.ts pins this against the interfaces the plugin actually generates for the example project, comparing all three projections so that every slot of a column type is read; a change to either mapping fails the example typecheck. The kysely test-d covers the field kinds the example project does not exercise.

Nested objects needed the field types to survive the builder chain. db.object() already infers them but discarded them in its return type, and each builder method dropped them again by returning the two-parameter TailorDBField, so one chained .description() erased the shape. An intersection at the construction site (Omit<TailorDBField<D, O>, "fields"> & { fields: F }) holds the shape but cannot restate it on the way out of a builder method, because nothing there can name F — a type parameter is what lets the input type hand it to the output type.

A nested date/datetime travels as an RFC3339 string inside the JSONB document, with a type hint alongside it that the function runtime uses to rebuild a Date before user code sees the row. That is why Timestamp is the right column type there, and why the nested field types have to survive: derived from the output type alone, a nested datetime is indistinguishable from a hand-written string | Date union and a nested date from a plain string.

Error messages

Supplying a value for a serial column previously reported the column as unknown, which reads as a misspelled field name and never mentions .serial():

error TS2353: Object literal may only specify known properties,
  and 'code' does not exist in type 'object & { name: string; } & { status?: string | undefined; }'.

Assigning to one of these input types now names the cause:

error TS2322: Type 'string' is not assignable to type
  'TypeLevelError<"assigned by .serial(); remove it from the input">'.

The sentence only prints where the marker sits directly in the assignment target. Inside the ValueExpression that Kysely's values() and set() require, TypeScript elides the type argument and it reads TypeLevelError<...> — enough to tell an intentional marker from a typo, but not the reason. This is depth-based, so shortening the message does not help; giving the marker a bare name that survives elision is tracked separately, across its other uses.

Separately, Insertable, Selectable and Updateable are flattened — both the TailorDB* helpers and the ones the generated Namespace exposes. For a table of status (defaulted) and name, a missing-field error now reads:

error TS2741: Property 'name' is missing in type '{}'
  but required in type '{ name: string; status?: string | undefined; }'.

rather than naming object & { name: string; } & { status?: string | undefined; }.

Behavior change

Serial is what the generated table types use, so this reaches Insertable<"MyType"> as well. A serial column becomes an omittable key rather than an absent one:

  • keyof Insertable<Table<"MyType">> now includes the serial column, and it appears in the printed shape of assignability errors for that table.

  • Copying a whole record into a create input is newly rejected, and this is a real bug it was hiding rather than a message change:

    declare const row: Selectable<"Invoice">;
    db.insertInto("Invoice").values({ ...row });  // used to compile

    A spread is not subject to excess property checking, and the old ColumnType<T, never, never> dropped the key from Insertable entirely, so the serial value was carried into the insert unnoticed — which is exactly the case that writes a caller-supplied value and leaves the sequence un-advanced (see Notes).

  • Aside from that spread case, assignments that compiled before still compile; ones that were rejected are still rejected, with a different message.

  • Passing undefined is accepted and means the same as omitting the column — Kysely drops undefined columns from the statement, so nothing is written and the platform assigns the value.

TailorDBField's third type parameter is optional and defaults to the previous widened record, so TailorDBField<Defined, Output> and TailorAnyDBField keep working; db.object() results simply carry a more specific fields type than before.

Notes

  • Blocking a serial value in the type is not cosmetic. The GraphQL mutation input omits serial fields entirely, but the SQL path behind getDB() stores a caller-supplied value as-is and leaves the sequence un-advanced, so a later auto-assigned value can collide with it.
  • Serial is also declared as a string literal in the migration db-types generator, where it is currently unreachable (nothing adds "Serial" to its used-utility-type set). That path is untouched here; migration types still treat a serial column as an ordinary writable column.

Add TailorDBColumns / TailorDBInsertable / TailorDBSelectable /
TailorDBUpdateable to @tailor-platform/sdk/kysely. They accept either a
table (typeof myTable) or a bare field collection, so code that is
generic over the fields can derive create/read/update inputs instead of
re-deriving the population rules per consumer.

Export IsReadOnlyDBField and IsAutoFilledDBField from the main entry
point for asking whether callers can never write a single field, or may
omit it on create and let the platform fill it in.

Serial now carries the reason in its insert and update types, so
supplying a value for a serial column fails with "assigned by .serial();
remove it from the input" instead of "does not exist in type ...", which
read as a misspelled field name. This applies to the generated table
types as well: a serial column becomes an omittable key on Insertable
and Updateable rather than an absent one, so keyof includes it, while
the same values that compiled before still compile.
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4197fff

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@tailor-platform/sdk Minor
@tailor-platform/sdk-plugin-seed Major
@tailor-platform/sdk-plugin-tailordb-erd Major
@tailor-platform/create-sdk Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@tailor-platform/create-sdk

pnpm add https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/create-sdk@4197fff

@tailor-platform/eslint-plugin-sdk

pnpm add https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/eslint-plugin-sdk@4197fff

@tailor-platform/sdk

pnpm add https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk@4197fff

@tailor-platform/sdk-plugin-seed

pnpm add https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk-plugin-seed@4197fff

@tailor-platform/sdk-plugin-tailordb-erd

pnpm add https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk-plugin-tailordb-erd@4197fff

commit: 4197fff

@github-actions

This comment has been minimized.

The column mapping only agreed with kyselyTypePlugin on which keys are
required. The column types themselves diverged: a datetime resolved to
`string | Date` instead of `Timestamp`, a nested object to a plain object
instead of `ObjectColumnType`, and an array of either to a plain array
instead of `ArrayColumnType`. TailorDBSelectable was the worst affected —
a datetime read back as `string | Date`, forcing callers to narrow a
value that is always a Date at runtime.

Map a field through the same type-to-column rules the generator applies,
and pin the two against each other in example/tests/kysely-parity.ts,
which compares TailorDBColumns to the interfaces the plugin actually
generates for that project. The kysely test-d additionally covers the
field kinds the example project does not exercise.

A date or datetime nested inside an object still resolves to
`string | Date`: TailorDBField widens its `fields` to
`Record<string, TailorAnyDBField>`, so a nested field kind is not
recoverable from the table type. The parity test pins that gap so lifting
the erasure surfaces here.

Also apply the intersection flattening to NamespaceInsertable,
NamespaceSelectable and NamespaceUpdateable, so errors from the generated
tables print a flat object shape too rather than only those from the
TailorDB* helpers.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🗺️ ERD preview

No relevant ERD changes in this update.

@github-actions

This comment has been minimized.

A date or datetime declared inside db.object() resolved to `string | Date`
(datetime) or `string` (date), while the runtime hands back a Date there
just as it does at the top level: sqlaccess ships the value as an RFC3339
string inside the JSONB document with a `__field_types__` hint alongside
it, and the function runtime turns it back into a Date and strips the
hint before user code sees the row.

The `string` case was not merely a wider type than necessary — it does
not contain Date, so calling a string method on a nested date compiled
and threw at runtime, while the correct Date call was rejected.

TailorDBField gains an optional third type parameter carrying a nested
object`s own fields. object() already infers them; it just discarded them
in its return type, and every builder method dropped them again by
returning the two-parameter form, so a single chained .description() was
enough to erase the shape. Threading it through the builder chain lets
TailorDBColumns recurse into a nested object and map its props the same
way it maps top-level ones. Writing TailorDBField<Defined, Output> still
works.

The example parity test no longer pins the divergent shape: TailorDBColumns
now equals the generated interface for NestedProfile as a whole.
@github-actions

This comment has been minimized.

The section sat between field extraction and permissions, in the middle of
the table-definition flow a reader follows, and most of it addressed a case
only a shared module hits: a field collection arriving as a type parameter.
Code that names one table already has the generated Insertable, which the
resolver guide covers.

It also duplicated the population rules and the serial error message that
the JSDoc on these types already carries, which the docs-authoring rule
asks not to do.
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@toiroakr
toiroakr marked this pull request as ready for review August 7, 2026 12:50
@toiroakr
toiroakr requested a review from a team as a code owner August 7, 2026 12:50

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📖 Docs Quality & Consistency Check

✅ Docs are consistent with the implementation and contain no user-facing internal-detail leaks.

Checked areas:

  • New public API exports: TailorDBColumns, TailorDBInsertable, TailorDBSelectable, TailorDBUpdateable from @​tailor-platform/sdk/kysely, and IsReadOnlyDBField, IsAutoFilledDBField from @​tailor-platform/sdk
  • JSDoc on exported types: All new exports have appropriate user-facing JSDoc that explains their purpose and usage without leaking internal implementation details
  • Behavior changes: Serial type now includes helpful error messages for type errors; flattened Insertable/Selectable/Updateable types for better error messages
  • Example code: example/tests/kysely-parity.ts uses the new types appropriately in a type-parity test
  • Existing documentation: Verified that packages/sdk/docs/testing.md and packages/sdk/docs/services/resolver.md (which reference Kysely types) remain accurate
  • TailorDB field changes: Added third optional type parameter to TailorDBField for nested object field types — backward compatible with existing two-parameter usage

Notes:

  • The new type helpers are well-documented via JSDoc, which users will see in their IDE
  • The JSDoc correctly references kyselyTypePlugin by name (a public plugin users configure), not as an internal detail
  • Internal comments in non-exported helpers appropriately reference implementation details for maintainer context
  • No user-facing documentation makes claims that would be contradicted by the new types

Re-run this check by adding the docs-check label to the PR.


TailorDBColumns was exported on the theory that someone would want to
compose it further, but nothing does: the only callers are the three
derived input types and the tests written alongside them. It describes how
the mapping is expressed, not something a caller needs to name, so it and
its parameter constraint are now internal.

Both test sites read the mapping through the insert, select and update
projections instead. Between them those read every slot of a ColumnType,
so two maps that agree on all three agree everywhere — verified by
breaking the datetime mapping, which fails all seven parity assertions.

Also fix a wrong label in the parity helper, which reported a selectable
mismatch as "updateable differs", and say in the module comment that this
entry point carries types for hand-written code as well as the ones the
generator emits against.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Code Metrics Report (packages/sdk)

main (3db5abf) #1979 (597dac7) +/-
Coverage 78.8% 78.8% 0.0%
Code to Test Ratio 1:0.4 1:0.4 -0.1
Details
  |                    | main (3db5abf) | #1979 (597dac7) | +/-  |
  |--------------------|----------------|-----------------|------|
  | Coverage           |          78.8% |           78.8% | 0.0% |
  |   Files            |            470 |             470 |    0 |
  |   Lines            |          18537 |           18537 |    0 |
  |   Covered          |          14621 |           14621 |    0 |
- | Code to Test Ratio |          1:0.4 |           1:0.4 | -0.1 |
  |   Code             |         132962 |          133345 | +383 |
  |   Test             |          65568 |           65568 |    0 |

Code coverage of files in pull request scope (95.4% → 95.4%)

Files Coverage +/- Status
packages/sdk/src/configure/services/index.ts 0.0% 0.0% modified
packages/sdk/src/configure/services/tailordb/index.ts 0.0% 0.0% modified
packages/sdk/src/configure/services/tailordb/schema.ts 96.6% 0.0% modified
packages/sdk/src/kysely/index.ts 33.3% 0.0% modified

SDK Configure Bundle Size

main (3db5abf) #1979 (597dac7) +/-
configure-index-size 39.25KB 39.25KB 0KB
dependency-chunks-size 24.96KB 24.96KB 0KB
total-bundle-size 64.22KB 64.22KB 0KB

Runtime Performance

main (3db5abf) #1979 (597dac7) +/-
Generate Median 2,315ms 2,373ms 58ms
Generate Max 2,341ms 2,398ms 57ms
Apply Build Median 2,367ms 2,426ms 59ms
Apply Build Max 2,378ms 2,460ms 82ms

Type Performance (instantiations)

main (3db5abf) #1979 (597dac7) +/-
tailordb-basic 40,450 44,607 4,157
tailordb-optional 4,535 4,758 223
tailordb-relation 3,854 3,945 91
tailordb-validate 666 684 18
tailordb-hooks 5,499 5,823 324
tailordb-object 16,345 13,752 -2,593
tailordb-enum 1,538 1,609 71
resolver-basic 12,120 12,120 0
resolver-nested 35,419 35,419 0
resolver-array 24,056 24,056 0
executor-schedule 4,489 4,489 0
executor-webhook 1,130 1,130 0
executor-record 4,542 4,585 43
executor-resolver 5,808 5,808 0
executor-operation-function 1,118 1,118 0
executor-operation-gql 1,126 1,126 0
executor-operation-webhook 1,137 1,137 0
executor-operation-workflow 1,931 1,931 0

Reported by octocov

@dqn dqn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@dqn
dqn merged commit df07ebe into main Aug 7, 2026
49 checks passed
@dqn
dqn deleted the feat/kysely-type branch August 7, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants