agentsclimarketplace

Zenstack querying

Skill zenstackhq/skills/skills/zenstack-querying

Agent skills for ZenStack, published to skills.sh — ZModel schema modeling, access control, querying, automatic CRUD service, migrations, and plugin development.

Install
npx -y skills add zenstackhq/skills --skill zenstack-querying

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Query and mutate data with the ZenStack V3 client. Use when creating a ZenStackClient (SQLite/Postgres/MySQL dialect), using the Prisma-compatible ORM API (findMany/create/update/etc.), relation queries (select/include), computed fields, polymorphic models, strongly-typed JSON, data validation on writes (@email/@length/@@validate), transactions, the Kysely query-builder escape hatch ($qb / $expr), custom procedures, raw SQL, logging, or handling ORMError.

SKILL.md

14.8 KB, as published. Nobody here has run it

ZenStack V3 — Querying

The ZenStack client exposes a Prisma-compatible ORM API for everyday queries plus a Kysely query builder ($qb) for anything SQL-shaped that the ORM API can't express. Schema syntax is in zenstack-schema-modeling; access control in zenstack-access-control.

Creating the client

Import ZenStackClient from @zenstackhq/orm, pass the generated schema plus a Kysely dialect. Install the matching driver yourself (see zenstack-project-setup).

SQLite (better-sqlite3):

import { ZenStackClient } from '@zenstackhq/orm';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import SQLite from 'better-sqlite3';
import { schema } from './zenstack/schema';

export const db = new ZenStackClient(schema, {
    dialect: new SqliteDialect({ database: new SQLite('./dev.db') }),
});

PostgreSQL (pg):

import { ZenStackClient } from '@zenstackhq/orm';
import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { schema } from './zenstack/schema';
import { Pool } from 'pg';

export const db = new ZenStackClient(schema, {
    dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL }) }),
});

MySQL (mysql2):

import { MysqlDialect } from '@zenstackhq/orm/dialects/mysql';
import { createPool } from 'mysql2';

export const db = new ZenStackClient(schema, {
    dialect: new MysqlDialect({ pool: createPool(process.env.DATABASE_URL) }),
});

Types are fully inferred from the schema — no separate model imports needed for most code. When you need an explicit type: ClientContract<SchemaType> for the client, or import generated models / input types, or use the ModelResult generic from @zenstackhq/orm to compute a row type from a given select/include.

To enforce access policies, wrap this client with the policy plugin — see zenstack-access-control.

ORM API

Model accessors live on the client (db.user, db.post, …). The API matches Prisma Client.

Read: findMany, findUnique, findUniqueOrThrow, findFirst, findFirstOrThrow, exists (v3.2.0+, cheaper than findFirst/count), count, aggregate, groupBy.

const users = await db.user.findMany({
    where: { age: { gt: 18 }, email: { endsWith: '@acme.com' } },
    orderBy: { createdAt: 'desc' },
    skip: 0,
    take: 20,
});
const user = await db.user.findUnique({ where: { id: 1 } });
const n = await db.user.count({ where: { age: { gte: 18 } } });

Filter operators: equals, not, in, notIn, contains, startsWith, endsWith, lt, lte, gt, gte, between; logical AND/OR/NOT; list has/hasEvery/hasSome/isEmpty (Postgres). For to-many relations filter with some/every/none; for to-one, filter fields directly.

Aggregate / groupBy support _count, _sum, _avg, _min, _max; groupBy filters aggregates via having:

await db.post.groupBy({ by: 'authorId', _count: true, having: { viewCount: { _sum: { gt: 100 } } } });

Create: create (supports nested writes, runs in a transaction), createMany (no nested relations; returns a count), createManyAndReturn.

await db.user.create({
    data: { email: '[email protected]', posts: { create: [{ title: 'Hello' }] } },
});

Update: update, updateMany / updateManyAndReturn (scalar fields only), upsert. List fields use push / set:

await db.post.update({ where: { id: 1 }, data: { topics: { push: 'webdev' } } });
await db.post.upsert({
    where: { id: 1 },
    create: { id: 1, title: 'New' },
    update: { title: 'Updated' },
});

Delete: delete, deleteMany, deleteManyAndReturn.

Relation queries — select / include / omit

  • select: pick specific fields; for a relation pass a nested object. Mutually exclusive with include/omit.
  • include: pull in relations (true for all fields, or an object to filter/where the relation). Mutually exclusive with select.
  • omit: drop specific scalar fields. Mutually exclusive with select.
await db.user.findMany({
    include: { posts: { where: { published: true }, select: { id: true, title: true } } },
});

Nested writes inside create/update/upsert can create, connect, disconnect, update, and delete related records (deeply), all within a transaction.

Computed fields

A field declared @computed in ZModel (see zenstack-schema-modeling) is evaluated on the database side. Provide its implementation as a Kysely expression in the computedFields client option; thereafter it behaves like a regular field — returned by queries, and usable in select, where, orderBy, and aggregate.

const db = new ZenStackClient(schema, {
    dialect,
    computedFields: {
        User: {
            // SQL: (SELECT COUNT(*) FROM "Post" WHERE "Post"."authorId" = "User"."id")
            postCount: (eb) =>
                eb.selectFrom('Post')
                    .whereRef('Post.authorId', '=', 'id')
                    .select(({ fn }) => fn.countAll<number>().as('count')), // selection must be named
        },
    },
});

await db.user.findFirst();                                   // postCount included
await db.user.findFirst({ select: { email: true, postCount: true } });
await db.user.findMany({ where: { postCount: { gt: 1 } }, orderBy: { postCount: 'desc' } });
await db.user.aggregate({ _avg: { postCount: true } });

The callback's second arg is a context with modelAlias — use sql.ref(\${modelAlias}.id`)(importsqlfrom@zenstackhq/orm/helpers`) to qualify the containing model's columns on conflicts.

Polymorphic models

For models using @@delegate inheritance (see zenstack-schema-modeling), query via the usual model accessors:

  • Create concrete models (db.video.create(...)) — the base row is created automatically. Base models cannot be created directly.
  • Query a base model (db.content.findMany()) and each result includes the concrete model's fields (unless you narrow with select). Querying a concrete model returns base + concrete fields.
  • Delete either side and the counterpart row is removed too.
const user = await db.user.create({ data: { email: '[email protected]' } });
await db.post.create({ data: { name: 'Post1', content: 'Hi', ownerId: user.id } });
await db.video.create({ data: { name: 'Video1', url: 'http://v/1', ownerId: user.id } });

const content = await db.content.findFirstOrThrow();   // includes concrete fields
await db.user.findFirstOrThrow({ include: { contents: true } });

The discriminator value is typed as a string literal (or enum member), so checking it narrows the result type to the matching concrete model:

const c = await db.content.findUniqueOrThrow({ where: { id: 1 } });
if (c.type === 'video') console.log(c.url);    // narrowed to Video
else if (c.type === 'image') console.log(c.data);

Strongly-typed JSON

A field typed with a custom type + @json (see zenstack-schema-modeling) is returned strongly typed (derived from the ZModel type), and inputs are validated against that type on create/update. Validation is loose — extra fields not in the type are allowed.

// query results are typed: user.profile.age / user.profile.gender
const user = await db.user.create({
    data: { email: '[email protected]', profile: { gender: 'male', age: 20 } },
});

await db.user.update({
    where: { id: user.id },
    data: { profile: { ...user.profile, tag: 'vip' } }, // extra `tag` is allowed
});

Data validation

Validation rules are declared in ZModel and enforced by the ORM on create/update inputs (not on the query builder / raw SQL). Failures throw ORMError with reason INVALID_INPUT. Each attribute takes an optional trailing message.

Field attributes

  • String length / lists: @length(min?, max?)
  • String content: @startsWith, @endsWith, @contains, @email, @url, @phone (E.164), @datetime (ISO 8601), @regex(pattern)
  • String transforms (applied before save): @lower, @upper, @trim
  • Numbers: @gt, @gte, @lt, @lte
model User {
    email    String  @email("Invalid email")
    password String  @length(8, 128)
    age      Int     @gte(0) @lte(150)
    website  String? @url
}

Model-level @@validate(condition, message?, path?) for cross-field rules, with helper functions: now(), length(), startsWith/endsWith/contains, isEmail/isUrl/isPhone/ isDateTime, regex(), has/hasSome/hasEvery/isEmpty.

model Event {
    startDate DateTime
    endDate   DateTime
    tags      String[]
    @@validate(endDate > startDate, "End date must be after start date")
    @@validate(!isEmpty(tags), "At least one tag is required")
}

Validation runs before access policies and before the database is touched. For access control see zenstack-access-control. After editing validation rules (or any ZModel — including procedure declarations), run zen generate to regenerate the client (it also validates the schema); run zen check if you only want to validate without regenerating.

Transactions — $transaction

Sequential — array of operations, executed in order, no cross-access:

const [a, b] = await db.$transaction([
    db.user.create({ data: { name: 'Alice' } }),
    db.user.create({ data: { name: 'Bob' } }),
]);

Interactive — callback receiving a transaction client; results feed later steps. Keep it short.

const post = await db.$transaction(async (tx) => {
    const user = await tx.user.create({ data: { name: 'Alice' } });
    return tx.post.create({ data: { title: 'Hi', authorId: user.id } });
});

ORM call results are lazy promises — they don't run until awaited (directly or inside $transaction).

Kysely query builder — $qb and $expr

Use the full Kysely API (typed from the schema) when the ORM API isn't enough. See https://kysely.dev for the builder API.

const rows = await db.$qb
    .selectFrom('User')
    .select(['id', 'name'])
    .where('age', '>', 18)
    .execute();

Inject a Kysely expression into an ORM where with $expr to mix the two:

await db.post.findMany({
    where: {
        published: true,
        $expr: (eb) => eb('viewCount', '>', 100),
    },
});

The query builder bypasses access policies and ORM-level validation — keep that in mind when a policy plugin is installed.

Custom procedures (v3.2.0+)

Declare typed operations in ZModel, implement them at client construction, call via $procs.

procedure getUserFeeds(userId: Int, limit: Int?) : Post[]
mutation procedure signUp(email: String) : User
const db = new ZenStackClient(schema, {
    dialect,
    procedures: {
        getUserFeeds: ({ client, args }) =>
            client.post.findMany({ where: { authorId: args.userId }, take: args.limit }),
        signUp: ({ client, args }) => client.user.create({ data: { email: args.email } }),
    },
});

const user = await db.$procs.signUp({ args: { email: '[email protected]' } });
const feeds = await db.$procs.getUserFeeds({ args: { userId: user.id, limit: 20 } });

Args are validated against the ZModel types before the callback runs; return values are not — match them yourself. Throw ORMError for failures.

Raw SQL

$queryRaw / $executeRaw use tagged templates (parameterized, safe); the *Unsafe variants take plain strings and are injection-prone.

const users = await db.$queryRaw<{ id: number; email: string }[]>`
    SELECT id, email FROM "User" WHERE role = ${role}`;
const affected = await db.$executeRaw`UPDATE "User" SET name = ${name} WHERE id = ${id}`;

Raw SQL bypasses access control; with the policy plugin installed it requires dangerouslyAllowRawSql.

Logging

Pass a log option to ZenStackClient — an array of levels, or a function. It's forwarded to the underlying Kysely instance.

const db = new ZenStackClient(schema, {
    dialect,
    log: ['query', 'error'],
    // or a function:
    // log: (event) => console.log(`[${event.level}] ${event.queryDurationMillis}ms`),
});

Error handling

All ORM errors are ORMError. Inspect error.reason (CONFIG_ERROR, INVALID_INPUT, NOT_FOUND, REJECTED_BY_POLICY, DB_QUERY_ERROR, NOT_SUPPORTED, INTERNAL_ERROR); other fields include model, dbErrorCode, dbErrorMessage, rejectedByPolicyReason, and (for DB_QUERY_ERROR) sql/sqlParams.

import { ORMError } from '@zenstackhq/orm';

try {
    await userDb.post.create({ data: { title: '' } });
} catch (e) {
    if (e instanceof ORMError && e.reason === 'REJECTED_BY_POLICY') {
        // handle access-denied
    }
}

Reference docs

Full ZenStack documentation for this topic is bundled under references/:

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.