Skip to content

Instantly share code, notes, and snippets.

@mattrobrob
Created June 22, 2026 15:25
Show Gist options
  • Select an option

  • Save mattrobrob/2ec8d4fd5c68421797a88e4de52cbb44 to your computer and use it in GitHub Desktop.

Select an option

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
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;
};
}
// 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