Skip to content

fix(ormize): treat caller-supplied config as read-only - #58

Merged
Azerothian merged 1 commit into
mainfrom
fix/config-object-mutation
Aug 31, 2026
Merged

fix(ormize): treat caller-supplied config as read-only#58
Azerothian merged 1 commit into
mainfrom
fix/config-object-mutation

Conversation

@Azerothian

Copy link
Copy Markdown
Owner

A Definition, and the options bag handed to new Ormize(...), belong to the caller. Ormize read them and then wrote on them, in five separate places.

This is not theoretical — the repo already carried a workaround. ormize-adapter-valkey/__tests__/relations.test.ts rebuilt every definition per test behind a makeDefs() factory, with a comment naming the cause. That factory is gone here, and the suite now runs both adapters off one shared definition set, which is the end-to-end proof.

The rule

Anything ormize did not construct is read-only; where a write is needed, the container is copied first. Functions, class references and DataType tokens are carried by identity, never cloned. structuredClone is not an option — it throws on the first function, and these trees are full of them.

The new @azerothian/utilize/utils/copy-on-write copies exactly the containers a backend is known to write on, and documents which sequelize behaviour forced each level — including the two it deliberately does not copy (options.hooks, options.defaultScope), because sequelize clones those itself.

Build-time

Site What was happening
def.define[f] Passed to sequelize.define by reference on the native-type branch. Sequelize hangs Model (a circular back-reference), fieldName, field, _modelAttribute on every attribute — so a definition stopped being serializable after a build.
def.options.indexes[i] _conformIndex defaults type/parser on; nameIndex stamps a name from the table name, so a reused definition carried the first build's index name.
adapterOptions.defaultAttr Spread into every model, so one shared descriptor was stamped once per model, last writer winning.
rel.options.through.model Overwritten from a model name to a model class — a type lie against its own declaration.
globalHooks[name] Aliased, so addHook pushed into the caller's array. A bare function (which HookMap permits) made it throw.
valkey __join Stamped onto the caller's relationship entry.

The through.model write also silently broke the three readers that correctly expect a name: resolveCrossAdapterJoin (which used the class as an object key), Ormize.deriveOtherKey, and the valkey adapter's own — the latter two stopped matching and fell back to a guessed key.

Request-time

expandComputedIncludeOrder wrote the expanded ordering back onto include descriptors that reach it as a definition's own objects. The first request rewrote the declaration and froze its ordering for the process lifetime, so a context-dependent orderBy never ran twice. Now copy-on-write, mirroring scopeIncludePlan — whose doc comment already stated this rule. Copy-on-write, not copy: expandOrderBy returns its input unchanged when there is nothing to expand, so the ordinary case allocates nothing on a per-parent-row path.

Deliberately not changed

reassertRowScope and the beforeFind scope hooks both write in place onto an options object the backend is about to execute and later hooks re-read — sequelize's runHooks discards return values. Making either copy-on-write would drop the scope and run the query unscoped. Both now carry a comment saying so.

Behaviour change

def.define[f].defaultValue authored as Sequelize.UUIDV4 now stays that token instead of silently becoming an instance.

Verification

Every new test was confirmed to be a real pin, not decoration — the fix was stashed and the suite re-run: 10 of 11 config-purity tests fail without it, and exactly the 2 include-descriptor tests fail without the manager change.

  • 1518 tests pass across all 9 packages
  • pnpm typecheck clean
  • pnpm lint clean at --max-warnings 0

🤖 Generated with Claude Code

https://claude.ai/code/session_019fGumVzMfMXDZ5vS7PJGDW

A `Definition`, and the options bag handed to `new Ormize(...)`, belong to the
caller. Ormize read them and then wrote on them, in five separate places, and
the damage was not theoretical: this repo already carried a workaround for it.
`ormize-adapter-valkey/__tests__/relations.test.ts` rebuilt every definition per
test behind a `makeDefs()` factory, with a comment naming the cause. That
factory is gone in this commit, and the suite now runs both adapters off one
shared definition set — which is the end-to-end proof.

The rule is now stated once: anything ormize did not construct is read-only,
and where a write is needed the container is copied first. Functions, class
references and DataType tokens are carried by identity, never cloned.

Build-time. `def.define[field]` descriptors reached `sequelize.define` by
reference whenever the field used a native Sequelize type — the token branch
already copied, the native one did not. Sequelize rewrites `type`,
`defaultValue` and `references.model` in place and then hangs `Model` (a
*circular* back-reference), `fieldName`, `field` and `_modelAttribute` on every
attribute, so a definition stopped being serializable after a build that used to
work. `options.indexes[i]` had the same shallow-copy gap: `_conformIndex`
defaults `type`/`parser` onto each entry and `nameIndex` stamps a `name` derived
from the table name, so a definition reused against a second table carried the
first one's index name. `adapterOptions.defaultAttr` is spread into every model,
so one shared descriptor was normalised and stamped once per model, last writer
winning.

`relationships[].options.through.model` was overwritten outright, from a model
*name* to a model *class* — a type lie against its own declaration, and the
thing the valkey workaround was written for. It silently broke the three readers
that correctly expect a name: `resolveCrossAdapterJoin` (which used the class as
an object key), `Ormize.deriveOtherKey` and the valkey adapter's own, both of
which stopped matching and fell back to a guessed key. It is now resolved into
the copy the adapter was already building two lines further down.

`globalHooks` arrays were aliased, so `addHook` pushed into the caller's array
and two orms built from one options bag shared a hook list. `HookMap` also
permits a bare function, which `.push` could not take at all — that shape now
normalises to a single-element array instead of throwing.

The valkey adapter stamped `__join` onto the caller's relationship entry;
`ValkeyModel` now owns its own copy, which the read path already goes through.

Request-time. `expandComputedIncludeOrder` wrote the expanded ordering back onto
include descriptors that reach it as a definition's *own* objects — the merge
path hands them through by identity when only one side declares a relation. The
first request therefore rewrote the declaration and froze its ordering for the
life of the process, so a context-dependent `orderBy` never ran twice. It is now
copy-on-write, mirroring `scopeIncludePlan`, whose doc comment already stated
this rule. Copy-on-*write*, not copy: `expandOrderBy` returns its input
unchanged when there is nothing to expand, so the ordinary case still allocates
nothing on what is a per-parent-row path.

Two sites that mutate in place are deliberately left alone, and now say so.
`reassertRowScope` and the `beforeFind` scope hooks both write onto an options
object the backend is about to execute and later hooks re-read — sequelize's
`runHooks` discards return values. Making either copy-on-write would drop the
scope and run the query unscoped, which is the one failure that layer exists to
prevent.

One observable behaviour change worth noting: `def.define[f].defaultValue`
authored as `Sequelize.UUIDV4` now stays that token instead of silently becoming
an instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fGumVzMfMXDZ5vS7PJGDW
@Azerothian
Azerothian merged commit a4bfb24 into main Aug 31, 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