Skip to content

refactor: consolidate the helpers that were written more than once - #62

Merged
Azerothian merged 1 commit into
mainfrom
refactor/consolidate-helpers
Sep 2, 2026
Merged

refactor: consolidate the helpers that were written more than once#62
Azerothian merged 1 commit into
mainfrom
refactor/consolidate-helpers

Conversation

@Azerothian

Copy link
Copy Markdown
Owner

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?.model appeared across ormize and valkey, and deriveOtherKey existed 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.model with 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 through handling 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 :resource URL segment in one and workflow input in the other — with two properties that are easy to lose in a re-implementation:

  • the null prototype, so constructor / __proto__ / hasOwnProperty don't resolve to inherited members
  • failing closed on a non-string

Now createNameResolver, with those cases pinned by 17 tests. temporalize's stricter typeof !== "string" guard is the one kept.

Delete / restore

getDeleteFunction and getRestoreFunction 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, 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 — 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 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 through variants.

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 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-fixtures package + 12 lines of re-export, annotated throughout so that class of mistake is found once.

The ormize-adapter-sequelize copies are deliberately untouched: they're typed against SequelizeDefinition, 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

  • 1554 tests pass (10 packages now — test-fixtures joins the graph)
  • pnpm typecheck, pnpm lint (--max-warnings 0), and pnpm build all clean
  • Golden schema snapshot unchanged, which is what proves the verb-table refactor is behaviour-preserving

🤖 Generated with Claude Code

https://claude.ai/code/session_019fGumVzMfMXDZ5vS7PJGDW

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
@Azerothian
Azerothian merged commit 4d2e8b9 into main Sep 2, 2026
1 check passed
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.

1 participant