Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/blog-api-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ jobs:
mongodb: false
implementation: typeorm
database: ./blog-typeorm.sqlite
- name: fastify-ucast-sql-blog (Sequelize)
package: fastify-ucast-sql-blog
mongodb: false
implementation: sequelize
database: ./blog-sequelize.sqlite
- name: fastify-ucast-sql-blog (Objection)
package: fastify-ucast-sql-blog
mongodb: false
implementation: objection
database: ./blog-objection.sqlite
env:
DATABASE_URL: ${{ matrix.database }}
AUTH_SECRET: ci-blog-secret
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Currently this repository contains the next examples:
| [CASL Vue blog](./packages/vue-blog) | ACL in Blog app with REST API | |
| [CASL Express API](./packages/express-blog) | ACL in REST API for Blog app | |
| [CASL Fastify+Prisma API](./packages/fastify-prisma-blog) | ACL in REST API for Blog app | |
| [CASL Fastify+UCAST SQL API](./packages/fastify-ucast-sql-blog) | ACL in REST API for Blog app with MikroORM or TypeORM | |
| [CASL Fastify+UCAST SQL API](./packages/fastify-ucast-sql-blog) | ACL in REST API for Blog app with MikroORM, TypeORM, Sequelize and Objection | |

[CASL HelloWorld]: https://codesandbox.io/s/github/stalniy/casl-examples/tree/master/packages/hello-world
[CASL React Todo]: https://codesandbox.io/s/github/stalniy/casl-examples/tree/master/packages/react-todo
Expand Down
35 changes: 11 additions & 24 deletions packages/fastify-ucast-sql-blog/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Fastify blog with UCAST SQL

This example shares the Fastify blog API while selecting either MikroORM 7.x or TypeORM 1.x before startup. Both repositories run CASL's effective rule AST through the matching `@ucast/sql` ORM interpreter. SQLite files are independent: selecting another implementation does not move or migrate data.
This example shares the Fastify blog API while selecting MikroORM 7.x, TypeORM 1.x, Sequelize 6.x, or Objection 3.x before startup. All repositories run CASL's effective rule AST through the matching `@ucast/sql` ORM interpreter. SQLite files are independent: selecting another implementation does not move or migrate data. Sequelize uses the `sqlite3` driver; Objection uses Knex with `better-sqlite3`.

The checked-in UCAST package is a reproducible packed build from UCAST commit `61b4ef843efe00e753d19c3e62c80c170fd8195a` (release PR 87, `@ucast/sql@0.2.1`). Its SHA-256 is `b660f717424eb6a59f8b851fcde5fcb66cec8d7ad69ccd09eaaa5a562a7983b0`. The build was performed in an isolated checkout under `/private/tmp/ucast-pr87`; the user's local UCAST checkout was not used. The upstream package's declaration export paths did not match the generated `dist/types/src/...` layout, so the isolated package manifest was corrected before packing. Runtime files are built from the pinned source; the tarball resolves `@ucast/core` to its declared `2.0.0` package dependency.

Expand All @@ -16,52 +16,39 @@ Pick one implementation and prepare its SQLite file. The schema command is expli

```sh
cd packages/fastify-ucast-sql-blog
DATABASE_URL=./blog-mikro.sqlite pnpm schema:mikro
DATABASE_URL=./blog-mikro.sqlite pnpm seed:mikro
pnpm schema:mikro # or schema:typeorm or schema:sequelize or schema:objection
pnpm seed:mikro # or schema:typeorm or schema:sequelize or schema:objection
```

For TypeORM, use a separate file:
it will automatically create `blog-${orm}.sqlite` database file in the current directory. It's also possible to configure this by specifying `DATABASE_URL` environment variable, for example:

```sh
DATABASE_URL=./blog-typeorm.sqlite pnpm schema:typeorm
DATABASE_URL=./blog-typeorm.sqlite pnpm seed:typeorm
```

Start the matching implementation. `DATABASE_URL` accepts a relative or absolute SQLite filename; `file:` is also accepted.
To start the matching implementation. `DATABASE_URL` accepts a relative or absolute SQLite filename; `file:` is also accepted.

```sh
IMPLEMENTATION=mikro DATABASE_URL=./blog-mikro.sqlite AUTH_SECRET=local-secret pnpm start
# or
IMPLEMENTATION=typeorm DATABASE_URL=./blog-typeorm.sqlite AUTH_SECRET=local-secret pnpm start
IMPLEMENTATION=mikro pnpm start
```

`IMPLEMENTATION` must be `mikro` or `typeorm`. The default is `mikro`; the default database filename is `blog-mikro.sqlite`. The server never creates or clears schema/data on startup. It closes the selected ORM connection when Fastify closes.
`IMPLEMENTATION` variable can be one of `mikro`, `typeorm`, `sequelize`, or `objection`. The default is `mikro`; the default database filename is `blog-${IMPLEMENTATION}$.sqlite`, also possible to set `DATABASE_URL` to provide custom database path. The server never creates or clears schema/data on startup. It closes the selected ORM connection when Fastify closes.

## API and rules

The API stays under `/api`: `POST /session`, user create/read/update routes, and article list/read/create/update/delete routes. Session response shape, string IDs, article `author`, list `createdBy`, and the reference pagination bounds are retained. Anonymous users see published articles, writers also see their own drafts and manage their own articles, and admins can manage all records. Missing individual resources return 404; existing resources that the caller cannot access or change return 403. Article lists return newest records first, and empty accessible lists return `{ items: [], count: 0 }`. Denied creation and publish transitions return 403.

`src/createApp.ts` composes the app and registers the `auth`, `users`, and `articles` modules under `src/modules`, matching the Fastify Prisma example. Each module exposes its routes through `index.ts`; authentication, ability definitions, and token services live in the auth module. Shared entity types remain in `src/domain.ts`, and app configuration is defined in `src/config.ts`.

`src/services/orm.ts` initializes the selected ORM and returns a `MikroBlogRepository` or `TypeOrmBlogRepository` instance to `createApp.ts`. The app injects that repository into `BlogService` and exposes the service to routes and authentication. `src/services/blog.ts` owns record permissions, create and publish checks, and missing/forbidden results. It derives query filters from CASL rules through `src/services/authorization.ts` so lists and counts include only accessible records.
`src/services/orm.ts` initializes the selected ORM and returns a `MikroBlogRepository`, `TypeOrmBlogRepository`, `SequelizeBlogRepository`, or `ObjectionBlogRepository` instance to `createApp.ts`. The app injects that repository into `BlogService` and exposes the service to routes and authentication. `src/services/blog.ts` owns record permissions, create and publish checks, and missing/forbidden results. It derives query filters from CASL rules through `src/services/authorization.ts` so lists and counts include only accessible records.

Entity schemas live alongside their corresponding repository classes in `src/repositories`. Repositories only perform database operations: metadata inspection, query construction, UCAST interpretation of supplied filters, and writes. They do not receive abilities or decide permissions. Their metadata keeps property names and physical column names identical, including `author`, `createdAt`, and `updatedAt`. Updates and deletes apply the service's filters in the changing SQL statement.

## Shared SQLite integration suite
Sequelize combines the interpreter's `where` expression with the requested identifier using `Op.and`, selecting the model alias for reads and the table name for writes. Objection applies the interpreter directly to its query builder and binds its models to each repository's Knex connection. Both expose the author through `createdBy` while retaining the scalar `author` column, and normalize timestamps to domain `Date` values.

The same API/repository integration suite runs against each real SQLite adapter:
## Testing

```sh
IMPLEMENTATION=mikro volta run --node 24.14.1 node --import tsx --test spec/*.spec.ts
IMPLEMENTATION=typeorm volta run --node 24.14.1 node --import tsx --test spec/*.spec.ts

# Run the original Express API contract suite against each Fastify SQL adapter.
IMPLEMENTATION=mikro volta run --node 24.14.1 node --import tsx spec/run-express-contract.cjs
IMPLEMENTATION=typeorm volta run --node 24.14.1 node --import tsx spec/run-express-contract.cjs
```

The suite covers role visibility, stable pagination and matching counts, missing and inaccessible identifiers, atomic owner writes, denied-write persistence, authorized-but-denied publish transitions, create/publish/user access, durable user updates, CASL rule priority/inversion, unconditional and absent permissions, identifier plus condition composition, quoted parameter values, unsupported UCAST translation, and generated property/column metadata.

To run the unchanged Express API contracts against a SQL server running on port 3000, run `npm run test:server` from `packages/express-blog`.
To test api contracts, run server running on port 3000 (default), and then run `npm run test:server` from `packages/express-blog`.

To refresh the vendored UCAST artifact, clone UCAST into a disposable directory, check out the pinned commit, install the lockfile, build `@ucast/core` and `@ucast/sql`, correct the generated declaration paths in the isolated `packages/sql/package.json`, pack `@ucast/sql`, then replace `vendor/ucast-sql-0.2.1.tgz` and update this hash. Do not use a registry `@ucast/sql` release for this example.
16 changes: 12 additions & 4 deletions packages/fastify-ucast-sql-blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,28 @@
"seed:mikro": "tsx src/seed.ts mikro",
"schema:typeorm": "tsx src/schema.ts typeorm",
"seed:typeorm": "tsx src/seed.ts typeorm",
"schema:sequelize": "tsx src/schema.ts sequelize",
"seed:sequelize": "tsx src/seed.ts sequelize",
"schema:objection": "tsx src/schema.ts objection",
"seed:objection": "tsx src/seed.ts objection",
"test": "node --import tsx --test spec/*.spec.ts",
"test:express-contract": "node --import tsx spec/run-express-contract.cjs"
},
"dependencies": {
"@casl/ability": "^7.0.0",
"@ucast/mongo2js": "^2.0.0",
"@ucast/sql": "file:vendor/ucast-sql-0.2.1.tgz",
"@mikro-orm/core": "^7.0.0",
"@mikro-orm/sqlite": "^7.0.0",
"@ucast/mongo2js": "^2.0.0",
"@ucast/sql": "file:vendor/ucast-sql-0.2.1.tgz",
"better-sqlite3": "^12.11.1",
"fastify": "^5.0.0",
"jsonwebtoken": "^9.0.0",
"knex": "^3.3.0",
"objection": "^3.8.0",
"reflect-metadata": "^0.2.2",
"typeorm": "^1.0.0",
"better-sqlite3": "^12.11.1"
"sequelize": "^6.37.8",
"sqlite3": "^5.1.7",
"typeorm": "^1.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/fastify-ucast-sql-blog/src/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { usersRoutes } from './modules/users/index.js';
import { articlesRoutes } from './modules/articles/index.js';

export async function createApp(config: AppConfig, injectedRepository?: BlogRepository): Promise<FastifyInstance> {
if (!['mikro', 'typeorm'].includes(config.IMPLEMENTATION)) {
throw new Error(`Unknown implementation "${config.IMPLEMENTATION}". Use mikro or typeorm.`);
if (!['mikro', 'typeorm', 'sequelize', 'objection'].includes(config.IMPLEMENTATION)) {
throw new Error(`Unknown implementation "${config.IMPLEMENTATION}". Use mikro, typeorm, sequelize or objection.`);
}
const repository = injectedRepository ?? await initializeOrm(config.IMPLEMENTATION, config.DATABASE_URL);
const fastify = Fastify({ logger: true });
Expand Down
180 changes: 180 additions & 0 deletions packages/fastify-ucast-sql-blog/src/repositories/objection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { Model, type ModelClass, type QueryBuilder } from 'objection';
import type { Knex } from 'knex';
import { interpret } from '@ucast/sql/objection';
import type { User, Article, Role } from '../domain.js';
import type { ArticlePatch, BlogRepository, QueryFilter, UserPatch } from './types.js';

export class ObjectionBlogRepository implements BlogRepository {
readonly kind = 'objection';
private readonly users: ModelClass<ObjectionUser>;
private readonly articles: ModelClass<ObjectionArticle>;

constructor(private readonly connection: Knex) {
this.users = ObjectionUser.bindKnex(connection);
this.articles = ObjectionArticle.bindKnex(connection);
}

schemaMetadata() {
const models: Array<[ModelClass<Model>, string]> = [[this.users, 'User'], [this.articles, 'Article']];
return models.map(([model, entity]) => ({
entity,
properties: Object.keys(model.jsonSchema.properties ?? {}).map(propertyName => ({
propertyName,
columnName: model.propertyNameToColumnName(propertyName),
})),
}));
}

async close(): Promise<void> {
await this.connection.destroy();
}

async findUserByEmail(email: string): Promise<User | null> {
const row = await this.users.query().findOne({ email });
return row ? this.#toUser(row) : null;
}

async userExists(id: string): Promise<boolean> {
return Boolean(await this.users.query().findById(id));
}

async articleExists(id: string): Promise<boolean> {
return Boolean(await this.articles.query().findById(id));
}

async findUserById(id: string, filter: QueryFilter = { kind: 'all' }): Promise<User | null> {
if (filter.kind === 'none') return null;
const row = await this.#filter(this.users.query(), filter).findById(id);
return row ? this.#toUser(row) : null;
}

async createUser(value: Omit<User, 'id'> & { id?: string }): Promise<User> {
const row = await this.users.query().insert({
...value, id: value.id ?? crypto.randomUUID(),
createdAt: value.createdAt.toISOString(), updatedAt: value.updatedAt.toISOString(),
});
return this.#toUser(row);
}

async updateUser(id: string, patch: UserPatch, filter: QueryFilter): Promise<User | null> {
if (filter.kind === 'none') return null;
const affected = await this.#filter(this.users.query(), filter).where('id', id)
.patch({ ...patch, updatedAt: new Date().toISOString() });
return affected ? this.findUserById(id) : null;
}

async findArticle(id: string, filter: QueryFilter = { kind: 'all' }): Promise<Article | null> {
if (filter.kind === 'none') return null;
const row = await this.#articleQuery(filter).findById(id);
return row ? this.#toArticle(row) : null;
}

async listArticles(filter: QueryFilter, page: number, pageSize: number): Promise<{ items: Article[]; count: number }> {
if (filter.kind === 'none') return { items: [], count: 0 };
const count = await this.#filter(this.articles.query(), filter).resultSize();
const rows = await this.#articleQuery(filter).orderBy('createdAt', 'desc').orderBy('id', 'asc')
.limit(pageSize).offset((page - 1) * pageSize);
return { items: rows.map(row => this.#toArticle(row)), count };
}

async createArticle(value: Omit<Article, 'id'> & { id?: string }): Promise<Article> {
const { createdBy, ...attributes } = value;
const row = await this.articles.query().insert({
...attributes, id: value.id ?? crypto.randomUUID(),
createdAt: value.createdAt.toISOString(), updatedAt: value.updatedAt.toISOString(),
});
const created = await this.findArticle(row.id);
if (!created) throw new Error('Created article could not be loaded with its author');
return created;
}

async updateArticle(id: string, patch: ArticlePatch, filter: QueryFilter): Promise<Article | null> {
if (filter.kind === 'none') return null;
const current = await this.findArticle(id, filter);
if (!current) return null;
const updatedAt = new Date();
const affected = await this.#filter(this.articles.query(), filter).where('id', id)
.patch({ ...patch, updatedAt: updatedAt.toISOString() });
return affected ? { ...current, ...patch, updatedAt } : null;
}

async deleteArticle(id: string, filter: QueryFilter): Promise<Article | null> {
if (filter.kind === 'none') return null;
const current = await this.findArticle(id, filter);
if (!current) return null;
const affected = await this.#filter(this.articles.query(), filter).where('id', id).delete();
return affected ? current : null;
}

#filter<T extends Model>(query: QueryBuilder<T, T[]>, filter: QueryFilter) {
if (filter.kind === 'ast') interpret(filter.ast, query);
return query;
}

#articleQuery(filter: QueryFilter) {
return this.#filter(this.articles.query(), filter).withGraphFetched('createdBy')
.modifyGraph('createdBy', query => query.select('id', 'email'));
}

#toUser(row: ObjectionUser): User {
return { ...row.toJSON(), createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt) } as User;
}

#toArticle(row: ObjectionArticle): Article {
return { ...row.toJSON(), createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt) } as Article;
}
}

export class ObjectionUser extends Model {
static tableName = 'users';
static jsonSchema = {
type: 'object',
properties: {
id: { type: 'string' }, name: { type: ['string', 'null'] }, email: { type: 'string' },
password: { type: 'string' }, role: { type: 'string' },
createdAt: { type: 'string' }, updatedAt: { type: 'string' },
},
};
id!: string;
name!: string | null;
email!: string;
password!: string;
role!: Role;
createdAt!: string;
updatedAt!: string;
}

export class ObjectionArticle extends Model {
static tableName = 'articles';
static jsonSchema = {
type: 'object',
properties: {
id: { type: 'string' }, author: { type: 'string' }, title: { type: 'string' },
body: { type: 'string' }, published: { type: 'boolean' },
createdAt: { type: 'string' }, updatedAt: { type: 'string' },
},
};
static get relationMappings() {
return {
createdBy: {
relation: Model.BelongsToOneRelation,
modelClass: ObjectionUser,
join: { from: 'articles.author', to: 'users.id' },
},
};
}
id!: string;
author!: string;
title!: string;
body!: string;
published!: boolean;
createdAt!: string;
updatedAt!: string;
createdBy?: Pick<User, 'id' | 'email'>;

$parseDatabaseJson(json: Record<string, any>) {
const parsed = super.$parseDatabaseJson(json);
if (parsed.published !== undefined) parsed.published = Boolean(parsed.published);
return parsed;
}
}
Loading
Loading