Created
June 22, 2026 15:25
-
-
Save mattrobrob/2ec8d4fd5c68421797a88e4de52cbb44 to your computer and use it in GitHub Desktop.
Zero sync soft-delete filter — injects deletedAt IS NULL into every query AST (top-level, related(), whereExists()) at the server boundary
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
| import { asQueryInternals, queryInternalsTag } from "@rocicorp/zero/bindings"; | |
| import type { AST, Condition, CorrelatedSubquery } from "@rocicorp/zero"; | |
| import type { Schema } from "@vestia/zero/schema"; | |
| /** | |
| * Tables where soft-deleted rows should STILL be synced to the client | |
| * so that child replies can render with "[deleted]" placeholders. | |
| * | |
| * For these tables, the mutator must zero out `body` (and any other | |
| * sensitive fields) at soft-delete time. The client renders "[deleted]" | |
| * when `deletedAt` is set. | |
| * | |
| * Everything NOT in this set gets `deletedAt IS NULL` injected into | |
| * every query (top-level, related subqueries, whereExists subqueries) | |
| * at the AST level — so deleted rows never reach the client. | |
| */ | |
| const ORPHAN_TOLERATE_TABLES = new Set<string>(["post_comments", "question_answer_comments"]); | |
| const DELETED_AT_NULL: Condition = { | |
| type: "simple", | |
| op: "IS", | |
| left: { type: "column", name: "deletedAt" }, | |
| right: { type: "literal", value: null }, | |
| }; | |
| function buildHardHideSet(schema: Schema): Set<string> { | |
| const hardHide = new Set<string>(); | |
| for (const [tableName, tableSchema] of Object.entries(schema.tables)) { | |
| if ("deletedAt" in tableSchema.columns && !ORPHAN_TOLERATE_TABLES.has(tableName)) { | |
| hardHide.add(tableName); | |
| } | |
| } | |
| return hardHide; | |
| } | |
| function andConditions(existing: Condition | undefined, extra: Condition): Condition { | |
| if (!existing) return extra; | |
| return { | |
| type: "and", | |
| conditions: [existing, extra], | |
| }; | |
| } | |
| function injectIntoCondition(cond: Condition, hardHide: Set<string>): Condition { | |
| switch (cond.type) { | |
| case "simple": | |
| return cond; | |
| case "and": | |
| case "or": | |
| return { | |
| ...cond, | |
| conditions: cond.conditions.map((c) => injectIntoCondition(c, hardHide)), | |
| }; | |
| case "correlatedSubquery": | |
| return { | |
| ...cond, | |
| related: { | |
| ...cond.related, | |
| subquery: injectIntoAST(cond.related.subquery, hardHide), | |
| }, | |
| }; | |
| } | |
| } | |
| function injectIntoAST(ast: AST, hardHide: Set<string>): AST { | |
| const shouldFilter = hardHide.has(ast.table); | |
| const where = ast.where ? injectIntoCondition(ast.where, hardHide) : undefined; | |
| const finalWhere = shouldFilter ? andConditions(where, DELETED_AT_NULL) : where; | |
| return { | |
| ...ast, | |
| where: finalWhere, | |
| related: ast.related?.map((r: CorrelatedSubquery) => ({ | |
| ...r, | |
| subquery: injectIntoAST(r.subquery, hardHide), | |
| })), | |
| }; | |
| } | |
| /** | |
| * Creates a filter function that injects `deletedAt IS NULL` into every | |
| * table reference in a query's AST — top-level, `related()` subqueries, | |
| * and `whereExists()` subqueries. | |
| * | |
| * Call this once at module level and use the returned function to wrap | |
| * every query returned from the `handleQueryRequest` callback. | |
| * | |
| * Tables in `ORPHAN_TOLERATE_TABLES` are excluded — their deleted rows | |
| * are still synced so the client can render "[deleted]" placeholders. | |
| */ | |
| export function createSoftDeleteFilter(schema: Schema) { | |
| const hardHide = buildHardHideSet(schema); | |
| return function filterQuery<T>(query: T): T { | |
| const internals = asQueryInternals(query as never); | |
| const filteredAst = injectIntoAST(internals.ast, hardHide); | |
| return { | |
| [queryInternalsTag]: true, | |
| ast: filteredAst, | |
| } as T; | |
| }; | |
| } |
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
| // Usage in your /query handler (e.g. api.ts) | |
| // | |
| // One line at module level to create the filter: | |
| import { createSoftDeleteFilter } from "./soft-delete-filter"; | |
| import { schema } from "@vestia/zero/schema"; | |
| const softDeleteFilter = createSoftDeleteFilter(schema); | |
| // Then wrap every query result before returning it from handleQueryRequest: | |
| const result = await handleQueryRequest( | |
| (name, args) => { | |
| const query = mustGetQuery(queries, name); | |
| return softDeleteFilter(query.fn({ args, ctx: { userId } })); | |
| }, | |
| schema, | |
| c.req.raw, | |
| ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment