feat(gqlize): soft delete — read deleted rows, restore them, gate both - #57
Merged
Conversation
Closes #56. `options: {paranoid: true}` already reached `sequelize.define` untouched, so soft delete *happened* — the root delete mutation goes through instance `destroy()`. Nothing ever set `paranoid` on a *read* options bag, though, so a soft-deleted row was unreachable through GraphQL by any means and there was no root-level way to bring it back. That is the whole of the gap. The read side is a `deleted: GQLTDeletedFilter` argument — EXCLUDE (the default) / INCLUDE / ONLY — on every list field of a model the adapter reports as soft deleting: root lists, nested relationship connections, and each entry in the `include` input. An enum rather than an `includeDeleted: Boolean` because ONLY, the trash view, is otherwise only expressible by naming the `deletedAt` column, which is renameable and so not something a caller can rely on. The flag is applied per *query node*, not per request. A top-level `paranoid: false` does not propagate into includes — confirmed empirically, and the reason `deletedOverlay` is applied at the root, on every include entry, on `countOptions`, and inside `countRelationship`, which builds its own options bag from scratch. Miss one and the failure is a silently wrong answer rather than an error: `total` counting live rows while `edges` return deleted ones. That agreement is what most of the new test file asserts, and the generated SQL was read back to confirm `deletedAt IS NULL` leaves the main query and the JOIN together. The write side is a root `restore` argument reusing the `delete` filter type rather than minting a second identical `[filterType]`. Restore is an *update* everywhere it has to be classified — `scope` operation, the new `beforeRestore` entry in `INSTANCE_HOOKS`, `Events.MUTATION_UPDATE` for `after` — rather than gaining an `Events` member every exhaustive hook would have to learn. That is already this codebase's position in `VERB_OPERATIONS`. Both surfaces are gated build-time, by two new keys: `queryDeleted` and `mutationRestore`. Denied means the element is not in the schema, so a query naming it fails validation rather than at resolution — which keeps the sync/async boundary `permission.test.ts` enforces structurally, and is why the shared fixture gains a paranoid `Memo` model: that test asserts every build-time key is consulted during the fixture build. Turning paranoid on for all or some models needed no new configuration. The Sequelize adapter's `defaultModel` is merged *under* each definition's own options, which is already "global default, per-model override, per-model opt out" — it was simply undocumented, with no test and no mention in docs or examples. So it is documented, pinned by tests, and hardened: `{paranoid: true, timestamps: false}` defines silently and is a lie (Sequelize reports `paranoid === true` and defines no column, so deletes are hard), and it is reachable in normal use because generated join models are defined with `timestamps: false` and would inherit a global `paranoid: true`. Hence `softDeletes` derives from `deletedAtColumn`, which requires both halves; generated join models now pin `paranoid: false`; and `createModel` warns. `_timestampAttributes.deletedAt` is undocumented Sequelize internals and the only source of a renamed column's name, so it gets a canary next to the `define-model.test.ts` one. Adapters opt in: `softDeletes` and `getRestoreFunction` are optional on `OrmAdapter` exactly as `computedOrderableFields` is, so valkey and any third-party adapter need no change and simply generate neither surface. Two pre-existing defects surfaced while building this, both of which restore would have inherited: `update`/`delete`/`select` used `waterfall` — a reduce that threads each step's result into the next — as if it were a map, so a filter matching n rows returned only the *last*, and a zero match returned `[null]` rather than `[]`. The rows written were always correct; only the mutation's own return value was truncated. The existing `delete - multiple` test deleted two rows and asserted a length of one, which is how it survived. Fixed in all four branches, with the three scope tests that asserted `[null]` updated to `[]` and a migration note for callers who read the array. The count-only path — `total` selected without `edges` — rebuilt its options bag from a three-key whitelist, dropping any adapter-specific key that decides which rows match. `M(deleted: ONLY) { total }` answered 0 while the same query with `edges` answered 1. It now derives the bag by removing the keys a count cannot use, so a key it has never heard of survives instead of being silently lost. Out of scope, and recorded in specifications §13: `belongsTo`/`hasOne` fields take only a `required` argument, so a soft-deleted single-relation target still reads as null; `node(id:)` still does not resolve a deleted row; and `update` / `delete` keep their implicit live-rows-only predicate — restore first, then write. Docs: guide §3/§6/§7/§8, specifications §5/§7/§9/§13, a migration entry for each of the new feature and the mutation return shape, and the gqlize README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Azerothian
changed the base branch from
feat/graphql-deprecated-validate-connection
to
main
August 28, 2026 10:31
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.
Closes #56. Stacked on #55 — review that one first; this PR's diff is only the soft-delete work.
The gap
options: {paranoid: true}already reachedsequelize.defineuntouched, so soft delete happened — the root delete mutation goes through instancedestroy(). Nothing ever setparanoidon a read options bag, so a soft-deleted row was unreachable through GraphQL by any means, and there was no root-level way to bring it back.Reading deleted rows
A
deleted: GQLTDeletedFilterargument on every list field of a model the adapter reports as soft-deleting — root lists, nested relationship connections, and each entry in theincludeinput.EXCLUDEINCLUDEONLYAn enum rather than
includeDeleted: BooleanbecauseONLYis otherwise only expressible by naming thedeletedAtcolumn, which is renameable and so not something a caller can rely on.The flag applies per query node, not per request. A top-level
paranoid: falsedoes not propagate into includes — confirmed empirically, and the reason the overlay is applied at the root, on every include entry, oncountOptions, and insidecountRelationship, which builds its own options bag from scratch. Miss one and the failure is a silently wrong answer rather than an error:totalcounting live rows whileedgesreturn deleted ones. The generated SQL was read back to confirmdeletedAt IS NULLleaves the main query and the JOIN together.Restoring
A root
restoreargument reusing thedeletefilter type rather than minting a second identical[filterType]:Restore is an update everywhere it has to be classified —
scopeoperation, the newbeforeRestoreentry inINSTANCE_HOOKS,Events.MUTATION_UPDATEforafter— rather than gaining anEventsmember every exhaustive hook would have to learn. That is already this codebase's position inVERB_OPERATIONS.Permissions
Two new build-time keys,
queryDeletedandmutationRestore. Denied means the element is not in the schema, so a query naming it fails validation rather than at resolution — which keeps the sync/async boundarypermission.test.tsenforces structurally. That test also asserts every build-time key is consulted during the fixture build, which is why the shared fixture gains a paranoidMemomodel.Row-level visibility stays
scope's: a caller allowed deleted rows sees only the deleted rows inside their scope, asserted on both read and restore.Enabling paranoid for all or some models
No new configuration. The Sequelize adapter's
defaultModelis merged under each definition's own options, which is already "global default, per-model override, per-model opt out" — it was simply undocumented, with no test and no mention in docs or examples. So it is documented, pinned by tests, and hardened:{paranoid: true, timestamps: false}defines silently and is a lie — Sequelize reportsparanoid === trueand defines no column, so deletes are hard. Reachable in normal use, because generated join models are defined withtimestamps: falseand would inherit a globalparanoid: true.softDeletestherefore derives fromdeletedAtColumn, which requires both halves; generated join models pinparanoid: false; andcreateModelwarns._timestampAttributes.deletedAtis undocumented Sequelize internals and the only source of a renamed column's name, so it gets a canary next to thedefine-model.test.tsone.Adapters opt in:
softDeletesandgetRestoreFunctionare optional onOrmAdapterexactly ascomputedOrderableFieldsis, so valkey and any third-party adapter need no change and generate neither surface.Two pre-existing defects, both of which restore would have inherited
Multi-row mutations returned one row.
update/delete/selectusedwaterfall— a reduce that threads each step's result into the next — as if it were a map, so a filter matching n rows returned only the last, and a zero match returned[null]rather than[]. The rows written were always correct; only the return value was truncated. The existingdelete - multipletest deleted two rows and asserted a length of one, which is how it survived. Fixed in all four branches; the three scope tests asserting[null]now assert[], with a migration note for callers who read the array.Count-only lost the flag.
totalselected withoutedgesrebuilt its options bag from a three-key whitelist, dropping any adapter-specific key that decides which rows match.M(deleted: ONLY) { total }answered0while the same query withedgesanswered1. It now derives the bag by removing the keys a count cannot use, so a key it has never heard of survives instead of being silently lost.Out of scope
Recorded in specifications §13:
belongsTo/hasOnefields take only arequiredargument, so a soft-deleted single-relation target still reads asnull;node(id:)still does not resolve a deleted row; andupdate/deletekeep their implicit live-rows-only predicate — restore first, then write.Tests
packages/gqlize/__tests__/paranoid.test.ts— 18 cases: all three values at root and nested depth,totalvsedgesagreement including both count-only paths, explicit andseparateincludes, both permission gates, scope interaction on read and restore, delete → restore round-trip.packages/ormize-adapter-sequelize/__tests__/paranoid.test.ts— 11 cases: the_timestampAttributescanary including a renamed column,defaultModelmerge order in both directions, join-model isolation under a global default, the misconfiguration warning.GQLTDeletedFiltersurvives as one shared instance, and a regenerated golden SDL.pnpm build,pnpm typecheck,pnpm test(1505 tests across 9 packages) andpnpm lint --max-warnings 0all pass.Docs
guide §3/§6/§7/§8, specifications §5/§7/§9/§13, a migration entry for each of the new feature and the mutation return shape, and the gqlize README.
🤖 Generated with Claude Code