@~lyre/model
v0.1.0
Published
Eloquent/Lyre-style model layer for SvelteKit + Drizzle. A base Repository carrying all CRUD + a fluent chainable query API, driven by a per-model spec and a query-string grammar (dynamic filtering/sorting/search/relations/pagination), with auto-generated
Maintainers
Readme
@~lyre/model
Eloquent/Lyre-style model layer for SvelteKit + Drizzle (Node/TypeScript). A
base Repository carries all CRUD + a fluent chainable query API, driven by a
per-model spec and a query-string grammar (dynamic filtering, sorting,
search, relations, pagination). Models auto-expose a REST API + remote-function
factory, and a lyre CLI scaffolds a whole model from one command.
Ported from the Laravel Lyre package.
Install
npm install @~lyre/model drizzle-ormdrizzle-orm is a peer dependency (^0.45.0). The package works with any
Drizzle Postgres driver — postgres-js and node-postgres alike.
Setup
Register the app's database handle once at startup; nothing else is required.
// src/hooks.server.ts
import { createPlatformHook } from '@~lyre/model';
import { getDb } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
export const handle = createPlatformHook({
db: getDb, // the app's handle factory — the package imports no db module
schema, // auto-registers a REST model per table
prefix: '/api',
version: 'v1',
auto: {
only: ['widgets', 'reports'], // ALLOWLIST — never expose a whole schema blindly
tenantColumns: ['tenant_id'], // e.g. ['app_slug', 'tenant_id'] when scoping differs
requireTenantScope: 'throw' // refuse to register a table with no scoping column
}
});Outside a hook, call setDatabaseProvider(getDb) directly.
Multi-tenant safety.
auto.onlyis an allowlist; prefer it overexcludeso a new table is never exposed by accident.requireTenantScopemakes an unscoped table a startup error rather than a silent cross-tenant leak.
Design
- Schema stays the source of truth. Drizzle schema files remain authoritative
for
drizzle-kitmigrations.registerSchema()introspects a table into structural metadata; the authoredModelSpecoverlays only what can't be inferred (relations, searchable fields, status, tenancy). No column is declared twice. - The app owns the database. This package imports no db module. The host
registers a handle factory once —
createPlatformHook({ db: getDb })orsetDatabaseProvider(getDb)— and every Repository uses it. An explicitRepoContext.dbstill wins per request, which is how multi-database apps route per-tenant/per-app pools.Dbis a structural type, so postgres-js and node-postgres both satisfy it. - Identifiers are inferred, not assumed.
idTypecomes from the id column's Drizzle type (uuid/numeric/text), so abigserialprimary key on a table that also has a slug resolvesfind('42')by id, not slug. - Tenant scoping is real. When a model declares
tenantColumn, every query is scoped toctx.tenantId. Escape via.withoutTenant()/findAny(). - Relations without
relations(). The Drizzle schema declares none, so relations live in the spec and execute via batched second queries (eager load) and correlated subqueries (whereHas / withCount / doesntHave).
Defining a model
import { products, collections, productVariants } from './schema';
import { defineModel, Repository, type RepoContext } from '@~lyre/model';
export const ProductModel = defineModel({
name: 'product',
table: products,
slugColumn: 'slug',
nameColumn: 'title',
tenantColumn: 'tenantId',
status: { column: 'status', active: 'active' },
searchable: ['title', 'description'],
relations: {
collection: { type: 'belongsTo', table: collections, foreignKey: 'collectionId' },
variants: { type: 'hasMany', table: productVariants, foreignKey: 'productId' }
},
with: ['collection', 'variants']
});
export const Products = (ctx?: RepoContext) => new Repository(ProductModel, ctx);Querying
await Products({ tenantId }).all();
await Products({ tenantId }).find('glass-feeding-bottle-set'); // uuid | slug aware
await Products({ tenantId }).searchQuery('bottle').status('active').paginate(9, 1).paginateResult();
await Products({ tenantId }).relations('variants', 'collection').withCount('variants').all();
// From a URL / search params (Lyre grammar):
await Products({ tenantId })
.fromQuery('?search=bottle&status=active&with=variants&withcount=variants&per_page=9')
.all();Query-string grammar
filter=col,val · range=col,min,max (nested rel.col) · relation=path,value ·
relation_in=rel,v1,v2 · with=rel,rel.nested · withcount=rel · search=kw
(+search-relations=rel,col) · status=a,b · order=col,asc|desc ·
per_page/page · unpaginated · limit/offset · startswith=abc ·
wherenull=col · doesnthave=rel · random · first · ?<relation>=value.
Writes
create, batchCreate, firstOrCreate, updateOrCreate, update(idsOrSlugs, values)
(bulk via comma), delete(idsOrSlugs) (bulk; soft-delete when opted in). Tenant id
is auto-injected on create.
REST API
A single catch-all route resolves any registered model:
GET /api/products?search=bottle&status=active&per_page=9&with=variants
GET /api/products/<id|slug>?with=collection
GET /api/products/<id>/variants
POST /api/products
PUT /api/products/<id[,id2]>
DELETE /api/products/<id[,id2]>Every response uses the envelope { status, message, result, code }. See
resourceResponse() / handleResource().
For idiomatic in-app use, makeResourceRemote(spec, { query, command, resolveContext })
returns typed SvelteKit remote functions.
CLI
pnpm lyre make:model widget --tenant --soft-delete --policy
pnpm lyre register
pnpm db:generate && pnpm db:migratemake:model emits separate files: a Drizzle table (schema template), the
model spec, a repository, a types/DTO file, and (optionally) a policy — then
register regenerates the model registry barrel. The Drizzle table stays owned
by the developer / drizzle-kit.
Status / limitations
- Eager load, whereHas, withCount, doesntHave, nested-range filters, and tenant scoping are implemented and validated live.
- Ordering by a relation column (
order=rel.col) is parsed but not yet joined — own-column ordering only for now. - Soft delete is opt-in per model (
softDelete: true+ adeletedAtcolumn). belongsToMany(pivot) relations are specced but eager-loading is single-level belongsTo/hasMany today.
