Skip to content

Latest commit

 

History

History
86 lines (71 loc) · 4.68 KB

File metadata and controls

86 lines (71 loc) · 4.68 KB

The moat: one CRUD contract, enforced on both ends

The problem

Every multi-tenant application accretes the same surface for entity after entity: list (with filters and pagination), get, create, patch, delete, bulk — and then it builds the mirror of that surface on the frontend, with optimistic updates so the UI doesn't feel like it's waiting on the network. The naïve way is to hand-write a router per entity and a store per entity. That is exactly where the defects breed:

  • the third router paginates with LIMIT/OFFSET (which skips and duplicates rows under concurrent writes) while the first two used a keyset cursor;
  • a filter endpoint interpolates a column name a caller supplied, because the allow-list was a copy-paste that someone trimmed;
  • a bulk endpoint emits one audit row per id (a thousand rows for one drag) or, worse, mutates outside a transaction so a mid-batch failure leaves half the rows changed;
  • the frontend store updates local state optimistically but, on a failed request, leaves the UI showing a change the server rejected — or removes a row and can't put it back;
  • and slowly the frontend and backend drift: the store reads next_page, the API now sends next_cursor, and a page silently stops loading.

None of these is hard to get right once. The problem is getting them right every time, on both ends, as the entity count grows and the people change.

The structural advantage

vue-pinia-crud-store makes the CRUD contract a single, parameterized artifact and pushes every one of those invariants into the plumbing, so a new entity inherits them instead of re-deriving them:

  1. The contract is one document, consumed twice. An EntitySpec names the resource, the primary key, and the readable / filterable / patchable columns. That same spec compiles the backend's filter allow-list and cursor ordering and names the routes (/<resource>) and the list-response key (<resource>) the frontend store calls. There is no second source of truth to drift from.

  2. Injection defense lives in the compiler. compile_filters always binds filter values as driver parameters, checks every filter column name against the entity's allow-list (column names can't be parameterized), and validates the table alias against an identifier regex. A new entity gets all three by construction — there is no per-entity hand-written WHERE clause in which to forget them.

  3. Pagination is keyset, always. One cursor codec (opaque base64 over a (timestamp, id) keyset) is used by every list endpoint, so pagination is stable under concurrent inserts and a tampered token decodes to "start over" instead of raising. No entity gets the offset variant by accident.

  4. Tenancy and audit are on the path, not bolted on. Every handler is tenant-scoped at the storage seam; not-found and cross-tenant-hidden return the same 404 (so the API can't enumerate other tenants' ids); every mutation emits exactly one best-effort audit row (one per bulk batch, not per id) and never fails the request when the audit write hiccups.

  5. Optimism that can't strand the UI. The frontend factory applies a change locally, then reconciles with the server — and on failure restores the prior value (patch) or re-inserts the removed row at its original index (delete). The rollback is written once in the factory, so every entity's store has it; no screen reimplements it and gets it subtly wrong.

Why it is hard to retrofit

Bolting a uniform CRUD contract onto an app that already has a dozen hand-written routers and stores means auditing each one for the cursor variant, the missing allow-list, the double audit row, the absent rollback — and then keeping them uniform as more get added by more people. Adopting the factory from the start means uniformity is the default: a new entity is an EntitySpec, and it cannot diverge on pagination, injection defense, tenancy, audit, or rollback because it has no bespoke code in which to diverge. The Python core is dependency-free (the in-memory store and every builder run with no web framework and no database), so "route every entity through it" carries no framework lock-in and no excuse.

Where it pays off most

Any multi-tenant product with a growing catalog of entities behind a SPA: admin consoles, internal tools, B2B dashboards, anything where each new object type needs the same list/detail/create/edit/bulk experience and the same correctness guarantees. The more entities you have, the larger the gap between "thirteen hand-written CRUD pairs" and "thirteen specs" — and the larger the assurance that the API your frontend calls is provably the API your backend serves.