The pattern is two halves that share a contract. You will (1) describe your
entities as EntitySpecs, (2) write a CrudStore adapter over your real
database, (3) wire tenant resolution and (optionally) an audit emitter, and
(4) point the frontend store factory at your routes. Nothing here references a
domain, so there is no vocabulary to strip — only seams to fill.
from vue_pinia_crud_store import EntitySpec
ORDER_SPEC = EntitySpec(
resource="orders", # URL segment + audit entity_type + list key
table_name="orders", # SQL table (often == resource)
pk_column="order_id",
columns=(
"order_id", "reference", "status", "total", "metadata",
"created_at", "updated_at",
),
filterable_columns={"reference", "status", "total", "created_at"},
patchable_columns={"reference", "status", "total", "metadata"},
required_create_columns=("reference",),
jsonb_columns=("metadata",),
has_updated_at=True, # False for append-only / event tables
)If you omit filterable_columns it defaults to all columns; if you omit
patchable_columns it defaults to columns minus pk_column / created_at /
updated_at. The two example specs (WIDGET_SPEC, EVENT_SPEC) are concrete
references — copy their shape, not their names.
CrudStore is a small duck-typed interface. The shipped InMemoryStore is the
dependency-free default (great for tests and a local frontend harness). For
production, write an adapter over your driver. A sketch over asyncpg:
from vue_pinia_crud_store import CrudStore, ListPage, FilterSpec, compile_filters
from vue_pinia_crud_store import encode_cursor, decode_cursor
class PgStore(CrudStore):
def __init__(self, pool, spec):
self.pool, self.spec = pool, spec
async def list_rows(self, tenant_id, *, filters, allowed_columns, limit, cursor):
where, params = compile_filters(filters, allowed_columns, table_alias="t")
keyset = decode_cursor(cursor)
clause = ""
if keyset is not None:
ts, rid = keyset
params += [ts, rid]
clause = f" AND (t.created_at, t.{self.spec.pk_column}) < (${len(params)-1}, ${len(params)})"
params.append(limit + 1)
sql = (
f"SELECT {', '.join(self.spec.columns)} FROM {self.spec.table_name} t "
f"WHERE {where}{clause} "
f"ORDER BY t.created_at DESC, t.{self.spec.pk_column} DESC LIMIT ${len(params)}"
)
async with self.pool.acquire() as conn:
await conn.execute("SET LOCAL app.current_tenant_id = $1", tenant_id) # RLS
rows = [dict(r) for r in await conn.fetch(sql, *params)]
has_more = len(rows) > limit
rows = rows[:limit]
nxt = encode_cursor(rows[-1]["created_at"], str(rows[-1][self.spec.pk_column])) if has_more and rows else None
return ListPage(rows=rows, next_cursor=nxt)
# get_row / create_row / patch_row / delete_row / bulk: same shape, using
# build_bulk_statement(...) for the bulk path.Tenant isolation can be explicit (filter by tenant_id) or delegated to
row-level security with a per-connection SET LOCAL (as above). The seam only
requires that one tenant never sees another's rows.
import uuid
from fastapi import Depends, FastAPI
from vue_pinia_crud_store import build_crud_router
async def current_session(authorization: str = ...):
return verify(authorization) # your auth; returns an object/dict
async def audit_emitter(tenant_id, *, entity_type, operation, entity_id, diff, bulk_count):
await write_ledger_row(tenant_id, entity_type, operation, entity_id, diff, bulk_count)
app = FastAPI()
app.include_router(build_crud_router(
ORDER_SPEC, PgStore(pool, ORDER_SPEC),
tenant_resolver=lambda session: session["tenant_id"],
auth_dependency=current_session, # omit → demo X-Tenant-Id header
audit_emitter=audit_emitter, # omit → mutations not audited
id_validator=uuid.UUID, # omit → ids passed through as strings
))Repeat include_router(build_crud_router(SPEC, store, ...)) per entity.
import { createCrudStore } from 'vue-pinia-crud-store/createCrudStore';
export const useOrders = createCrudStore({
resource: 'orders',
idField: 'order_id', // match the backend pk_column
baseUrl: '/api',
headers: { Authorization: `Bearer ${token}` },
// or pass `http` to use an axios-shaped client / inject auth refresh
});You don't have to take the whole stack:
- Use
compile_filters(specs, allowed_columns, table_alias=...)as a safe filter→SQL compiler in a query layer you already have. - Use
encode_cursor/decode_cursorfor keyset pagination anywhere. - Use
build_bulk_statement(...)to get a parameterized bulk statement without the router. - Use
CrudHandlers(spec, store)for a framework-free CRUD surface (e.g. behind a GraphQL resolver or a different web framework).
- The column allow-list in
compile_filters. It is the only defense against column-name injection (column names cannot be SQL-parameterized). Removing it to "accept any filter" reopens the hole. - The
None-on-every-failure contract indecode_cursor. A cursor comes from the client; making it raise turns a tampered token into a 500. - One audit row per bulk batch. Emitting one per id turns a 500-id drag into 500 ledger rows; emitting zero loses the trail. The batch is the unit.
- The same-404-for-absent-and-hidden rule. Distinguishing "no such id" from "hidden by your tenant scope" is a tenant-enumeration side channel.
- The optimistic-rollback in the frontend factory. Dropping it leaves the UI showing changes the server rejected. If you must disable optimism for a specific entity, do it at the call site, not by removing the rollback.