feat(tailordb): derive CRUD input types from field definitions - #1979
Merged
Conversation
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 detectedLatest commit: 4197fff The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 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 |
@tailor-platform/create-sdk
@tailor-platform/eslint-plugin-sdk
@tailor-platform/sdk
@tailor-platform/sdk-plugin-seed
@tailor-platform/sdk-plugin-tailordb-erd
commit: |
This comment has been minimized.
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.
🗺️ ERD previewNo relevant ERD changes in this update. |
This comment has been minimized.
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.
This comment has been minimized.
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.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
toiroakr
force-pushed
the
feat/kysely-type
branch
from
August 5, 2026 05:07
18aa95d to
98e4091
Compare
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
📖 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,TailorDBUpdateablefrom@tailor-platform/sdk/kysely, andIsReadOnlyDBField,IsAutoFilledDBFieldfrom@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:
Serialtype now includes helpful error messages for type errors; flattenedInsertable/Selectable/Updateabletypes for better error messages - Example code:
example/tests/kysely-parity.tsuses the new types appropriately in a type-parity test - Existing documentation: Verified that
packages/sdk/docs/testing.mdandpackages/sdk/docs/services/resolver.md(which reference Kysely types) remain accurate - TailorDB field changes: Added third optional type parameter to
TailorDBFieldfor 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
kyselyTypePluginby 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-checklabel 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.
Code Metrics Report (packages/sdk)
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%)
SDK Configure Bundle Size
Runtime Performance
Type Performance (instantiations)
Reported by octocov |
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TailorDBInsertable/TailorDBSelectable/TailorDBUpdateableto@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.IsReadOnlyDBFieldandIsAutoFilledDBFieldfrom 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.TailorDBFieldan optional third type parameter carrying a nested object's own fields, so a date or datetime insidedb.object()resolves toTimestamplike every other position.Serialcarry the reason in its insert and update types, so supplying a value for a serial column fails with a message that says why.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 hadoutput<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
kyselyTypePluginwrites for it, so both surfaces agree on which keys are required and on what each column reads back as —date/datetimetoTimestamp, a nested object toObjectColumnType, an array of either toArrayColumnType,.serial()toSerial,.default()and.hooks({ create })toGenerated.example/tests/kysely-parity.tspins 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 kyselytest-dcovers 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-parameterTailorDBField, 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 nameF— 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
Datebefore user code sees the row. That is whyTimestampis the right column type there, and why the nested field types have to survive: derived from the output type alone, a nesteddatetimeis indistinguishable from a hand-writtenstring | Dateunion and a nesteddatefrom a plainstring.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():Assigning to one of these input types now names the cause:
The sentence only prints where the marker sits directly in the assignment target. Inside the
ValueExpressionthat Kysely'svalues()andset()require, TypeScript elides the type argument and it readsTypeLevelError<...>— 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,SelectableandUpdateableare flattened — both theTailorDB*helpers and the ones the generatedNamespaceexposes. For a table ofstatus(defaulted) andname, a missing-field error now reads:rather than naming
object & { name: string; } & { status?: string | undefined; }.Behavior change
Serialis what the generated table types use, so this reachesInsertable<"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:
A spread is not subject to excess property checking, and the old
ColumnType<T, never, never>dropped the key fromInsertableentirely, 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
undefinedis 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, soTailorDBField<Defined, Output>andTailorAnyDBFieldkeep working;db.object()results simply carry a more specificfieldstype than before.Notes
getDB()stores a caller-supplied value as-is and leaves the sequence un-advanced, so a later auto-assigned value can collide with it.Serialis 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.