refactor: consolidate the helpers that were written more than once - #62
Merged
Conversation
Net 261 lines out. Each item is a place where the same logic existed twice and
the copies could disagree — in three cases they already had.
Join keys. `typeof through === "string" ? through : through?.model` appeared four
times across ormize and the valkey adapter, and `deriveOtherKey` existed twice
over different registries. `@azerothian/utilize/utils/join-keys` now holds
`throughModelName`, `throughOtherKey` and `reciprocalOtherKey`, the last taking a
relationship list so both callers can pass their own. This one is a correctness
argument as much as a tidiness one: the type those readers depend on has already
been broken once — the sequelize adapter used to overwrite `through.model` with a
live model class, which turned every one of them into a silent no-match that fell
back to a guessed key. There is now one place where "this is a model name" lives.
The sequelize adapter's own `through` handling is deliberately left out; it
resolves the name to a model object because that is what Sequelize wants.
Name resolution. nestize and temporalize each carried a null-prototype map with a
case-insensitive lookup, down to the same comment. That is a security control —
the input is a `:resource` URL segment in one and workflow input in the other, and
an unknown name has to come back `undefined` — with two properties that are easy
to lose in a re-implementation: the null prototype, so `constructor`/`__proto__`
do not resolve to inherited members, and failing closed on a non-string. It lives
once now, as `createNameResolver`, with the prototype-pollution cases pinned by
tests. temporalize's stricter `typeof !== "string"` guard is the one kept.
Delete and restore. `getDeleteFunction` and `getRestoreFunction` in the sequelize
adapter were 45 of 64 identical lines carrying the same warning twice — that the
loop must be serial and awaited, because returning un-awaited promises let callers
run before the writes landed. One `rowVerbFunction` now, with the warning stated
once. Restore also drops its hand-rolled `Op.and` narrowing for the existing
`deletedOverlay(..., "ONLY", ...)`, so a security-adjacent filter has one spelling
instead of two. `buildFind` is a callback rather than an options overlay because
the two verbs compose their find options in a different order — delete puts the
caller's options last, restore first — and normalising that would change which
side wins a key present in both.
Mutation verbs. `create-mutation-input.ts` built the same eight verbs twice, once
per branch of `if (isCollection)`, as ~97 lines of near-identical blocks. Now one
ordered table, which is the shape ormize's `relationship-mutations.ts` already
uses for the engine-side half of the same eight. The point is less the ~50 lines
than that the two lists can now be read against each other: a verb offered by the
schema that the engine does not implement is a field that silently does nothing,
and nothing made that requirement visible before. Field order and every
description string are unchanged, which the golden schema snapshot pins — it
covers these fields 37 times, `through` variants included.
Test fixtures. ormize and gqlize each carried near-identical `task`/`item`/
`task-item` models, 91-96% the same, and they had already drifted: ormize's copy
had a top-level `tableName` that is not a `Definition` field and was therefore
silently ignored — a dead key gqlize's copy had already caught and removed,
because it annotates (`const x: Definition = {...}`) rather than asserts
(`{...} as Definition`) and so gets excess-property checking. 1021 lines become a
private `@azerothian/test-fixtures` package plus 12 lines of re-export, annotated
throughout so that class of mistake is found once.
The `ormize-adapter-sequelize` copies are deliberately untouched. They are typed
against `SequelizeDefinition` rather than `Definition`, and their comments explain
that the direct annotation is what makes nested hooks contextually typed against
the *adapter's* definition type — which is the thing they exist to test.
Also replaces two inline re-implementations of `lowercase()` with the shared one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fGumVzMfMXDZ5vS7PJGDW
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.
Group 4. Net 261 lines out. Each item is a place where the same logic existed twice and the copies could disagree — in three cases they already had.
Join keys
typeof through === "string" ? through : through?.modelappeared 4× across ormize and valkey, andderiveOtherKeyexisted twice over different registries. Now@azerothian/utilize/utils/join-keys.This is a correctness argument as much as a tidiness one: the type those readers depend on has already been broken once — the sequelize adapter used to overwrite
through.modelwith a live model class (fixed in #59), which turned every one of them into a silent no-match that fell back to a guessed key. There is now one place where "this is a model name" lives.The sequelize adapter's own
throughhandling is deliberately left out — it resolves the name to a model object, because that's what Sequelize's association builders want.Name resolution
nestize and temporalize each carried a null-prototype map with case-insensitive lookup, down to the same comment. That's a security control — the input is a
:resourceURL segment in one and workflow input in the other — with two properties that are easy to lose in a re-implementation:constructor/__proto__/hasOwnPropertydon't resolve to inherited membersNow
createNameResolver, with those cases pinned by 17 tests. temporalize's strictertypeof !== "string"guard is the one kept.Delete / restore
getDeleteFunctionandgetRestoreFunctionwere 45 of 64 identical lines, carrying the same warning twice — that the loop must be serial and awaited, because returning un-awaited promises let callers run before the writes landed. OnerowVerbFunctionnow, warning stated once.Restore also drops its hand-rolled
Op.andnarrowing for the existingdeletedOverlay(…, "ONLY", …), so a security-adjacent filter has one spelling instead of two.buildFindis a callback rather than an options overlay because the two verbs compose their find options in a different order — delete puts the caller'soptionslast, restore first — and normalising that would change which side wins a key present in both.Mutation verbs
create-mutation-input.tsbuilt the same eight verbs twice, once per branch ofif (isCollection), as ~97 lines of near-identical blocks. Now one ordered table — the shape ormize'srelationship-mutations.tsalready uses for the engine-side half of the same eight.The point is less the ~50 lines than that the two lists can now be read against each other: a verb offered by the schema that the engine doesn't implement is a field that silently does nothing, and nothing made that requirement visible before.
Field order and every description string are unchanged — pinned by the golden schema snapshot, which covers these fields 37 times including the
throughvariants.Test fixtures
ormize and gqlize each carried near-identical
task/item/task-itemmodels (91-96% the same), and they had already drifted: ormize's copy had a top-leveltableNamethat is not aDefinitionfield and was silently ignored — a dead key gqlize's copy had already caught, because it annotates (const x: Definition = {…}) rather than asserts ({…} as Definition) and so gets excess-property checking.1021 lines → a private
@azerothian/test-fixturespackage + 12 lines of re-export, annotated throughout so that class of mistake is found once.The
ormize-adapter-sequelizecopies are deliberately untouched: they're typed againstSequelizeDefinition, and their comments explain that the direct annotation is what makes nested hooks contextually typed against the adapter's definition type — which is the thing they exist to test.Verification
pnpm typecheck,pnpm lint(--max-warnings 0), andpnpm buildall clean🤖 Generated with Claude Code
https://claude.ai/code/session_019fGumVzMfMXDZ5vS7PJGDW