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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-v3",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "ZenStack",
"packageManager": "pnpm@10.12.1",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"type": "module",
"author": {
"name": "ZenStack Team"
Expand Down
2 changes: 1 addition & 1 deletion packages/common-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/common-helpers",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "ZenStack Common Helpers",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/create-zenstack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-zenstack",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "Create a new ZenStack project",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/dialects/sql.js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/kysely-sql-js",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "Kysely dialect for sql.js",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/eslint-config",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"type": "module",
"private": true,
"license": "MIT"
Expand Down
2 changes: 1 addition & 1 deletion packages/ide/vscode/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zenstack",
"publisher": "zenstack",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"displayName": "ZenStack Language Tools",
"description": "VSCode extension for ZenStack ZModel language",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion packages/language/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/language",
"description": "ZenStack ZModel language specification",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"license": "MIT",
"author": "ZenStack Team",
"files": [
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/runtime",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "ZenStack Runtime",
"type": "module",
"scripts": {
Expand Down Expand Up @@ -65,11 +65,12 @@
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"@zenstackhq/common-helpers": "workspace:*",
"decimal.js": "^10.4.3",
"json-stable-stringify": "^1.3.0",
"nanoid": "^5.0.9",
"toposort": "^2.0.2",
"ts-pattern": "catalog:",
"ulid": "^3.0.0",
"uuid": "^11.0.5"
Expand All @@ -91,6 +92,7 @@
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/pg": "^8.0.0",
"@types/toposort": "^2.0.7",
"@zenstackhq/eslint-config": "workspace:*",
"@zenstackhq/language": "workspace:*",
"@zenstackhq/sdk": "workspace:*",
Expand Down
101 changes: 72 additions & 29 deletions packages/runtime/src/client/helpers/schema-db-pusher.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { invariant } from '@zenstackhq/common-helpers';
import { CreateTableBuilder, sql, type ColumnDataType, type OnModifyForeignAction } from 'kysely';
import toposort from 'toposort';
import { match } from 'ts-pattern';
import {
ExpressionUtils,
type BuiltinType,
type CascadeAction,
type FieldDef,
type GetModels,
type ModelDef,
type SchemaDef,
} from '../../schema';
Expand All @@ -24,32 +24,84 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
if (this.schema.enums && this.schema.provider.type === 'postgresql') {
for (const [name, enumDef] of Object.entries(this.schema.enums)) {
const createEnum = tx.schema.createType(name).asEnum(Object.values(enumDef));
// console.log('Creating enum:', createEnum.compile().sql);
await createEnum.execute();
}
}

for (const model of Object.keys(this.schema.models)) {
const createTable = this.createModelTable(tx, model as GetModels<Schema>);
// console.log('Creating table:', createTable.compile().sql);
// sort models so that target of fk constraints are created first
const sortedModels = this.sortModels(this.schema.models);
for (const modelDef of sortedModels) {
const createTable = this.createModelTable(tx, modelDef);
await createTable.execute();
}
});
}

private createModelTable(kysely: ToKysely<Schema>, model: GetModels<Schema>) {
let table = kysely.schema.createTable(model).ifNotExists();
const modelDef = requireModel(this.schema, model);
private sortModels(models: Record<string, ModelDef>): ModelDef[] {
const graph: [ModelDef, ModelDef | undefined][] = [];

for (const model of Object.values(models)) {
let added = false;

if (model.baseModel) {
// base model should be created before concrete model
const baseDef = requireModel(this.schema, model.baseModel);
// edge: concrete model -> base model
graph.push([model, baseDef]);
added = true;
}

for (const field of Object.values(model.fields)) {
// relation order
if (field.relation && field.relation.fields && field.relation.references) {
const targetModel = requireModel(this.schema, field.type);
// edge: fk side -> target model
graph.push([model, targetModel]);
added = true;
}
}

if (!added) {
// no relations, add self to graph to ensure it is included in the result
graph.push([model, undefined]);
}
}

return toposort(graph)
.reverse()
.filter((m) => !!m);
}
Comment on lines +40 to +73
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider handling circular dependencies in the model graph.

The topological sorting implementation correctly handles foreign key and base model dependencies. However, if there are circular dependencies in the model relationships, toposort will throw an error that isn't caught.

Consider adding error handling to provide a more helpful error message:

 private sortModels(models: Record<string, ModelDef>): ModelDef[] {
     const graph: [ModelDef, ModelDef | undefined][] = [];
 
     for (const model of Object.values(models)) {
         let added = false;
 
         if (model.baseModel) {
             // base model should be created before concrete model
             const baseDef = requireModel(this.schema, model.baseModel);
             // edge: concrete model -> base model
             graph.push([model, baseDef]);
             added = true;
         }
 
         for (const field of Object.values(model.fields)) {
             // relation order
             if (field.relation && field.relation.fields && field.relation.references) {
                 const targetModel = requireModel(this.schema, field.type);
                 // edge: fk side -> target model
                 graph.push([model, targetModel]);
                 added = true;
             }
         }
 
         if (!added) {
             // no relations, add self to graph to ensure it is included in the result
             graph.push([model, undefined]);
         }
     }
 
-    return toposort(graph)
-        .reverse()
-        .filter((m) => !!m);
+    try {
+        return toposort(graph)
+            .reverse()
+            .filter((m) => !!m);
+    } catch (error) {
+        if (error instanceof Error && error.message.includes('Cyclic dependency')) {
+            throw new Error(`Circular dependency detected in model relationships: ${error.message}`);
+        }
+        throw error;
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private sortModels(models: Record<string, ModelDef>): ModelDef[] {
const graph: [ModelDef, ModelDef | undefined][] = [];
for (const model of Object.values(models)) {
let added = false;
if (model.baseModel) {
// base model should be created before concrete model
const baseDef = requireModel(this.schema, model.baseModel);
// edge: concrete model -> base model
graph.push([model, baseDef]);
added = true;
}
for (const field of Object.values(model.fields)) {
// relation order
if (field.relation && field.relation.fields && field.relation.references) {
const targetModel = requireModel(this.schema, field.type);
// edge: fk side -> target model
graph.push([model, targetModel]);
added = true;
}
}
if (!added) {
// no relations, add self to graph to ensure it is included in the result
graph.push([model, undefined]);
}
}
return toposort(graph)
.reverse()
.filter((m) => !!m);
}
private sortModels(models: Record<string, ModelDef>): ModelDef[] {
const graph: [ModelDef, ModelDef | undefined][] = [];
for (const model of Object.values(models)) {
let added = false;
if (model.baseModel) {
// base model should be created before concrete model
const baseDef = requireModel(this.schema, model.baseModel);
// edge: concrete model -> base model
graph.push([model, baseDef]);
added = true;
}
for (const field of Object.values(model.fields)) {
// relation order
if (field.relation && field.relation.fields && field.relation.references) {
const targetModel = requireModel(this.schema, field.type);
// edge: fk side -> target model
graph.push([model, targetModel]);
added = true;
}
}
if (!added) {
// no relations, add self to graph to ensure it is included in the result
graph.push([model, undefined]);
}
}
try {
return toposort(graph)
.reverse()
.filter((m) => !!m);
} catch (error) {
if (error instanceof Error && error.message.includes('Cyclic dependency')) {
throw new Error(`Circular dependency detected in model relationships: ${error.message}`);
}
throw error;
}
}
🤖 Prompt for AI Agents
In packages/runtime/src/client/helpers/schema-db-pusher.ts around lines 40 to
73, the call to toposort can throw on circular dependencies and is not handled;
wrap the toposort + reverse + filter sequence in a try/catch, catch the error,
build and throw a new Error with a clear message that a circular dependency was
detected (include contextual info such as the schema name or the list of model
names/edges from the graph for easier debugging), and rethrow or return a
controlled failure; ensure the function still returns the expected ModelDef[]
type when no error occurs.


private createModelTable(kysely: ToKysely<Schema>, modelDef: ModelDef) {
let table: CreateTableBuilder<string, any> = kysely.schema.createTable(modelDef.name).ifNotExists();

for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.originModel && !fieldDef.id) {
// skip non-id fields inherited from base model
continue;
}

if (fieldDef.relation) {
table = this.addForeignKeyConstraint(table, model, fieldName, fieldDef);
table = this.addForeignKeyConstraint(table, modelDef.name, fieldName, fieldDef);
} else if (!this.isComputedField(fieldDef)) {
table = this.createModelField(table, fieldName, fieldDef, modelDef);
table = this.createModelField(table, fieldDef, modelDef);
}
}

table = this.addPrimaryKeyConstraint(table, model, modelDef);
table = this.addUniqueConstraint(table, model, modelDef);
if (modelDef.baseModel) {
// create fk constraint
const baseModelDef = requireModel(this.schema, modelDef.baseModel);
table = table.addForeignKeyConstraint(
`fk_${modelDef.baseModel}_delegate`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix constraint naming: should use the concrete model name.

The foreign key constraint name uses modelDef.baseModel but it should use modelDef.name to maintain consistency with other constraint naming patterns and avoid potential naming conflicts.

             table = table.addForeignKeyConstraint(
-                `fk_${modelDef.baseModel}_delegate`,
+                `fk_${modelDef.name}_delegate`,
                 baseModelDef.idFields,
                 modelDef.baseModel,
                 baseModelDef.idFields,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`fk_${modelDef.baseModel}_delegate`,
table = table.addForeignKeyConstraint(
- `fk_${modelDef.baseModel}_delegate`,
+ `fk_${modelDef.name}_delegate`,
baseModelDef.idFields,
modelDef.baseModel,
baseModelDef.idFields,
);
🤖 Prompt for AI Agents
In packages/runtime/src/client/helpers/schema-db-pusher.ts around line 95, the
foreign-key constraint is currently named using modelDef.baseModel but should
use the concrete model name to match other constraint naming; replace use of
modelDef.baseModel with modelDef.name (e.g., build the constraint name as
fk_${modelDef.name}_delegate) so the constraint names are consistent and avoid
conflicts.

baseModelDef.idFields,
modelDef.baseModel,
baseModelDef.idFields,
(cb) => cb.onDelete('cascade').onUpdate('cascade'),
);
}

table = this.addPrimaryKeyConstraint(table, modelDef);
table = this.addUniqueConstraint(table, modelDef);

return table;
}
Expand All @@ -58,11 +110,7 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
return fieldDef.attributes?.some((a) => a.name === '@computed');
}

private addPrimaryKeyConstraint(
table: CreateTableBuilder<string, any>,
model: GetModels<Schema>,
modelDef: ModelDef,
) {
private addPrimaryKeyConstraint(table: CreateTableBuilder<string, any>, modelDef: ModelDef) {
if (modelDef.idFields.length === 1) {
if (Object.values(modelDef.fields).some((f) => f.id)) {
// @id defined at field level
Expand All @@ -71,13 +119,13 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
}

if (modelDef.idFields.length > 0) {
table = table.addPrimaryKeyConstraint(`pk_${model}`, modelDef.idFields);
table = table.addPrimaryKeyConstraint(`pk_${modelDef.name}`, modelDef.idFields);
}

return table;
}

private addUniqueConstraint(table: CreateTableBuilder<string, any>, model: string, modelDef: ModelDef) {
private addUniqueConstraint(table: CreateTableBuilder<string, any>, modelDef: ModelDef) {
for (const [key, value] of Object.entries(modelDef.uniqueFields)) {
invariant(typeof value === 'object', 'expecting an object');
if ('type' in value) {
Expand All @@ -86,22 +134,17 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
if (fieldDef.unique) {
continue;
}
table = table.addUniqueConstraint(`unique_${model}_${key}`, [key]);
table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, [key]);
} else {
// multi-field constraint
table = table.addUniqueConstraint(`unique_${model}_${key}`, Object.keys(value));
table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, Object.keys(value));
}
}
return table;
}

private createModelField(
table: CreateTableBuilder<any>,
fieldName: string,
fieldDef: FieldDef,
modelDef: ModelDef,
) {
return table.addColumn(fieldName, this.mapFieldType(fieldDef), (col) => {
private createModelField(table: CreateTableBuilder<any>, fieldDef: FieldDef, modelDef: ModelDef) {
return table.addColumn(fieldDef.name, this.mapFieldType(fieldDef), (col) => {
// @id
if (fieldDef.id && modelDef.idFields.length === 1) {
col = col.primaryKey();
Expand Down Expand Up @@ -178,7 +221,7 @@ export class SchemaDbPusher<Schema extends SchemaDef> {

private addForeignKeyConstraint(
table: CreateTableBuilder<string, any>,
model: GetModels<Schema>,
model: string,
fieldName: string,
fieldDef: FieldDef,
) {
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/sdk",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "ZenStack SDK",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/tanstack-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/tanstack-query",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "",
"main": "index.js",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/testtools/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/testtools",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "ZenStack Test Tools",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/typescript-config",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"private": true,
"license": "MIT"
}
2 changes: 1 addition & 1 deletion packages/vitest-config/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/vitest-config",
"type": "module",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"private": true,
"license": "MIT",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/zod/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/zod",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "",
"type": "module",
"main": "index.js",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion samples/blog/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sample-blog",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"description": "",
"main": "index.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "e2e",
"version": "3.0.0-alpha.21",
"version": "3.0.0-alpha.22",
"private": true,
"type": "module",
"scripts": {
Expand Down