fix(ormize): treat caller-supplied config as read-only - #58
Merged
Conversation
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
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.
A
Definition, and the options bag handed tonew 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.tsrebuilt every definition per test behind amakeDefs()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.
structuredCloneis not an option — it throws on the first function, and these trees are full of them.The new
@azerothian/utilize/utils/copy-on-writecopies 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
def.define[f]sequelize.defineby reference on the native-type branch. Sequelize hangsModel(a circular back-reference),fieldName,field,_modelAttributeon every attribute — so a definition stopped being serializable after a build.def.options.indexes[i]_conformIndexdefaultstype/parseron;nameIndexstamps anamefrom the table name, so a reused definition carried the first build's index name.adapterOptions.defaultAttrrel.options.through.modelglobalHooks[name]addHookpushed into the caller's array. A bare function (whichHookMappermits) made it throw.__joinThe
through.modelwrite 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
expandComputedIncludeOrderwrote 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-dependentorderBynever ran twice. Now copy-on-write, mirroringscopeIncludePlan— whose doc comment already stated this rule. Copy-on-write, not copy:expandOrderByreturns its input unchanged when there is nothing to expand, so the ordinary case allocates nothing on a per-parent-row path.Deliberately not changed
reassertRowScopeand thebeforeFindscope hooks both write in place onto an options object the backend is about to execute and later hooks re-read — sequelize'srunHooksdiscards 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].defaultValueauthored asSequelize.UUIDV4now 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.
pnpm typecheckcleanpnpm lintclean at--max-warnings 0🤖 Generated with Claude Code
https://claude.ai/code/session_019fGumVzMfMXDZ5vS7PJGDW