From 92184841de9982862ab95b57588c7f571eea3e0d Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 5 May 2026 04:48:15 +0300 Subject: [PATCH 1/2] fix relations types circular error --- ...2026_05_02_115334_create_comments_table.ts | 17 +++ apps/blog-next/server/db/schema.generated.ts | 13 +- apps/blog-next/server/holo-models.d.ts | 57 ++++++++ apps/blog-next/server/models/Category.ts | 5 +- apps/blog-next/server/models/Comment.ts | 9 ++ apps/blog-next/server/models/Post.ts | 11 +- apps/blog-next/server/models/Tag.ts | 9 +- apps/blog-next/server/models/User.ts | 6 +- apps/blog-next/tests/blog-logic.mjs | 8 ++ ...2026_05_02_115438_create_comments_table.ts | 17 +++ apps/blog-nuxt/server/db/schema.generated.ts | 13 +- apps/blog-nuxt/server/holo-models.d.ts | 57 ++++++++ apps/blog-nuxt/server/models/Category.ts | 5 +- apps/blog-nuxt/server/models/Comment.ts | 11 ++ apps/blog-nuxt/server/models/Post.ts | 11 +- apps/blog-nuxt/server/models/Tag.ts | 9 +- apps/blog-nuxt/server/models/User.ts | 6 +- apps/blog-nuxt/tests/blog-logic.mjs | 8 ++ ...2026_05_02_115430_create_comments_table.ts | 17 +++ .../server/db/schema.generated.ts | 13 +- apps/blog-sveltekit/server/holo-models.d.ts | 57 ++++++++ apps/blog-sveltekit/server/models/Category.ts | 5 +- apps/blog-sveltekit/server/models/Comment.ts | 11 ++ apps/blog-sveltekit/server/models/Post.ts | 11 +- apps/blog-sveltekit/server/models/Tag.ts | 9 +- apps/blog-sveltekit/server/models/User.ts | 6 +- apps/blog-sveltekit/tests/blog-logic.mjs | 8 ++ apps/docs/docs/orm/index.md | 19 +++ apps/docs/docs/orm/relationships.md | 57 +++++++- .../docs/orm/relationships/many-to-many.md | 6 +- .../docs/orm/relationships/one-to-many.md | 7 +- .../docs/docs/orm/relationships/one-to-one.md | 7 +- bun.lock | 8 +- package.json | 1 + .../adapter-next/tests/adapter.type.test.ts | 18 +-- packages/adapter-nuxt/tests/setup.test.ts | 48 ++++--- .../adapter-sveltekit/tests/adapter.test.ts | 28 ++-- .../tests/adapter.type.test.ts | 18 +-- packages/cli/src/runtime.ts | 27 +++- packages/cli/tests/cli.test.ts | 79 +++++++----- packages/db/package.json | 5 +- packages/db/src/index.ts | 7 + packages/db/src/model/ModelRegistry.ts | 12 ++ packages/db/src/model/defineModel.ts | 2 + packages/db/src/model/defineModelHelpers.ts | 4 +- packages/db/src/model/index.ts | 7 + packages/db/src/model/relations.ts | 103 +++++++++++++-- packages/db/src/model/types.ts | 4 + packages/db/src/schema/pluralize.ts | 12 +- packages/db/tests/model-core.test.ts | 20 +++ packages/db/tests/schema-core.test.ts | 18 +++ packages/db/tests/type-system.test.ts | 60 +++++++++ packages/queue-db/tests/setup.test.ts | 32 ++--- packages/queue/tests/setup.test.ts | 27 ++-- tests/support/published-package.ts | 122 ++++++++++++++++++ 55 files changed, 976 insertions(+), 191 deletions(-) create mode 100644 apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts create mode 100644 apps/blog-next/server/holo-models.d.ts create mode 100644 apps/blog-next/server/models/Comment.ts create mode 100644 apps/blog-nuxt/server/db/migrations/2026_05_02_115438_create_comments_table.ts create mode 100644 apps/blog-nuxt/server/holo-models.d.ts create mode 100644 apps/blog-nuxt/server/models/Comment.ts create mode 100644 apps/blog-sveltekit/server/db/migrations/2026_05_02_115430_create_comments_table.ts create mode 100644 apps/blog-sveltekit/server/holo-models.d.ts create mode 100644 apps/blog-sveltekit/server/models/Comment.ts create mode 100644 tests/support/published-package.ts diff --git a/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts b/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts new file mode 100644 index 00000000..b5c11a57 --- /dev/null +++ b/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts @@ -0,0 +1,17 @@ +import { defineMigration, type MigrationContext } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }: MigrationContext) { + await schema.createTable('comments', (table) => { + table.id() + table.integer('post_id') + table.integer('user_id') + table.text('body') + table.string('status').default('pending') + table.timestamps() + }) + }, + async down({ schema }: MigrationContext) { + await schema.dropTable('comments') + }, +}) diff --git a/apps/blog-next/server/db/schema.generated.ts b/apps/blog-next/server/db/schema.generated.ts index d68ec722..be254d13 100644 --- a/apps/blog-next/server/db/schema.generated.ts +++ b/apps/blog-next/server/db/schema.generated.ts @@ -31,6 +31,16 @@ export const categories = defineGeneratedTable("categories", { "updated_at": column.timestamp().defaultNow(), }) +export const comments = defineGeneratedTable("comments", { + "id": column.id(), + "post_id": column.integer(), + "user_id": column.integer(), + "body": column.text(), + "status": column.string().default("pending"), + "created_at": column.timestamp().defaultNow(), + "updated_at": column.timestamp().defaultNow(), +}) + export const emailVerificationTokens = defineGeneratedTable("email_verification_tokens", { "id": column.uuid().primaryKey(), "provider": column.string().default("users"), @@ -134,6 +144,7 @@ declare module '@holo-js/db' { "_holo_migrations": typeof holoMigrations "auth_identities": typeof authIdentities "categories": typeof categories + "comments": typeof comments "email_verification_tokens": typeof emailVerificationTokens "notifications": typeof notifications "password_reset_tokens": typeof passwordResetTokens @@ -146,6 +157,6 @@ declare module '@holo-js/db' { } } -export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const +export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "comments": comments, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const registerGeneratedTables(tables) diff --git a/apps/blog-next/server/holo-models.d.ts b/apps/blog-next/server/holo-models.d.ts new file mode 100644 index 00000000..7ffa216b --- /dev/null +++ b/apps/blog-next/server/holo-models.d.ts @@ -0,0 +1,57 @@ +import type { + BelongsToManyRelationDefinition, + BelongsToRelationDefinition, + EmptyScopeMap, + GeneratedSchemaTable, + HasManyRelationDefinition, + ModelReference, + RelationMap, +} from '@holo-js/db' + +type CategoryTable = GeneratedSchemaTable<'categories'> +type CommentTable = GeneratedSchemaTable<'comments'> +type PostTable = GeneratedSchemaTable<'posts'> +type PostTagTable = GeneratedSchemaTable<'post_tags'> +type TagTable = GeneratedSchemaTable<'tags'> +type UserTable = GeneratedSchemaTable<'users'> + +interface CategoryRelations extends RelationMap { + readonly posts: HasManyRelationDefinition +} + +interface CommentRelations extends RelationMap { + readonly post: BelongsToRelationDefinition + readonly user: BelongsToRelationDefinition +} + +interface PostRelations extends RelationMap { + readonly user: BelongsToRelationDefinition + readonly category: BelongsToRelationDefinition + readonly tags: BelongsToManyRelationDefinition + readonly comments: HasManyRelationDefinition +} + +interface TagRelations extends RelationMap { + readonly posts: BelongsToManyRelationDefinition +} + +interface UserRelations extends RelationMap { + readonly posts: HasManyRelationDefinition + readonly comments: HasManyRelationDefinition +} + +type CategoryModel = ModelReference +type CommentModel = ModelReference +type PostModel = ModelReference +type TagModel = ModelReference +type UserModel = ModelReference + +declare module '@holo-js/db' { + interface RegisteredModels { + Category: CategoryModel + Comment: CommentModel + Post: PostModel + Tag: TagModel + User: UserModel + } +} diff --git a/apps/blog-next/server/models/Category.ts b/apps/blog-next/server/models/Category.ts index dcdb8628..801ab600 100644 --- a/apps/blog-next/server/models/Category.ts +++ b/apps/blog-next/server/models/Category.ts @@ -1,5 +1,8 @@ -import { defineModel } from '@holo-js/db' +import { defineModel, hasMany } from '@holo-js/db' export default defineModel('categories', { fillable: ['name', 'slug', 'description'], + relations: { + posts: hasMany('Post', { foreignKey: 'category_id' }), + }, }) diff --git a/apps/blog-next/server/models/Comment.ts b/apps/blog-next/server/models/Comment.ts new file mode 100644 index 00000000..8b7a05f6 --- /dev/null +++ b/apps/blog-next/server/models/Comment.ts @@ -0,0 +1,9 @@ +import { belongsTo, defineModel } from '@holo-js/db' + +export default defineModel('comments', { + fillable: ['post_id', 'user_id', 'body', 'status'], + relations: { + post: belongsTo('Post', { foreignKey: 'post_id' }), + user: belongsTo('User', { foreignKey: 'user_id' }), + }, +}) diff --git a/apps/blog-next/server/models/Post.ts b/apps/blog-next/server/models/Post.ts index a2dd591b..b241784a 100644 --- a/apps/blog-next/server/models/Post.ts +++ b/apps/blog-next/server/models/Post.ts @@ -1,15 +1,14 @@ -import { belongsTo, belongsToMany, defineModel } from '@holo-js/db' - -import Category from './Category' -import Tag from './Tag' +import { belongsTo, belongsToMany, defineModel, hasMany } from '@holo-js/db' const relations = { - category: belongsTo(() => Category, { foreignKey: 'category_id' }), - tags: belongsToMany(() => Tag, { + user: belongsTo('User', { foreignKey: 'user_id' }), + category: belongsTo('Category', { foreignKey: 'category_id' }), + tags: belongsToMany('Tag', { pivotTable: 'post_tags', foreignPivotKey: 'post_id', relatedPivotKey: 'tag_id', }), + comments: hasMany('Comment', { foreignKey: 'post_id' }), } export default defineModel('posts', { diff --git a/apps/blog-next/server/models/Tag.ts b/apps/blog-next/server/models/Tag.ts index 442fb854..2840e980 100644 --- a/apps/blog-next/server/models/Tag.ts +++ b/apps/blog-next/server/models/Tag.ts @@ -1,5 +1,12 @@ -import { defineModel } from '@holo-js/db' +import { belongsToMany, defineModel } from '@holo-js/db' export default defineModel('tags', { fillable: ['name', 'slug'], + relations: { + posts: belongsToMany('Post', { + pivotTable: 'post_tags', + foreignPivotKey: 'tag_id', + relatedPivotKey: 'post_id', + }), + }, }) diff --git a/apps/blog-next/server/models/User.ts b/apps/blog-next/server/models/User.ts index 3f4ff6a9..2de4b8ec 100644 --- a/apps/blog-next/server/models/User.ts +++ b/apps/blog-next/server/models/User.ts @@ -1,6 +1,10 @@ -import { defineModel } from '@holo-js/db' +import { defineModel, hasMany } from '@holo-js/db' export default defineModel('users', { fillable: ['name', 'email', 'password', 'avatar'], hidden: ['password'], + relations: { + posts: hasMany('Post', { foreignKey: 'user_id' }), + comments: hasMany('Comment', { foreignKey: 'user_id' }), + }, }) diff --git a/apps/blog-next/tests/blog-logic.mjs b/apps/blog-next/tests/blog-logic.mjs index f11b8fe6..a9ebfcaf 100644 --- a/apps/blog-next/tests/blog-logic.mjs +++ b/apps/blog-next/tests/blog-logic.mjs @@ -5,6 +5,7 @@ import { initializeHoloAdapterProject } from '@holo-js/core' import Category from '../server/models/Category.ts' import Post from '../server/models/Post.ts' import Tag from '../server/models/Tag.ts' +import User from '../server/models/User.ts' import { createCategory, createPost, @@ -37,6 +38,13 @@ try { assert.equal(home.categories.length, 2) assert.equal(home.tags.length, 3) + const firstUser = await User.with('posts').first() + assert.ok(firstUser) + assert.ok(Array.isArray(firstUser.posts)) + + const featuredPost = await Post.with('user').where('slug', home.featured?.slug ?? '').first() + assert.ok(featuredPost?.user) + const dashboard = await getAdminDashboardData() assert.equal(dashboard.postCount, 2) assert.equal(dashboard.publishedCount, 2) diff --git a/apps/blog-nuxt/server/db/migrations/2026_05_02_115438_create_comments_table.ts b/apps/blog-nuxt/server/db/migrations/2026_05_02_115438_create_comments_table.ts new file mode 100644 index 00000000..b5c11a57 --- /dev/null +++ b/apps/blog-nuxt/server/db/migrations/2026_05_02_115438_create_comments_table.ts @@ -0,0 +1,17 @@ +import { defineMigration, type MigrationContext } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }: MigrationContext) { + await schema.createTable('comments', (table) => { + table.id() + table.integer('post_id') + table.integer('user_id') + table.text('body') + table.string('status').default('pending') + table.timestamps() + }) + }, + async down({ schema }: MigrationContext) { + await schema.dropTable('comments') + }, +}) diff --git a/apps/blog-nuxt/server/db/schema.generated.ts b/apps/blog-nuxt/server/db/schema.generated.ts index d68ec722..be254d13 100644 --- a/apps/blog-nuxt/server/db/schema.generated.ts +++ b/apps/blog-nuxt/server/db/schema.generated.ts @@ -31,6 +31,16 @@ export const categories = defineGeneratedTable("categories", { "updated_at": column.timestamp().defaultNow(), }) +export const comments = defineGeneratedTable("comments", { + "id": column.id(), + "post_id": column.integer(), + "user_id": column.integer(), + "body": column.text(), + "status": column.string().default("pending"), + "created_at": column.timestamp().defaultNow(), + "updated_at": column.timestamp().defaultNow(), +}) + export const emailVerificationTokens = defineGeneratedTable("email_verification_tokens", { "id": column.uuid().primaryKey(), "provider": column.string().default("users"), @@ -134,6 +144,7 @@ declare module '@holo-js/db' { "_holo_migrations": typeof holoMigrations "auth_identities": typeof authIdentities "categories": typeof categories + "comments": typeof comments "email_verification_tokens": typeof emailVerificationTokens "notifications": typeof notifications "password_reset_tokens": typeof passwordResetTokens @@ -146,6 +157,6 @@ declare module '@holo-js/db' { } } -export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const +export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "comments": comments, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const registerGeneratedTables(tables) diff --git a/apps/blog-nuxt/server/holo-models.d.ts b/apps/blog-nuxt/server/holo-models.d.ts new file mode 100644 index 00000000..7ffa216b --- /dev/null +++ b/apps/blog-nuxt/server/holo-models.d.ts @@ -0,0 +1,57 @@ +import type { + BelongsToManyRelationDefinition, + BelongsToRelationDefinition, + EmptyScopeMap, + GeneratedSchemaTable, + HasManyRelationDefinition, + ModelReference, + RelationMap, +} from '@holo-js/db' + +type CategoryTable = GeneratedSchemaTable<'categories'> +type CommentTable = GeneratedSchemaTable<'comments'> +type PostTable = GeneratedSchemaTable<'posts'> +type PostTagTable = GeneratedSchemaTable<'post_tags'> +type TagTable = GeneratedSchemaTable<'tags'> +type UserTable = GeneratedSchemaTable<'users'> + +interface CategoryRelations extends RelationMap { + readonly posts: HasManyRelationDefinition +} + +interface CommentRelations extends RelationMap { + readonly post: BelongsToRelationDefinition + readonly user: BelongsToRelationDefinition +} + +interface PostRelations extends RelationMap { + readonly user: BelongsToRelationDefinition + readonly category: BelongsToRelationDefinition + readonly tags: BelongsToManyRelationDefinition + readonly comments: HasManyRelationDefinition +} + +interface TagRelations extends RelationMap { + readonly posts: BelongsToManyRelationDefinition +} + +interface UserRelations extends RelationMap { + readonly posts: HasManyRelationDefinition + readonly comments: HasManyRelationDefinition +} + +type CategoryModel = ModelReference +type CommentModel = ModelReference +type PostModel = ModelReference +type TagModel = ModelReference +type UserModel = ModelReference + +declare module '@holo-js/db' { + interface RegisteredModels { + Category: CategoryModel + Comment: CommentModel + Post: PostModel + Tag: TagModel + User: UserModel + } +} diff --git a/apps/blog-nuxt/server/models/Category.ts b/apps/blog-nuxt/server/models/Category.ts index dcdb8628..801ab600 100644 --- a/apps/blog-nuxt/server/models/Category.ts +++ b/apps/blog-nuxt/server/models/Category.ts @@ -1,5 +1,8 @@ -import { defineModel } from '@holo-js/db' +import { defineModel, hasMany } from '@holo-js/db' export default defineModel('categories', { fillable: ['name', 'slug', 'description'], + relations: { + posts: hasMany('Post', { foreignKey: 'category_id' }), + }, }) diff --git a/apps/blog-nuxt/server/models/Comment.ts b/apps/blog-nuxt/server/models/Comment.ts new file mode 100644 index 00000000..74af195e --- /dev/null +++ b/apps/blog-nuxt/server/models/Comment.ts @@ -0,0 +1,11 @@ +import { belongsTo, defineModel } from '@holo-js/db' + +const relations = { + post: belongsTo('Post', { foreignKey: 'post_id' }), + user: belongsTo('User', { foreignKey: 'user_id' }), +} + +export default defineModel('comments', { + fillable: ['post_id', 'user_id', 'body', 'status'], + relations, +}) diff --git a/apps/blog-nuxt/server/models/Post.ts b/apps/blog-nuxt/server/models/Post.ts index a2dd591b..b241784a 100644 --- a/apps/blog-nuxt/server/models/Post.ts +++ b/apps/blog-nuxt/server/models/Post.ts @@ -1,15 +1,14 @@ -import { belongsTo, belongsToMany, defineModel } from '@holo-js/db' - -import Category from './Category' -import Tag from './Tag' +import { belongsTo, belongsToMany, defineModel, hasMany } from '@holo-js/db' const relations = { - category: belongsTo(() => Category, { foreignKey: 'category_id' }), - tags: belongsToMany(() => Tag, { + user: belongsTo('User', { foreignKey: 'user_id' }), + category: belongsTo('Category', { foreignKey: 'category_id' }), + tags: belongsToMany('Tag', { pivotTable: 'post_tags', foreignPivotKey: 'post_id', relatedPivotKey: 'tag_id', }), + comments: hasMany('Comment', { foreignKey: 'post_id' }), } export default defineModel('posts', { diff --git a/apps/blog-nuxt/server/models/Tag.ts b/apps/blog-nuxt/server/models/Tag.ts index 442fb854..2840e980 100644 --- a/apps/blog-nuxt/server/models/Tag.ts +++ b/apps/blog-nuxt/server/models/Tag.ts @@ -1,5 +1,12 @@ -import { defineModel } from '@holo-js/db' +import { belongsToMany, defineModel } from '@holo-js/db' export default defineModel('tags', { fillable: ['name', 'slug'], + relations: { + posts: belongsToMany('Post', { + pivotTable: 'post_tags', + foreignPivotKey: 'tag_id', + relatedPivotKey: 'post_id', + }), + }, }) diff --git a/apps/blog-nuxt/server/models/User.ts b/apps/blog-nuxt/server/models/User.ts index 3f4ff6a9..2de4b8ec 100644 --- a/apps/blog-nuxt/server/models/User.ts +++ b/apps/blog-nuxt/server/models/User.ts @@ -1,6 +1,10 @@ -import { defineModel } from '@holo-js/db' +import { defineModel, hasMany } from '@holo-js/db' export default defineModel('users', { fillable: ['name', 'email', 'password', 'avatar'], hidden: ['password'], + relations: { + posts: hasMany('Post', { foreignKey: 'user_id' }), + comments: hasMany('Comment', { foreignKey: 'user_id' }), + }, }) diff --git a/apps/blog-nuxt/tests/blog-logic.mjs b/apps/blog-nuxt/tests/blog-logic.mjs index 9327fb83..71b8e936 100644 --- a/apps/blog-nuxt/tests/blog-logic.mjs +++ b/apps/blog-nuxt/tests/blog-logic.mjs @@ -5,6 +5,7 @@ import { initializeHoloAdapterProject } from '@holo-js/core' import Category from '../server/models/Category.ts' import Post from '../server/models/Post.ts' import Tag from '../server/models/Tag.ts' +import User from '../server/models/User.ts' import { createCategory, createPost, @@ -37,6 +38,13 @@ try { assert.equal(home.categories.length, 2) assert.equal(home.tags.length, 3) + const firstUser = await User.with('posts').first() + assert.ok(firstUser) + assert.ok(Array.isArray(firstUser.posts)) + + const featuredPost = await Post.with('user').where('slug', home.featured?.slug ?? '').first() + assert.ok(featuredPost?.user) + const dashboard = await getAdminDashboardData() assert.equal(dashboard.postCount, 2) assert.equal(dashboard.publishedCount, 2) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_05_02_115430_create_comments_table.ts b/apps/blog-sveltekit/server/db/migrations/2026_05_02_115430_create_comments_table.ts new file mode 100644 index 00000000..b5c11a57 --- /dev/null +++ b/apps/blog-sveltekit/server/db/migrations/2026_05_02_115430_create_comments_table.ts @@ -0,0 +1,17 @@ +import { defineMigration, type MigrationContext } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }: MigrationContext) { + await schema.createTable('comments', (table) => { + table.id() + table.integer('post_id') + table.integer('user_id') + table.text('body') + table.string('status').default('pending') + table.timestamps() + }) + }, + async down({ schema }: MigrationContext) { + await schema.dropTable('comments') + }, +}) diff --git a/apps/blog-sveltekit/server/db/schema.generated.ts b/apps/blog-sveltekit/server/db/schema.generated.ts index d68ec722..be254d13 100644 --- a/apps/blog-sveltekit/server/db/schema.generated.ts +++ b/apps/blog-sveltekit/server/db/schema.generated.ts @@ -31,6 +31,16 @@ export const categories = defineGeneratedTable("categories", { "updated_at": column.timestamp().defaultNow(), }) +export const comments = defineGeneratedTable("comments", { + "id": column.id(), + "post_id": column.integer(), + "user_id": column.integer(), + "body": column.text(), + "status": column.string().default("pending"), + "created_at": column.timestamp().defaultNow(), + "updated_at": column.timestamp().defaultNow(), +}) + export const emailVerificationTokens = defineGeneratedTable("email_verification_tokens", { "id": column.uuid().primaryKey(), "provider": column.string().default("users"), @@ -134,6 +144,7 @@ declare module '@holo-js/db' { "_holo_migrations": typeof holoMigrations "auth_identities": typeof authIdentities "categories": typeof categories + "comments": typeof comments "email_verification_tokens": typeof emailVerificationTokens "notifications": typeof notifications "password_reset_tokens": typeof passwordResetTokens @@ -146,6 +157,6 @@ declare module '@holo-js/db' { } } -export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const +export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "comments": comments, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const registerGeneratedTables(tables) diff --git a/apps/blog-sveltekit/server/holo-models.d.ts b/apps/blog-sveltekit/server/holo-models.d.ts new file mode 100644 index 00000000..7ffa216b --- /dev/null +++ b/apps/blog-sveltekit/server/holo-models.d.ts @@ -0,0 +1,57 @@ +import type { + BelongsToManyRelationDefinition, + BelongsToRelationDefinition, + EmptyScopeMap, + GeneratedSchemaTable, + HasManyRelationDefinition, + ModelReference, + RelationMap, +} from '@holo-js/db' + +type CategoryTable = GeneratedSchemaTable<'categories'> +type CommentTable = GeneratedSchemaTable<'comments'> +type PostTable = GeneratedSchemaTable<'posts'> +type PostTagTable = GeneratedSchemaTable<'post_tags'> +type TagTable = GeneratedSchemaTable<'tags'> +type UserTable = GeneratedSchemaTable<'users'> + +interface CategoryRelations extends RelationMap { + readonly posts: HasManyRelationDefinition +} + +interface CommentRelations extends RelationMap { + readonly post: BelongsToRelationDefinition + readonly user: BelongsToRelationDefinition +} + +interface PostRelations extends RelationMap { + readonly user: BelongsToRelationDefinition + readonly category: BelongsToRelationDefinition + readonly tags: BelongsToManyRelationDefinition + readonly comments: HasManyRelationDefinition +} + +interface TagRelations extends RelationMap { + readonly posts: BelongsToManyRelationDefinition +} + +interface UserRelations extends RelationMap { + readonly posts: HasManyRelationDefinition + readonly comments: HasManyRelationDefinition +} + +type CategoryModel = ModelReference +type CommentModel = ModelReference +type PostModel = ModelReference +type TagModel = ModelReference +type UserModel = ModelReference + +declare module '@holo-js/db' { + interface RegisteredModels { + Category: CategoryModel + Comment: CommentModel + Post: PostModel + Tag: TagModel + User: UserModel + } +} diff --git a/apps/blog-sveltekit/server/models/Category.ts b/apps/blog-sveltekit/server/models/Category.ts index dcdb8628..801ab600 100644 --- a/apps/blog-sveltekit/server/models/Category.ts +++ b/apps/blog-sveltekit/server/models/Category.ts @@ -1,5 +1,8 @@ -import { defineModel } from '@holo-js/db' +import { defineModel, hasMany } from '@holo-js/db' export default defineModel('categories', { fillable: ['name', 'slug', 'description'], + relations: { + posts: hasMany('Post', { foreignKey: 'category_id' }), + }, }) diff --git a/apps/blog-sveltekit/server/models/Comment.ts b/apps/blog-sveltekit/server/models/Comment.ts new file mode 100644 index 00000000..74af195e --- /dev/null +++ b/apps/blog-sveltekit/server/models/Comment.ts @@ -0,0 +1,11 @@ +import { belongsTo, defineModel } from '@holo-js/db' + +const relations = { + post: belongsTo('Post', { foreignKey: 'post_id' }), + user: belongsTo('User', { foreignKey: 'user_id' }), +} + +export default defineModel('comments', { + fillable: ['post_id', 'user_id', 'body', 'status'], + relations, +}) diff --git a/apps/blog-sveltekit/server/models/Post.ts b/apps/blog-sveltekit/server/models/Post.ts index a2dd591b..b241784a 100644 --- a/apps/blog-sveltekit/server/models/Post.ts +++ b/apps/blog-sveltekit/server/models/Post.ts @@ -1,15 +1,14 @@ -import { belongsTo, belongsToMany, defineModel } from '@holo-js/db' - -import Category from './Category' -import Tag from './Tag' +import { belongsTo, belongsToMany, defineModel, hasMany } from '@holo-js/db' const relations = { - category: belongsTo(() => Category, { foreignKey: 'category_id' }), - tags: belongsToMany(() => Tag, { + user: belongsTo('User', { foreignKey: 'user_id' }), + category: belongsTo('Category', { foreignKey: 'category_id' }), + tags: belongsToMany('Tag', { pivotTable: 'post_tags', foreignPivotKey: 'post_id', relatedPivotKey: 'tag_id', }), + comments: hasMany('Comment', { foreignKey: 'post_id' }), } export default defineModel('posts', { diff --git a/apps/blog-sveltekit/server/models/Tag.ts b/apps/blog-sveltekit/server/models/Tag.ts index 442fb854..2840e980 100644 --- a/apps/blog-sveltekit/server/models/Tag.ts +++ b/apps/blog-sveltekit/server/models/Tag.ts @@ -1,5 +1,12 @@ -import { defineModel } from '@holo-js/db' +import { belongsToMany, defineModel } from '@holo-js/db' export default defineModel('tags', { fillable: ['name', 'slug'], + relations: { + posts: belongsToMany('Post', { + pivotTable: 'post_tags', + foreignPivotKey: 'tag_id', + relatedPivotKey: 'post_id', + }), + }, }) diff --git a/apps/blog-sveltekit/server/models/User.ts b/apps/blog-sveltekit/server/models/User.ts index 3f4ff6a9..2de4b8ec 100644 --- a/apps/blog-sveltekit/server/models/User.ts +++ b/apps/blog-sveltekit/server/models/User.ts @@ -1,6 +1,10 @@ -import { defineModel } from '@holo-js/db' +import { defineModel, hasMany } from '@holo-js/db' export default defineModel('users', { fillable: ['name', 'email', 'password', 'avatar'], hidden: ['password'], + relations: { + posts: hasMany('Post', { foreignKey: 'user_id' }), + comments: hasMany('Comment', { foreignKey: 'user_id' }), + }, }) diff --git a/apps/blog-sveltekit/tests/blog-logic.mjs b/apps/blog-sveltekit/tests/blog-logic.mjs index a9e95ca8..2a59091f 100644 --- a/apps/blog-sveltekit/tests/blog-logic.mjs +++ b/apps/blog-sveltekit/tests/blog-logic.mjs @@ -5,6 +5,7 @@ import { initializeHoloAdapterProject } from '@holo-js/core' import Category from '../server/models/Category.ts' import Post from '../server/models/Post.ts' import Tag from '../server/models/Tag.ts' +import User from '../server/models/User.ts' import { createCategory, createPost, @@ -37,6 +38,13 @@ try { assert.equal(home.categories.length, 2) assert.equal(home.tags.length, 3) + const firstUser = await User.with('posts').first() + assert.ok(firstUser) + assert.ok(Array.isArray(firstUser.posts)) + + const featuredPost = await Post.with('user').where('slug', home.featured?.slug ?? '').first() + assert.ok(featuredPost?.user) + const dashboard = await getAdminDashboardData() assert.equal(dashboard.postCount, 2) assert.equal(dashboard.publishedCount, 2) diff --git a/apps/docs/docs/orm/index.md b/apps/docs/docs/orm/index.md index 14889ecd..5ed41096 100644 --- a/apps/docs/docs/orm/index.md +++ b/apps/docs/docs/orm/index.md @@ -63,6 +63,25 @@ For writes, mass assignment, and trusted write paths, see [ORM Writes](/orm/writ The table name is the first argument to `defineModel(...)`. +### Model Names + +Model names are inferred from the table name and used by string-based relation targets. + +- `defineModel('users', ...)` becomes `User` +- `defineModel('blog_posts', ...)` becomes `BlogPost` +- `defineModel('people', ...)` becomes `Person` +- `defineModel('children', ...)` becomes `Child` +- `defineModel('blue_lot_of_things', ...)` becomes `BlueLotOfThing` + +If the inferred name is not the public API you want, set it explicitly and use that exact name in +relations: + +```ts +const Person = defineModel('people', { + name: 'Person', +}) +``` + ### Primary Keys Primary key truth comes from migrations and the generated schema artifact. The model reads it from the diff --git a/apps/docs/docs/orm/relationships.md b/apps/docs/docs/orm/relationships.md index c63ec297..c980a994 100644 --- a/apps/docs/docs/orm/relationships.md +++ b/apps/docs/docs/orm/relationships.md @@ -10,9 +10,9 @@ Keep the schema responsible for columns and keys, then use the model to describe ```ts const User = defineModel('users', { relations: { - profile: hasOne(Profile, { foreignKey: 'user_id' }), - posts: hasMany(Post, { foreignKey: 'user_id' }), - roles: belongsToMany(Role, { + profile: hasOne('Profile', { foreignKey: 'user_id' }), + posts: hasMany('Post', { foreignKey: 'user_id' }), + roles: belongsToMany('Role', { pivotTable: 'role_user', foreignPivotKey: 'user_id', relatedPivotKey: 'role_id', @@ -21,6 +21,53 @@ const User = defineModel('users', { }) ``` +When models point at each other across files, prefer string relation targets. That avoids circular +inference errors while keeping eager-load and relation-name autocomplete once your models are registered. + +Framework adapters load `server/models` automatically. In custom runtimes, import your model files once +during boot before resolving string-based relations. + +## Relation Target Names + +String relation targets use the model name, not the file name. + +By default, Holo-JS infers the model name from the table name: + +- `defineModel('users', ...)` -> `User` +- `defineModel('blog_posts', ...)` -> `BlogPost` +- `defineModel('people', ...)` -> `Person` +- `defineModel('children', ...)` -> `Child` +- `defineModel('blue_lot_of_things', ...)` -> `BlueLotOfThing` + +That means these are valid relation targets: + +```ts +const Person = defineModel('people', { + relations: { + posts: hasMany('Post', { foreignKey: 'person_id' }), + }, +}) + +const Child = defineModel('children', { + relations: { + parent: belongsTo('Person', { foreignKey: 'person_id' }), + }, +}) +``` + +If your table name is unusual, not English, or you want a different public model name, set `name` +explicitly and use that value in relations: + +```ts +const Person = defineModel('people', { + name: 'Person', +}) + +const LegacyThing = defineModel('tbl_legacy_people', { + name: 'LegacyPerson', +}) +``` + When the foreign key lives on the current model, use `belongsTo`. When the foreign key lives on the other model, use `hasOne` or `hasMany`. When neither side can own the association directly, use a pivot table and `belongsToMany`. @@ -179,8 +226,8 @@ Inverse resolution is explicit when a model can reach the same target more than ```ts const Invoice = defineModel('invoices', { relations: { - billingAddress: belongsTo(Address, { foreignKey: 'billing_address_id' }), - shippingAddress: belongsTo(Address, { foreignKey: 'shipping_address_id' }), + billingAddress: belongsTo('Address', { foreignKey: 'billing_address_id' }), + shippingAddress: belongsTo('Address', { foreignKey: 'shipping_address_id' }), }, }) ``` diff --git a/apps/docs/docs/orm/relationships/many-to-many.md b/apps/docs/docs/orm/relationships/many-to-many.md index c82aaa53..9a1ee057 100644 --- a/apps/docs/docs/orm/relationships/many-to-many.md +++ b/apps/docs/docs/orm/relationships/many-to-many.md @@ -32,7 +32,7 @@ server/models/Role.ts ```ts const User = defineModel('users', { relations: { - roles: belongsToMany(() => Role, { + roles: belongsToMany('Role', { pivotTable: 'role_user', foreignPivotKey: 'user_id', relatedPivotKey: 'role_id', @@ -41,6 +41,10 @@ const User = defineModel('users', { }) ``` +The `'Role'` target is the model name, not the file name. By default Holo-JS infers that name from the +table, including irregular forms such as `people -> Person` and compound names such as +`blue_lot_of_things -> BlueLotOfThing`. + ## Load related models ```ts diff --git a/apps/docs/docs/orm/relationships/one-to-many.md b/apps/docs/docs/orm/relationships/one-to-many.md index 2baefab8..c75e0ae2 100644 --- a/apps/docs/docs/orm/relationships/one-to-many.md +++ b/apps/docs/docs/orm/relationships/one-to-many.md @@ -9,13 +9,16 @@ Use `hasMany` on the parent-facing side. ```ts const User = defineModel('users', { relations: { - posts: hasMany(Post, { + posts: hasMany('Post', { foreignKey: 'user_id', }), }, }) ``` +String relation targets use the model name inferred from the related table, so `posts -> Post`, +`people -> Person`, and `children -> Child`. + ### Load children ```ts @@ -60,7 +63,7 @@ Use `belongsTo` on the child-facing side when the child table stores the foreign ```ts const Post = defineModel('posts', { relations: { - author: belongsTo(User, { + author: belongsTo('User', { foreignKey: 'user_id', }), }, diff --git a/apps/docs/docs/orm/relationships/one-to-one.md b/apps/docs/docs/orm/relationships/one-to-one.md index f9b550b5..a147f05f 100644 --- a/apps/docs/docs/orm/relationships/one-to-one.md +++ b/apps/docs/docs/orm/relationships/one-to-one.md @@ -10,13 +10,16 @@ Use `hasOne` on the parent-facing side when the related table stores the foreign ```ts const User = defineModel('users', { relations: { - profile: hasOne(Profile, { + profile: hasOne('Profile', { foreignKey: 'user_id', }), }, }) ``` +String relation targets use the model name inferred from the related table, including irregular plurals +such as `people -> Person` and `children -> Child`. + ### Save or create the related model ```ts @@ -39,7 +42,7 @@ Use `belongsTo` on the inverse side when the current model stores the foreign ke ```ts const Profile = defineModel('profiles', { relations: { - user: belongsTo(User, { + user: belongsTo('User', { foreignKey: 'user_id', }), }, diff --git a/bun.lock b/bun.lock index fa880f36..86bc2ff4 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,7 @@ "@holo-js/cache": "^0.1.4", "@holo-js/cli": "workspace:*", "@holo-js/config": "workspace:*", + "@holo-js/core": "^0.1.4", "@holo-js/db": "workspace:*", "@holo-js/db-mysql": "^0.1.4", "@holo-js/db-postgres": "^0.1.4", @@ -160,7 +161,7 @@ "@holo-js/cache-db": "^0.1.4", "@holo-js/cli": "workspace:*", "@holo-js/config": "workspace:*", - "@holo-js/core": "workspace:*", + "@holo-js/core": "^0.1.4", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "^0.1.4", "@holo-js/events": "^0.1.4", @@ -252,6 +253,7 @@ "@holo-js/cache": "^0.1.4", "@holo-js/cli": "workspace:*", "@holo-js/config": "workspace:*", + "@holo-js/core": "^0.1.4", "@holo-js/db": "workspace:*", "@holo-js/db-mysql": "^0.1.4", "@holo-js/db-postgres": "^0.1.4", @@ -679,6 +681,7 @@ "name": "@holo-js/db", "version": "0.1.4", "dependencies": { + "inflection": "catalog:", "ulid": "^3.0.1", "uuid": "^12.0.0", }, @@ -1075,6 +1078,7 @@ "eslint": "^9.17.0", "fast-check": "^4.5.3", "globals": "^15.14.0", + "inflection": "^3.0.2", "ioredis": "^5.4.2", "mysql2": "^3.17.1", "nuxt": "^4.0.0", @@ -2441,6 +2445,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inflection": ["inflection@3.0.2", "", {}, "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], diff --git a/package.json b/package.json index ed45d960..0b2edafd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "esbuild": "^0.27.4", "globals": "^15.14.0", "@types/pg": "^8.11.0", + "inflection": "^3.0.2", "mysql2": "^3.17.1", "pg": "^8.13.0", "ioredis": "^5.4.2", diff --git a/packages/adapter-next/tests/adapter.type.test.ts b/packages/adapter-next/tests/adapter.type.test.ts index be49d58d..4f9e51b3 100644 --- a/packages/adapter-next/tests/adapter.type.test.ts +++ b/packages/adapter-next/tests/adapter.type.test.ts @@ -1,9 +1,13 @@ import { execFileSync } from 'node:child_process' -import { cp, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { createNextHoloHelpers } from '../src' +import { + linkInstalledDependenciesForPackage, + stagePublishedPackage, +} from '../../../tests/support/published-package' const packageDir = resolve(import.meta.dirname, '..') @@ -18,12 +22,6 @@ function buildPackage(packageRoot: string, outDir?: string): void { }) } -async function stagePublishedPackage(sourceDir: string, targetDir: string, distDir: string): Promise { - await mkdir(targetDir, { recursive: true }) - await writeFile(join(targetDir, 'package.json'), await readFile(join(sourceDir, 'package.json'), 'utf8')) - await cp(distDir, join(targetDir, 'dist'), { recursive: true }) -} - declare module '@holo-js/config' { interface HoloConfigRegistry { services: { @@ -70,7 +68,11 @@ describe('@holo-js/adapter-next typing', () => { try { await mkdir(tempHoloNodeModules, { recursive: true }) - await symlink(resolve(packageDir, '../validation/node_modules/valibot'), join(tempNodeModules, 'valibot')) + await linkInstalledDependenciesForPackage({ + repoRoot: resolve(packageDir, '../..'), + nodeModulesRoot: tempNodeModules, + packageJsonPath: resolve(packageDir, '../validation/package.json'), + }) buildPackage(resolve(packageDir, '../validation'), join(buildRoot, 'validation')) buildPackage(resolve(packageDir, '../forms'), join(buildRoot, 'forms')) diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts index aa07f507..6afd984b 100644 --- a/packages/adapter-nuxt/tests/setup.test.ts +++ b/packages/adapter-nuxt/tests/setup.test.ts @@ -1,10 +1,15 @@ import { existsSync } from 'node:fs' import { execFileSync } from 'node:child_process' -import { cp, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { + linkInstalledDependenciesForPackage, + provisionTempPackage, + stagePublishedPackage, +} from '../../../tests/support/published-package' const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const repoRoot = resolve(packageDir, '../..') @@ -13,7 +18,6 @@ const nitropackPackageDir = resolve(repoRoot, 'node_modules/.bun/node_modules/ni const tempDirs: string[] = [] const tempBuildRoots: string[] = [] let adapterBuildPromise: Promise<{ adapterOutDir: string }> | null = null -const dbRuntimeDependencyNames = ['better-sqlite3', 'mysql2', 'pg', 'ulid', 'uuid'] as const type RuntimeConfigShape = Record type NuxtHarnessOptions = { @@ -43,20 +47,6 @@ async function createTempBuildRoot(prefix: string): Promise { return root } -async function provisionTempPackage(sourcePackageDir: string, tempPackageDir: string): Promise { - await cp(sourcePackageDir, tempPackageDir, { - recursive: true, - filter(source) { - return !source.includes('/dist/') - && !source.endsWith('/dist') - && !source.includes('/tests/') - && !source.endsWith('/tests') - && !source.includes('/node_modules/') - && !source.endsWith('/node_modules') - }, - }) -} - async function runPackageBuild(command: string, args: string[], targetPackageDir: string, outDir?: string): Promise { const env: NodeJS.ProcessEnv = { ...process.env, @@ -75,12 +65,6 @@ async function runPackageBuild(command: string, args: string[], targetPackageDir }) } -async function stagePublishedPackage(sourceDir: string, targetDir: string, distDir: string): Promise { - await mkdir(targetDir, { recursive: true }) - await writeFile(join(targetDir, 'package.json'), await readFile(join(sourceDir, 'package.json'), 'utf8')) - await cp(distDir, join(targetDir, 'dist'), { recursive: true }) -} - async function runAdapterStub(): Promise<{ adapterOutDir: string }> { if (!adapterBuildPromise) { adapterBuildPromise = (async () => { @@ -108,9 +92,17 @@ async function runAdapterStub(): Promise<{ adapterOutDir: string }> { await symlink(resolve(repoRoot, 'node_modules/.bun/node_modules/bullmq'), join(tempRootNodeModules, 'bullmq')) await symlink(resolve(packageDir, 'node_modules/@nuxt'), join(tempRootNodeModules, '@nuxt')) await symlink(nitropackPackageDir, join(tempRootNodeModules, 'nitropack')) - for (const dependencyName of dbRuntimeDependencyNames) { - await symlink(resolve(repoRoot, 'node_modules', dependencyName), join(tempRootNodeModules, dependencyName)) - } + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot: tempRootNodeModules, + packageJsonPath: resolve(packageDir, '../db/package.json'), + }) + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot: tempRootNodeModules, + packageJsonPath: resolve(packageDir, '../queue/package.json'), + extraDependencyNames: ['bullmq'], + }) await provisionTempPackage(resolve(packageDir, '../db'), dbPackageRoot) await provisionTempPackage(resolve(packageDir, '../config'), configPackageRoot) @@ -288,7 +280,11 @@ describe('@holo-js/adapter-nuxt module setup', () => { try { await mkdir(tempHoloNodeModules, { recursive: true }) - await symlink(resolve(packageDir, '../validation/node_modules/valibot'), join(tempNodeModules, 'valibot')) + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot: tempNodeModules, + packageJsonPath: resolve(packageDir, '../validation/package.json'), + }) await runPackageBuild(resolve(packageDir, '../validation/node_modules/.bin/tsup'), [], resolve(packageDir, '../validation'), join(buildRoot, 'validation')) await runPackageBuild(resolve(packageDir, '../forms/node_modules/.bin/tsup'), [], resolve(packageDir, '../forms'), join(buildRoot, 'forms')) diff --git a/packages/adapter-sveltekit/tests/adapter.test.ts b/packages/adapter-sveltekit/tests/adapter.test.ts index 112a2115..2840ab5e 100644 --- a/packages/adapter-sveltekit/tests/adapter.test.ts +++ b/packages/adapter-sveltekit/tests/adapter.test.ts @@ -5,13 +5,13 @@ import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { writeConfigCache } from '@holo-js/config' +import { linkInstalledDependenciesForPackage } from '../../../tests/support/published-package' import type * as AdapterModule from '../src' const configEntry = JSON.stringify(resolve(import.meta.dirname, '../../config/src/index.ts')) const repoRoot = resolve(import.meta.dirname, '../../..') const tempDirs: string[] = [] const tempBuildRoots: string[] = [] -const dbRuntimeDependencyNames = ['better-sqlite3', 'mysql2', 'pg', 'ulid', 'uuid'] as const let coreBuildPromise: Promise<{ coreEntryUrl: string }> | null = null let adapterModulePromise: Promise | null = null @@ -35,17 +35,6 @@ async function linkPackageDependency( await symlink(dependencyRoot, dependencyPath) } -async function linkExternalDependency( - targetPackageDir: string, - dependencyName: string, -): Promise { - await linkPackageDependency( - targetPackageDir, - dependencyName, - join(repoRoot, 'node_modules', ...dependencyName.split('/')), - ) -} - function buildWorkspacePackage(filter: string, outDir: string): void { const env: NodeJS.ProcessEnv = { ...process.env, @@ -75,13 +64,20 @@ async function ensureIsolatedCoreBuild(): Promise<{ coreEntryUrl: string }> { const corePackageRoot = join(root, 'packages/core') await writePackageWrapper(resolve(repoRoot, 'packages/db'), dbPackageRoot) - for (const dependencyName of dbRuntimeDependencyNames) { - await linkExternalDependency(dbPackageRoot, dependencyName) - } + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot: join(dbPackageRoot, 'node_modules'), + packageJsonPath: resolve(repoRoot, 'packages/db/package.json'), + }) buildWorkspacePackage('@holo-js/db', join(dbPackageRoot, 'dist')) await writePackageWrapper(resolve(repoRoot, 'packages/queue'), queuePackageRoot) - await linkExternalDependency(queuePackageRoot, 'bullmq') + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot: join(queuePackageRoot, 'node_modules'), + packageJsonPath: resolve(repoRoot, 'packages/queue/package.json'), + extraDependencyNames: ['bullmq'], + }) buildWorkspacePackage('@holo-js/queue', join(queuePackageRoot, 'dist')) await writePackageWrapper(resolve(repoRoot, 'packages/queue-db'), queueDbPackageRoot) diff --git a/packages/adapter-sveltekit/tests/adapter.type.test.ts b/packages/adapter-sveltekit/tests/adapter.type.test.ts index 6546317c..69fc9b44 100644 --- a/packages/adapter-sveltekit/tests/adapter.type.test.ts +++ b/packages/adapter-sveltekit/tests/adapter.type.test.ts @@ -1,10 +1,14 @@ import { execFileSync } from 'node:child_process' -import { cp, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { createSvelteKitHoloHelpers } from '../src' import type { SerializedSvelteKitData } from '../src/transport' +import { + linkInstalledDependenciesForPackage, + stagePublishedPackage, +} from '../../../tests/support/published-package' const packageDir = resolve(import.meta.dirname, '..') @@ -19,12 +23,6 @@ function buildPackage(packageRoot: string, outDir: string): void { }) } -async function stagePublishedPackage(sourceDir: string, targetDir: string, distDir: string): Promise { - await mkdir(targetDir, { recursive: true }) - await writeFile(join(targetDir, 'package.json'), await readFile(join(sourceDir, 'package.json'), 'utf8')) - await cp(distDir, join(targetDir, 'dist'), { recursive: true }) -} - declare module '@holo-js/config' { interface HoloConfigRegistry { services: { @@ -109,7 +107,11 @@ describe('@holo-js/adapter-sveltekit typing', () => { try { await mkdir(tempHoloNodeModules, { recursive: true }) - await symlink(resolve(packageDir, '../validation/node_modules/valibot'), join(tempNodeModules, 'valibot')) + await linkInstalledDependenciesForPackage({ + repoRoot: resolve(packageDir, '../..'), + nodeModulesRoot: tempNodeModules, + packageJsonPath: resolve(packageDir, '../validation/package.json'), + }) buildPackage(resolve(packageDir, '../validation'), join(buildRoot, 'validation')) buildPackage(resolve(packageDir, '../forms'), join(buildRoot, 'forms')) diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index eca67706..325c963a 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -379,7 +379,10 @@ try { if (executed.length === 0) { console.log('No migrations were executed.') } else { - console.log(\`Migrations executed: \${executed.map(item => item.name).join(', ')}\`) + console.log('Migrations executed:') + for (const item of executed) { + console.log(\` \${item.name}\`) + } } } else if (payload.kind === 'fresh') { const migrations = [] @@ -401,7 +404,10 @@ try { if (executed.length === 0) { console.log('No migrations were executed.') } else { - console.log(\`Migrations executed: \${executed.map(item => item.name).join(', ')}\`) + console.log('Migrations executed:') + for (const item of executed) { + console.log(\` \${item.name}\`) + } } if (payload.options?.seed) { @@ -423,7 +429,10 @@ try { if (seeded.length === 0) { console.log('No seeders were executed.') } else { - console.log(\`Seeders executed: \${seeded.map(item => item.name).join(', ')}\`) + console.log('Seeders executed:') + for (const item of seeded) { + console.log(\` \${item.name}\`) + } } } } else if (payload.kind === 'rollback') { @@ -440,9 +449,12 @@ try { const rolledBack = await createMigrationService(manager.connection(), migrations).rollback(payload.options ?? {}) await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) if (rolledBack.length === 0) { - console.log('No migrations were executed.') + console.log('No migrations were rolled back.') } else { - console.log(\`Migrations executed: \${rolledBack.map(item => item.name).join(', ')}\`) + console.log('Migrations rolled back:') + for (const item of rolledBack) { + console.log(\` \${item.name}\`) + } } } else if (payload.kind === 'seed') { if (payload.generatedSchema) { @@ -462,7 +474,10 @@ try { if (executed.length === 0) { console.log('No seeders were executed.') } else { - console.log(\`Seeders executed: \${executed.map(item => item.name).join(', ')}\`) + console.log('Seeders executed:') + for (const item of executed) { + console.log(\` \${item.name}\`) + } } } else if (payload.kind === 'prune') { const models = [] diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index f076a05a..2e750429 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -10,6 +10,11 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { loadConfigDirectory } from '@holo-js/config' import { initializeHolo } from '@holo-js/core' import { DB, createSchemaService } from '@holo-js/db' +import { + linkInstalledDependenciesForPackageSync, + resolveInstalledDependencyRoot, + symlinkPackageDependencySync, +} from '../../../tests/support/published-package' import type * as HoloConfigModule from '@holo-js/config' import type * as HoloCoreModule from '@holo-js/core' import type * as HoloDbModule from '@holo-js/db' @@ -70,7 +75,6 @@ import { ESBUILD_PACKAGE_VERSION, HOLO_PACKAGE_VERSION } from '../src/metadata' import type { FSWatcher } from 'node:fs' const workspaceRoot = resolve(import.meta.dirname, '../../..') -const bunStoreNodeModulesRoot = join(workspaceRoot, 'node_modules/.bun/node_modules') type BuiltWorkspacePackages = { readonly root: string readonly broadcastPackageRoot: string @@ -124,22 +128,6 @@ function linkPackageDependencySync( symlinkSync(dependencyRoot, dependencyPath) } -function linkExternalDependencySync(targetPackageDir: string, dependencyName: string): void { - linkPackageDependencySync( - targetPackageDir, - dependencyName, - join(workspaceRoot, 'node_modules', ...dependencyName.split('/')), - ) -} - -function linkBunStoreDependencySync(targetPackageDir: string, dependencyName: string): void { - linkPackageDependencySync( - targetPackageDir, - dependencyName, - join(bunStoreNodeModulesRoot, ...dependencyName.split('/')), - ) -} - function buildWorkspacePackageSync(filter: string, outDir: string) { return spawnSync('bun', ['run', '--filter', filter, 'build'], { cwd: workspaceRoot, @@ -176,23 +164,38 @@ function ensureBuiltWorkspacePackagesSync(): BuiltWorkspacePackages { const cliPackageRoot = join(root, 'packages/cli') writePackageWrapperSync(resolve(workspaceRoot, 'packages/db-sqlite'), dbSqlitePackageRoot) - linkExternalDependencySync(dbSqlitePackageRoot, 'better-sqlite3') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(dbSqlitePackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/db-sqlite/package.json'), + }) const dbSqliteBuild = buildWorkspacePackageSync('@holo-js/db-sqlite', join(dbSqlitePackageRoot, 'dist')) expect(dbSqliteBuild.status, dbSqliteBuild.stderr || dbSqliteBuild.stdout).toBe(0) writePackageWrapperSync(resolve(workspaceRoot, 'packages/db-postgres'), dbPostgresPackageRoot) - linkExternalDependencySync(dbPostgresPackageRoot, 'pg') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(dbPostgresPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/db-postgres/package.json'), + }) const dbPostgresBuild = buildWorkspacePackageSync('@holo-js/db-postgres', join(dbPostgresPackageRoot, 'dist')) expect(dbPostgresBuild.status, dbPostgresBuild.stderr || dbPostgresBuild.stdout).toBe(0) writePackageWrapperSync(resolve(workspaceRoot, 'packages/db-mysql'), dbMysqlPackageRoot) - linkExternalDependencySync(dbMysqlPackageRoot, 'mysql2') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(dbMysqlPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/db-mysql/package.json'), + }) const dbMysqlBuild = buildWorkspacePackageSync('@holo-js/db-mysql', join(dbMysqlPackageRoot, 'dist')) expect(dbMysqlBuild.status, dbMysqlBuild.stderr || dbMysqlBuild.stdout).toBe(0) writePackageWrapperSync(resolve(workspaceRoot, 'packages/db'), dbPackageRoot) - linkExternalDependencySync(dbPackageRoot, 'ulid') - linkExternalDependencySync(dbPackageRoot, 'uuid') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(dbPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/db/package.json'), + }) linkPackageDependencySync(dbPackageRoot, '@holo-js/db-sqlite', dbSqlitePackageRoot) linkPackageDependencySync(dbPackageRoot, '@holo-js/db-postgres', dbPostgresPackageRoot) linkPackageDependencySync(dbPackageRoot, '@holo-js/db-mysql', dbMysqlPackageRoot) @@ -200,7 +203,11 @@ function ensureBuiltWorkspacePackagesSync(): BuiltWorkspacePackages { expect(dbBuild.status, dbBuild.stderr || dbBuild.stdout).toBe(0) writePackageWrapperSync(resolve(workspaceRoot, 'packages/queue-redis'), queueRedisPackageRoot) - linkBunStoreDependencySync(queueRedisPackageRoot, 'bullmq') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(queueRedisPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/queue-redis/package.json'), + }) const queueRedisBuild = buildWorkspacePackageSync('@holo-js/queue-redis', join(queueRedisPackageRoot, 'dist')) expect(queueRedisBuild.status, queueRedisBuild.stderr || queueRedisBuild.stdout).toBe(0) @@ -228,14 +235,22 @@ function ensureBuiltWorkspacePackagesSync(): BuiltWorkspacePackages { expect(configBuild.status, configBuild.stderr || configBuild.stdout).toBe(0) writePackageWrapperSync(resolve(workspaceRoot, 'packages/validation'), validationPackageRoot) - linkBunStoreDependencySync(validationPackageRoot, 'valibot') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(validationPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/validation/package.json'), + }) const validationBuild = buildWorkspacePackageSync('@holo-js/validation', join(validationPackageRoot, 'dist')) expect(validationBuild.status, validationBuild.stderr || validationBuild.stdout).toBe(0) writePackageWrapperSync(resolve(workspaceRoot, 'packages/broadcast'), broadcastPackageRoot) linkPackageDependencySync(broadcastPackageRoot, '@holo-js/config', configPackageRoot) linkPackageDependencySync(broadcastPackageRoot, '@holo-js/validation', validationPackageRoot) - linkExternalDependencySync(broadcastPackageRoot, 'ws') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(broadcastPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/broadcast/package.json'), + }) const broadcastBuild = buildWorkspacePackageSync('@holo-js/broadcast', join(broadcastPackageRoot, 'dist')) expect(broadcastBuild.status, broadcastBuild.stderr || broadcastBuild.stdout).toBe(0) @@ -258,7 +273,11 @@ function ensureBuiltWorkspacePackagesSync(): BuiltWorkspacePackages { linkPackageDependencySync(mailPackageRoot, '@holo-js/config', configPackageRoot) linkPackageDependencySync(mailPackageRoot, '@holo-js/queue', queuePackageRoot) linkPackageDependencySync(mailPackageRoot, '@holo-js/storage', storagePackageRoot) - linkExternalDependencySync(mailPackageRoot, 'nodemailer') + linkInstalledDependenciesForPackageSync({ + repoRoot: workspaceRoot, + nodeModulesRoot: join(mailPackageRoot, 'node_modules'), + packageJsonPath: resolve(workspaceRoot, 'packages/mail/package.json'), + }) const mailBuild = buildWorkspacePackageSync('@holo-js/mail', join(mailPackageRoot, 'dist')) expect(mailBuild.status, mailBuild.stderr || mailBuild.stdout).toBe(0) @@ -435,15 +454,9 @@ async function writeProjectFile(projectRoot: string, relativePath: string, conte async function linkWorkspaceExternalDependency(projectRoot: string, dependencyName: string): Promise { const dependencyPath = join(projectRoot, 'node_modules', ...dependencyName.split('/')) - const workspaceDependencyPath = join(workspaceRoot, 'node_modules', ...dependencyName.split('/')) - const bunStoreDependencyPath = join(bunStoreNodeModulesRoot, ...dependencyName.split('/')) - const dependencyRoot = await stat(workspaceDependencyPath) - .then(() => workspaceDependencyPath) - .catch(async () => await stat(bunStoreDependencyPath) - .then(() => bunStoreDependencyPath)) await mkdir(dirname(dependencyPath), { recursive: true }) await rm(dependencyPath, { recursive: true, force: true }).catch(() => {}) - await symlink(dependencyRoot, dependencyPath).catch(() => {}) + await symlink(resolveInstalledDependencyRoot(workspaceRoot, dependencyName), dependencyPath).catch(() => {}) } async function linkWorkspaceConfig(projectRoot: string): Promise { diff --git a/packages/db/package.json b/packages/db/package.json index 73d03599..d57cbc73 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,8 +23,9 @@ "test": "vitest --run" }, "dependencies": { - "ulid": "^3.0.1", - "uuid": "^12.0.0" + "inflection": "catalog:", + "ulid": "catalog:", + "uuid": "catalog:" }, "peerDependencies": { "@holo-js/db-mysql": "^0.1.4", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 0d774247..a0272a37 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -229,6 +229,10 @@ export type { SQLiteStatementLike, } from './drivers' export type { + BelongsToManyRelationDefinition, + BelongsToRelationDefinition, + HasManyRelationDefinition, + HasOneRelationDefinition, ModelCollection, } from './model' export type { @@ -312,6 +316,9 @@ export type { export type { DefineModelOptions, EntityWithLoaded, + RegisteredModelName, + RegisteredModelReference, + RegisteredModels, ModelAttributeKey, ModelDefinition, DynamicRelationResolver, diff --git a/packages/db/src/model/ModelRegistry.ts b/packages/db/src/model/ModelRegistry.ts index 3f55bd17..a27b1ee4 100644 --- a/packages/db/src/model/ModelRegistry.ts +++ b/packages/db/src/model/ModelRegistry.ts @@ -5,6 +5,8 @@ function resolveDefinition(reference: ModelDefinitionLike): AnyModelDefinition { return 'definition' in reference ? reference.definition : reference } +const globalModels = new Map() + export class ModelRegistry { private readonly models = new Map() @@ -39,3 +41,13 @@ export class ModelRegistry { export function createModelRegistry(): ModelRegistry { return new ModelRegistry() } + +export function registerGlobalModel(reference: ModelDefinitionLike): ModelDefinitionLike { + const definition = resolveDefinition(reference) + globalModels.set(definition.name, reference) + return reference +} + +export function getGlobalModel(name: string): ModelDefinitionLike | undefined { + return globalModels.get(name) +} diff --git a/packages/db/src/model/defineModel.ts b/packages/db/src/model/defineModel.ts index d8042ac6..915e3e2b 100644 --- a/packages/db/src/model/defineModel.ts +++ b/packages/db/src/model/defineModel.ts @@ -1,6 +1,7 @@ import { registerDynamicRelation } from './dynamicRelations' import { withoutModelEvents, withoutModelGuards } from './eventState' import { ModelRepository } from './ModelRepository' +import { registerGlobalModel } from './ModelRegistry' import { registerMorphModel } from './morphRegistry' import { setAutomaticEagerLoading, setPreventAccessingMissingAttributes, setPreventLazyLoading } from './runtimeSettings' import { resolveUniqueIdConfig, validateUniqueIdConfig } from './uniqueIds' @@ -899,6 +900,7 @@ function createStaticModelApi< ;(model as Record)[name] = (...args: readonly unknown[]) => scoped(model.query(), ...args) } + registerGlobalModel(model) registerMorphModel(definition.morphClass, model) return Object.freeze(model) as unknown as StaticModelApi diff --git a/packages/db/src/model/defineModelHelpers.ts b/packages/db/src/model/defineModelHelpers.ts index d0eb5d93..efc19609 100644 --- a/packages/db/src/model/defineModelHelpers.ts +++ b/packages/db/src/model/defineModelHelpers.ts @@ -1,3 +1,4 @@ +import { singularize } from 'inflection' import { SchemaError } from '../core/errors' import { TableDefinitionBuilder } from '../schema/TableDefinitionBuilder' import { getGeneratedTableDefinition } from '../schema/generated' @@ -31,7 +32,8 @@ export function buildModelTable< } export function inferModelName(tableName: string): string { - const singular = tableName.endsWith('s') ? tableName.slice(0, -1) : tableName + const unqualifiedName = tableName.split('.').at(-1) ?? tableName + const singular = singularize(unqualifiedName) return singular .split(/[_-]/g) .filter(Boolean) diff --git a/packages/db/src/model/index.ts b/packages/db/src/model/index.ts index 274f75ac..d1b16a5f 100644 --- a/packages/db/src/model/index.ts +++ b/packages/db/src/model/index.ts @@ -42,17 +42,24 @@ export { export type { ModelCollection } from './collection' export type { Model } from './Entity' export type { + BelongsToManyRelationDefinition, + BelongsToRelationDefinition, DefineModelOptions, EntityWithLoaded, ModelAttributeKey, ModelTrait, ModelDefinition, + HasManyRelationDefinition, + HasOneRelationDefinition, ModelInsertPayload, ModelRecord, ModelReference, DynamicRelationResolver, EmptyScopeMap, EnumCastDefinition, + RegisteredModelName, + RegisteredModelReference, + RegisteredModels, RelationDefinition, RelationMap, ModelScopeArgs, diff --git a/packages/db/src/model/relations.ts b/packages/db/src/model/relations.ts index 714491ec..cd3a0a40 100644 --- a/packages/db/src/model/relations.ts +++ b/packages/db/src/model/relations.ts @@ -15,15 +15,20 @@ import type { MorphToManyRelationDefinition, PivotTableColumnName, PivotRelationMethods, + RegisteredModelName, + RegisteredModelReference, RelationConstraintDefinition, RelationDefinition, } from './types' +import { getGlobalModel } from './ModelRegistry' +import { RelationError } from '../core/errors' import type { TableDefinition } from '../schema/types' type PivotMethodKeys = 'withPivot' | 'wherePivot' | 'orderByPivot' | 'as' | 'using' type BareBelongsToManyRelation = Omit type BareMorphToManyRelation = Omit type BareMorphedByManyRelation = Omit +type RelatedInput> = RegisteredModelName | (() => TRelated) function defaultMorphTypeColumn(name: string): string { return `${name}_type` @@ -33,6 +38,25 @@ function defaultMorphIdColumn(name: string): string { return `${name}_id` } +function resolveRegisteredModel(name: string): ModelDefinitionLike { + const model = getGlobalModel(name) + if (!model) { + throw new RelationError( + `Relation target "${name}" is not registered. Import the related model once during boot or use your framework's model discovery.`, + ) + } + + return model +} + +function normalizeRelatedResolver(related: RegisteredModelName | (() => ModelDefinitionLike)): () => ModelDefinitionLike { + if (typeof related === 'string') { + return () => resolveRegisteredModel(related) + } + + return related +} + function decoratePivotRelation( + related: TName, + foreignKeyOrOptions: string | { foreignKey: string, ownerKey?: string }, + ownerKey?: string, +): BelongsToRelationDefinition> export function belongsTo>( related: () => TRelated, foreignKeyOrOptions: string | { foreignKey: string, ownerKey?: string }, + ownerKey?: string, +): BelongsToRelationDefinition +export function belongsTo( + related: RelatedInput>, + foreignKeyOrOptions: string | { foreignKey: string, ownerKey?: string }, ownerKey = 'id', -): BelongsToRelationDefinition { +): BelongsToRelationDefinition> { const foreignKey = typeof foreignKeyOrOptions === 'string' ? foreignKeyOrOptions : foreignKeyOrOptions.foreignKey @@ -110,17 +144,27 @@ export function belongsTo> return Object.freeze({ kind: 'belongsTo', - related, + related: normalizeRelatedResolver(related), foreignKey, ownerKey: resolvedOwnerKey, }) } +export function hasMany( + related: TName, + foreignKeyOrOptions: string | { foreignKey: string, localKey?: string }, + localKey?: string, +): HasManyRelationDefinition> export function hasMany>( related: () => TRelated, foreignKeyOrOptions: string | { foreignKey: string, localKey?: string }, + localKey?: string, +): HasManyRelationDefinition +export function hasMany( + related: RelatedInput>, + foreignKeyOrOptions: string | { foreignKey: string, localKey?: string }, localKey = 'id', -): HasManyRelationDefinition { +): HasManyRelationDefinition> { const foreignKey = typeof foreignKeyOrOptions === 'string' ? foreignKeyOrOptions : foreignKeyOrOptions.foreignKey @@ -130,17 +174,27 @@ export function hasMany>( return Object.freeze({ kind: 'hasMany', - related, + related: normalizeRelatedResolver(related), foreignKey, localKey: resolvedLocalKey, }) } +export function hasOne( + related: TName, + foreignKeyOrOptions: string | { foreignKey: string, localKey?: string }, + localKey?: string, +): HasOneRelationDefinition> export function hasOne>( related: () => TRelated, foreignKeyOrOptions: string | { foreignKey: string, localKey?: string }, + localKey?: string, +): HasOneRelationDefinition +export function hasOne( + related: RelatedInput>, + foreignKeyOrOptions: string | { foreignKey: string, localKey?: string }, localKey = 'id', -): HasOneRelationDefinition { +): HasOneRelationDefinition> { const foreignKey = typeof foreignKeyOrOptions === 'string' ? foreignKeyOrOptions : foreignKeyOrOptions.foreignKey @@ -150,7 +204,7 @@ export function hasOne>( return Object.freeze({ kind: 'hasOne', - related, + related: normalizeRelatedResolver(related), foreignKey, localKey: resolvedLocalKey, }) @@ -281,6 +335,23 @@ export function oldestOfMany( + related: TName, + pivotTableOrOptions: TPivotTable | { + pivotTable: TPivotTable + foreignPivotKey: string + relatedPivotKey: string + parentKey?: string + relatedKey?: string + }, + foreignPivotKey?: string, + relatedPivotKey?: string, + parentKey?: string, + relatedKey?: string, +): BelongsToManyRelationDefinition, TPivotTable> & PivotRelationMethods, TPivotTable>, TPivotTable> export function belongsToMany< TRelated extends ModelDefinitionLike, TPivotTable extends string | TableDefinition, @@ -295,9 +366,23 @@ export function belongsToMany< }, foreignPivotKey?: string, relatedPivotKey?: string, + parentKey?: string, + relatedKey?: string, +): BelongsToManyRelationDefinition & PivotRelationMethods, TPivotTable> +export function belongsToMany( + related: RelatedInput>, + pivotTableOrOptions: TPivotTable | { + pivotTable: TPivotTable + foreignPivotKey: string + relatedPivotKey: string + parentKey?: string + relatedKey?: string + }, + foreignPivotKey?: string, + relatedPivotKey?: string, parentKey = 'id', relatedKey = 'id', -): BelongsToManyRelationDefinition & PivotRelationMethods, TPivotTable> { +): BelongsToManyRelationDefinition, TPivotTable> & PivotRelationMethods, TPivotTable>, TPivotTable> { const pivotTable = typeof pivotTableOrOptions === 'object' && 'pivotTable' in pivotTableOrOptions ? pivotTableOrOptions.pivotTable : pivotTableOrOptions @@ -316,11 +401,11 @@ export function belongsToMany< return decoratePivotRelation< BareBelongsToManyRelation, - BelongsToManyRelationDefinition, + BelongsToManyRelationDefinition, TPivotTable>, TPivotTable >(Object.freeze({ kind: 'belongsToMany', - related, + related: normalizeRelatedResolver(related), pivotTable, foreignPivotKey: resolvedForeignPivotKey, relatedPivotKey: resolvedRelatedPivotKey, diff --git a/packages/db/src/model/types.ts b/packages/db/src/model/types.ts index 31d7c630..4bcd1d5d 100644 --- a/packages/db/src/model/types.ts +++ b/packages/db/src/model/types.ts @@ -56,6 +56,10 @@ export interface AnyEntity { getChanges(): Record } export type AnyModelQueryBuilder = ModelQueryBuilder +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration merging hook for app model registries +export interface RegisteredModels {} +export type RegisteredModelName = Extract +export type RegisteredModelReference = RegisteredModels[TName] export type ModelDefinitionLike = | (AnyModelDefinition & { readonly table: TTable }) | { readonly definition: AnyModelDefinition & { readonly table: TTable } } diff --git a/packages/db/src/schema/pluralize.ts b/packages/db/src/schema/pluralize.ts index 10084f44..06488bba 100644 --- a/packages/db/src/schema/pluralize.ts +++ b/packages/db/src/schema/pluralize.ts @@ -1,13 +1,7 @@ -export function pluralizeTableName(word: string): string { - if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) { - return `${word.slice(0, -1)}ies` - } +import { pluralize } from 'inflection' - if (/(?:[sxz]|ch|sh)$/i.test(word)) { - return `${word}es` - } - - return `${word}s` +export function pluralizeTableName(word: string): string { + return pluralize(word) } export function inferConstrainedTableName(columnName: string): string { diff --git a/packages/db/tests/model-core.test.ts b/packages/db/tests/model-core.test.ts index c7b5097a..7249539e 100644 --- a/packages/db/tests/model-core.test.ts +++ b/packages/db/tests/model-core.test.ts @@ -489,6 +489,26 @@ describe('model core slice', () => { expect(User.definition.name).toBe('User') }) + it('infers model names from regular, irregular, compound, and schema-qualified table names', () => { + const people = defineTable('people', { + id: column.id(), + }) + const children = defineTable('children', { + id: column.id(), + }) + const blueLotOfThings = defineTable('blue_lot_of_things', { + id: column.id(), + }) + const auditLogs = defineTable('analytics.audit_logs', { + id: column.id(), + }) + + expect(defineModel(people).definition.name).toBe('Person') + expect(defineModel(children).definition.name).toBe('Child') + expect(defineModel(blueLotOfThings).definition.name).toBe('BlueLotOfThing') + expect(defineModel(auditLogs).definition.name).toBe('AuditLog') + }) + it('throws immediately when defineModel(tableName) references a missing generated schema table', () => { expect(() => defineModel('missing_users')).toThrow( 'Model "missing_users" is not present in the generated schema registry. Import your generated schema module and run "holo migrate" to refresh it.', diff --git a/packages/db/tests/schema-core.test.ts b/packages/db/tests/schema-core.test.ts index a97f501c..884d2264 100644 --- a/packages/db/tests/schema-core.test.ts +++ b/packages/db/tests/schema-core.test.ts @@ -1114,6 +1114,9 @@ describe('native schema core', () => { const memberships = defineTable('memberships', { team_id: column.foreignId().constrained().cascadeOnDelete().restrictOnUpdate(), category_id: column.foreignId().constrained(), + person_id: column.foreignId().constrained(), + child_id: column.foreignId().constrained(), + blue_lot_of_thing_id: column.foreignId().constrained(), ownerId: column.foreignId().constrained().nullOnDelete().noActionOnUpdate(), status: column.foreignId().constrained(), company_id: column.foreignId().references('uuid').on('companies').noActionOnDelete().nullOnUpdate(), @@ -1129,6 +1132,21 @@ describe('native schema core', () => { column: 'id', onDelete: undefined, onUpdate: undefined }) + expect(memberships.person_id.references).toEqual({ + table: 'people', + column: 'id', + onDelete: undefined, + onUpdate: undefined }) + expect(memberships.child_id.references).toEqual({ + table: 'children', + column: 'id', + onDelete: undefined, + onUpdate: undefined }) + expect(memberships.blue_lot_of_thing_id.references).toEqual({ + table: 'blue_lot_of_things', + column: 'id', + onDelete: undefined, + onUpdate: undefined }) expect(memberships.ownerId.references).toEqual({ table: 'owners', column: 'id', diff --git a/packages/db/tests/type-system.test.ts b/packages/db/tests/type-system.test.ts index 43804dd2..d382abbe 100644 --- a/packages/db/tests/type-system.test.ts +++ b/packages/db/tests/type-system.test.ts @@ -16,15 +16,19 @@ import { unsafeSql, type TableQueryBuilder, type CursorPaginatedResult, + type EmptyScopeMap, type Entity, + type HasManyRelationDefinition, type InferInsert, type InferSelect, type InferUpdate, type MySQLAdapterOptions, type ModelCollection, type ModelQueryBuilder, + type ModelReference, type PaginatedResult, type PostgresAdapterOptions, + type RelationMap, type SimplePaginatedResult } from '../src' import type { AnyModelDefinition, BelongsToRelationDefinition, ModelRelationPath } from '../src/model/types' import { defineModelFromTable, defineTable } from './support/internal' @@ -36,6 +40,35 @@ type IsEqual type Assert = T +const namedUsersTable = defineTable('named_users', { + id: column.id(), + name: column.string(), +}) + +const namedPostsTable = defineTable('named_posts', { + id: column.id(), + user_id: column.integer(), + title: column.string(), +}) + +interface NamedUserRelations extends RelationMap { + readonly posts: HasManyRelationDefinition +} + +interface NamedPostRelations extends RelationMap { + readonly user: BelongsToRelationDefinition +} + +type NamedUserModel = ModelReference +type NamedPostModel = ModelReference + +declare module '../src' { + interface RegisteredModels { + NamedPost: NamedPostModel + NamedUser: NamedUserModel + } +} + describe('type system contracts', () => { it('infers schema select, insert, and update payloads from one schema definition', () => { const users = defineTable('users', { @@ -814,6 +847,33 @@ describe('type system contracts', () => { } }) + it('supports string relation targets for cross-file inverse relations', () => { + const NamedPost = defineModelFromTable(namedPostsTable, { + relations: { + user: belongsTo('NamedUser', 'user_id'), + }, + }) + const NamedUser = defineModelFromTable(namedUsersTable, { + relations: { + posts: hasMany('NamedPost', 'user_id'), + }, + }) + + if (false) { + const query = NamedUser.with('posts.user') + type Result = NonNullable>> + type LoadedPosts = Result['posts'] + type LoadedUser = LoadedPosts[number]['user'] + expectTypeOf().toMatchTypeOf | null>() + + // @ts-expect-error string relation targets must match the registered model names + hasMany('MissingModel', 'user_id') + } + + expectTypeOf(NamedUser.definition.relations.posts).toMatchTypeOf>() + expectTypeOf(NamedPost.definition.relations.user).toMatchTypeOf>() + }) + it('supports self-referential eager-load paths', () => { const categories = defineTable('categories', { id: column.id(), diff --git a/packages/queue-db/tests/setup.test.ts b/packages/queue-db/tests/setup.test.ts index 222ee523..2ecb20f7 100644 --- a/packages/queue-db/tests/setup.test.ts +++ b/packages/queue-db/tests/setup.test.ts @@ -1,10 +1,14 @@ import { existsSync } from 'node:fs' import { execFileSync } from 'node:child_process' -import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { + linkInstalledDependenciesForPackage, + provisionTempPackage, +} from '../../../tests/support/published-package' const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const repoRoot = resolve(packageDir, '../..') @@ -19,20 +23,6 @@ async function createTempBuildRoot(prefix: string): Promise { return root } -async function provisionTempPackage(sourcePackageDir: string, tempPackageDir: string): Promise { - await cp(sourcePackageDir, tempPackageDir, { - recursive: true, - filter(source) { - return !source.includes('/dist/') - && !source.endsWith('/dist') - && !source.includes('/tests/') - && !source.endsWith('/tests') - && !source.includes('/node_modules/') - && !source.endsWith('/node_modules') - }, - }) -} - async function runPackageBuild(): Promise<{ outDir: string }> { if (!packageBuildPromise) { packageBuildPromise = (async () => { @@ -59,7 +49,17 @@ async function runPackageBuild(): Promise<{ outDir: string }> { await provisionTempPackage(packageDir, queueDbPackageRoot) await symlink(dbPackageRoot, join(holoNodeModulesRoot, 'db')) await symlink(queuePackageRoot, join(holoNodeModulesRoot, 'queue')) - await symlink(resolve(repoRoot, 'node_modules/.bun/node_modules/bullmq'), join(nodeModulesRoot, 'bullmq')) + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot, + packageJsonPath: resolve(repoRoot, 'packages/db/package.json'), + }) + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot, + packageJsonPath: resolve(repoRoot, 'packages/queue/package.json'), + extraDependencyNames: ['bullmq'], + }) execFileSync(resolve(packageDir, '../db/node_modules/.bin/tsup'), [], { cwd: dbPackageRoot, diff --git a/packages/queue/tests/setup.test.ts b/packages/queue/tests/setup.test.ts index 2a3e0016..dacca09e 100644 --- a/packages/queue/tests/setup.test.ts +++ b/packages/queue/tests/setup.test.ts @@ -1,10 +1,14 @@ import { existsSync } from 'node:fs' import { execFileSync } from 'node:child_process' -import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { + linkInstalledDependenciesForPackage, + provisionTempPackage, +} from '../../../tests/support/published-package' const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const repoRoot = resolve(packageDir, '../..') @@ -19,20 +23,6 @@ async function createTempBuildRoot(prefix: string): Promise { return root } -async function provisionTempPackage(sourcePackageDir: string, tempPackageDir: string): Promise { - await cp(sourcePackageDir, tempPackageDir, { - recursive: true, - filter(source) { - return !source.includes('/dist/') - && !source.endsWith('/dist') - && !source.includes('/tests/') - && !source.endsWith('/tests') - && !source.includes('/node_modules/') - && !source.endsWith('/node_modules') - }, - }) -} - async function runPackageBuild(): Promise<{ outDir: string }> { if (!packageBuildPromise) { packageBuildPromise = (async () => { @@ -45,10 +35,15 @@ async function runPackageBuild(): Promise<{ outDir: string }> { await symlink(resolve(repoRoot, 'tsconfig.json'), join(buildRoot, 'tsconfig.json')) await mkdir(nodeModulesRoot, { recursive: true }) await mkdir(typesRoot, { recursive: true }) - await symlink(resolve(repoRoot, 'node_modules/.bun/node_modules/bullmq'), join(nodeModulesRoot, 'bullmq')) await symlink(resolve(packageDir, 'node_modules/tsup'), join(nodeModulesRoot, 'tsup')) await symlink(resolve(packageDir, 'node_modules/typescript'), join(nodeModulesRoot, 'typescript')) await symlink(resolve(repoRoot, 'node_modules/@types/node'), join(typesRoot, 'node')) + await linkInstalledDependenciesForPackage({ + repoRoot, + nodeModulesRoot, + packageJsonPath: resolve(packageDir, 'package.json'), + extraDependencyNames: ['bullmq'], + }) await provisionTempPackage(packageDir, queuePackageRoot) execFileSync(resolve(packageDir, 'node_modules/.bin/tsup'), [], { diff --git a/tests/support/published-package.ts b/tests/support/published-package.ts new file mode 100644 index 00000000..aa9ff0f9 --- /dev/null +++ b/tests/support/published-package.ts @@ -0,0 +1,122 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import { cp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' + +type PackageManifest = { + dependencies?: Record +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)] +} + +function isWorkspaceDependency(dependencyName: string): boolean { + return dependencyName.startsWith('@holo-js/') +} + +export function readExternalDependencyNames(packageJsonPath: string): string[] { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageManifest + + return Object.keys(packageJson.dependencies ?? {}) + .filter(dependencyName => !isWorkspaceDependency(dependencyName)) +} + +export function resolveInstalledDependencyRoot(repoRoot: string, dependencyName: string): string { + const directPath = resolve(repoRoot, 'node_modules', ...dependencyName.split('/')) + if (existsSync(directPath)) { + return directPath + } + + const bunStorePath = resolve(repoRoot, 'node_modules/.bun/node_modules', ...dependencyName.split('/')) + if (existsSync(bunStorePath)) { + return bunStorePath + } + + throw new Error(`Installed dependency "${dependencyName}" was not found under "${repoRoot}/node_modules".`) +} + +export async function symlinkPackageDependency( + nodeModulesRoot: string, + packageName: string, + dependencyRoot: string, +): Promise { + const dependencyPath = join(nodeModulesRoot, ...packageName.split('/')) + await rm(dependencyPath, { recursive: true, force: true }) + await mkdir(dirname(dependencyPath), { recursive: true }) + await symlink(dependencyRoot, dependencyPath) +} + +export function symlinkPackageDependencySync( + nodeModulesRoot: string, + packageName: string, + dependencyRoot: string, +): void { + const dependencyPath = join(nodeModulesRoot, ...packageName.split('/')) + rmSync(dependencyPath, { recursive: true, force: true }) + mkdirSync(dirname(dependencyPath), { recursive: true }) + symlinkSync(dependencyRoot, dependencyPath) +} + +export async function linkInstalledDependenciesForPackage(options: { + repoRoot: string + nodeModulesRoot: string + packageJsonPath: string + extraDependencyNames?: readonly string[] +}): Promise { + const dependencyNames = unique([ + ...readExternalDependencyNames(options.packageJsonPath), + ...(options.extraDependencyNames ?? []), + ]) + + for (const dependencyName of dependencyNames) { + await symlinkPackageDependency( + options.nodeModulesRoot, + dependencyName, + resolveInstalledDependencyRoot(options.repoRoot, dependencyName), + ) + } +} + +export function linkInstalledDependenciesForPackageSync(options: { + repoRoot: string + nodeModulesRoot: string + packageJsonPath: string + extraDependencyNames?: readonly string[] +}): void { + const dependencyNames = unique([ + ...readExternalDependencyNames(options.packageJsonPath), + ...(options.extraDependencyNames ?? []), + ]) + + for (const dependencyName of dependencyNames) { + symlinkPackageDependencySync( + options.nodeModulesRoot, + dependencyName, + resolveInstalledDependencyRoot(options.repoRoot, dependencyName), + ) + } +} + +export async function provisionTempPackage(sourcePackageDir: string, tempPackageDir: string): Promise { + await cp(sourcePackageDir, tempPackageDir, { + recursive: true, + filter(source) { + return !source.includes('/dist/') + && !source.endsWith('/dist') + && !source.includes('/tests/') + && !source.endsWith('/tests') + && !source.includes('/node_modules/') + && !source.endsWith('/node_modules') + }, + }) +} + +export async function stagePublishedPackage( + sourceDir: string, + targetDir: string, + distDir: string, +): Promise { + await mkdir(targetDir, { recursive: true }) + await writeFile(join(targetDir, 'package.json'), readFileSync(join(sourceDir, 'package.json'), 'utf8')) + await cp(distDir, join(targetDir, 'dist'), { recursive: true }) +} From ac9657a37ba117f6ba0fd8b52d3cff027a414367 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 5 May 2026 05:14:16 +0300 Subject: [PATCH 2/2] Finish generated schema cleanup and validation fixes --- apps/blog-next/config/app.ts | 2 +- ...2026_05_02_115334_create_comments_table.ts | 2 + apps/blog-next/server/db/schema.generated.ts | 162 ------------------ apps/blog-next/server/models/Comment.ts | 2 +- apps/blog-nuxt/config/app.ts | 2 +- .../pages/admin/categories/index.vue | 4 +- .../blog-nuxt/pages/admin/posts/[id]/edit.vue | 4 +- apps/blog-nuxt/pages/admin/posts/index.vue | 4 +- apps/blog-nuxt/pages/admin/posts/new.vue | 4 +- apps/blog-nuxt/pages/admin/tags/index.vue | 4 +- apps/blog-nuxt/pages/categories/[slug].vue | 4 +- apps/blog-nuxt/pages/index.vue | 4 +- apps/blog-nuxt/pages/posts/index.vue | 4 +- apps/blog-nuxt/pages/tags/[slug].vue | 4 +- ...2026_05_02_115438_create_comments_table.ts | 2 + apps/blog-nuxt/server/db/schema.generated.ts | 162 ------------------ apps/blog-nuxt/server/holo-models.d.ts | 1 + apps/blog-nuxt/server/lib/blog.ts | 9 + apps/blog-sveltekit/config/app.ts | 2 +- ...2026_05_02_115430_create_comments_table.ts | 2 + .../server/db/schema.generated.ts | 162 ------------------ apps/docs/docs/database/commands.md | 2 +- apps/docs/docs/database/index.md | 3 +- apps/docs/docs/database/migrations.md | 10 +- apps/docs/docs/directory-structure.md | 4 +- apps/docs/docs/orm/index.md | 3 +- packages/adapter-nuxt/src/module.ts | 2 +- packages/adapter-nuxt/tests/module.test.ts | 6 +- packages/adapter-nuxt/tests/setup.test.ts | 1 - packages/cli/src/dev.ts | 12 +- .../src/project/scaffold/project-renderers.ts | 4 +- .../cli/tests/authorization-registry.test.ts | 5 +- packages/cli/tests/broadcast-registry.test.ts | 3 +- packages/cli/tests/broadcast.test.ts | 6 +- packages/cli/tests/cli.test.ts | 66 +++---- packages/core/tests/adapter.test.ts | 10 +- packages/core/tests/auth-runtime.test.ts | 2 +- .../core/tests/broadcast-registry.test.ts | 4 +- packages/db/src/model/ModelRegistry.ts | 17 ++ packages/db/src/model/defineModelHelpers.ts | 2 +- packages/db/src/project.ts | 2 +- packages/db/tests/model-core.test.ts | 4 +- packages/db/tests/schema-core.test.ts | 2 +- scripts/validate-framework-smoke.mjs | 2 +- 44 files changed, 142 insertions(+), 576 deletions(-) delete mode 100644 apps/blog-next/server/db/schema.generated.ts delete mode 100644 apps/blog-nuxt/server/db/schema.generated.ts delete mode 100644 apps/blog-sveltekit/server/db/schema.generated.ts diff --git a/apps/blog-next/config/app.ts b/apps/blog-next/config/app.ts index 3b239c6d..23ff7760 100644 --- a/apps/blog-next/config/app.ts +++ b/apps/blog-next/config/app.ts @@ -20,6 +20,6 @@ export default defineAppConfig({ jobs: 'server/jobs', events: 'server/events', listeners: 'server/listeners', - generatedSchema: 'server/db/schema.generated.ts', + generatedSchema: '.holo-js/generated/schema.generated.ts', }, }) diff --git a/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts b/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts index b5c11a57..cb02e19e 100644 --- a/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts +++ b/apps/blog-next/server/db/migrations/2026_05_02_115334_create_comments_table.ts @@ -5,7 +5,9 @@ export default defineMigration({ await schema.createTable('comments', (table) => { table.id() table.integer('post_id') + table.index(['post_id']) table.integer('user_id') + table.index(['user_id']) table.text('body') table.string('status').default('pending') table.timestamps() diff --git a/apps/blog-next/server/db/schema.generated.ts b/apps/blog-next/server/db/schema.generated.ts deleted file mode 100644 index be254d13..00000000 --- a/apps/blog-next/server/db/schema.generated.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { column, defineGeneratedTable, registerGeneratedTables } from '@holo-js/db' - -export const holoMigrations = defineGeneratedTable("_holo_migrations", { - "id": column.id(), - "name": column.string().unique(), - "batch": column.integer(), - "migrated_at": column.timestamp().defaultNow(), -}) - -export const authIdentities = defineGeneratedTable("auth_identities", { - "id": column.id(), - "user_id": column.string(), - "guard": column.string().default("web"), - "auth_provider": column.string().default("users"), - "provider": column.string(), - "provider_user_id": column.string(), - "email": column.string().nullable(), - "email_verified": column.boolean().default(false), - "profile": column.json().default({}), - "tokens": column.json().default({}), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}, { indexes: [{ columns: ["user_id"], unique: false }, { columns: ["provider","provider_user_id"], unique: true, name: "auth_identities_provider_user_unique" }] }) - -export const categories = defineGeneratedTable("categories", { - "id": column.id(), - "name": column.string(), - "slug": column.string().unique(), - "description": column.text().nullable(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}) - -export const comments = defineGeneratedTable("comments", { - "id": column.id(), - "post_id": column.integer(), - "user_id": column.integer(), - "body": column.text(), - "status": column.string().default("pending"), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}) - -export const emailVerificationTokens = defineGeneratedTable("email_verification_tokens", { - "id": column.uuid().primaryKey(), - "provider": column.string().default("users"), - "user_id": column.string(), - "email": column.string(), - "token_hash": column.string(), - "expires_at": column.timestamp(), - "used_at": column.timestamp().nullable(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}, { indexes: [{ columns: ["provider"], unique: false }, { columns: ["user_id"], unique: false }, { columns: ["email"], unique: false }] }) - -export const notifications = defineGeneratedTable("notifications", { - "id": column.string().primaryKey(), - "type": column.string().nullable(), - "notifiable_type": column.string(), - "notifiable_id": column.string(), - "data": column.json().default({}), - "read_at": column.timestamp().nullable(), - "created_at": column.timestamp(), - "updated_at": column.timestamp(), -}, { indexes: [{ columns: ["notifiable_type","notifiable_id"], unique: false }, { columns: ["read_at"], unique: false }] }) - -export const passwordResetTokens = defineGeneratedTable("password_reset_tokens", { - "id": column.uuid().primaryKey(), - "provider": column.string().default("users"), - "email": column.string(), - "token_hash": column.string(), - "expires_at": column.timestamp(), - "used_at": column.timestamp().nullable(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}, { indexes: [{ columns: ["provider"], unique: false }, { columns: ["email"], unique: false }] }) - -export const personalAccessTokens = defineGeneratedTable("personal_access_tokens", { - "id": column.uuid().primaryKey(), - "provider": column.string().default("users"), - "user_id": column.string(), - "name": column.string(), - "token_hash": column.string().unique(), - "abilities": column.json().default([]), - "last_used_at": column.timestamp().nullable(), - "expires_at": column.timestamp().nullable(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}, { indexes: [{ columns: ["provider"], unique: false }, { columns: ["user_id"], unique: false }] }) - -export const postTags = defineGeneratedTable("post_tags", { - "post_id": column.integer(), - "tag_id": column.integer(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}) - -export const posts = defineGeneratedTable("posts", { - "id": column.id(), - "user_id": column.integer(), - "category_id": column.integer().nullable(), - "title": column.string(), - "slug": column.string().unique(), - "status": column.string().default("draft"), - "excerpt": column.text().nullable(), - "body": column.text(), - "published_at": column.timestamp().nullable(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}) - -export const sessions = defineGeneratedTable("sessions", { - "id": column.string().primaryKey(), - "store": column.string().default("database"), - "data": column.json().default({}), - "created_at": column.timestamp(), - "last_activity_at": column.timestamp(), - "expires_at": column.timestamp(), - "invalidated_at": column.timestamp().nullable(), - "remember_token_hash": column.string().nullable(), -}, { indexes: [{ columns: ["expires_at"], unique: false }] }) - -export const tags = defineGeneratedTable("tags", { - "id": column.id(), - "name": column.string(), - "slug": column.string().unique(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}) - -export const users = defineGeneratedTable("users", { - "id": column.id(), - "name": column.string(), - "email": column.string().unique(), - "password": column.string().nullable(), - "avatar": column.string().nullable(), - "email_verified_at": column.timestamp().nullable(), - "created_at": column.timestamp().defaultNow(), - "updated_at": column.timestamp().defaultNow(), -}) - -declare module '@holo-js/db' { - interface GeneratedSchemaTables { - "_holo_migrations": typeof holoMigrations - "auth_identities": typeof authIdentities - "categories": typeof categories - "comments": typeof comments - "email_verification_tokens": typeof emailVerificationTokens - "notifications": typeof notifications - "password_reset_tokens": typeof passwordResetTokens - "personal_access_tokens": typeof personalAccessTokens - "post_tags": typeof postTags - "posts": typeof posts - "sessions": typeof sessions - "tags": typeof tags - "users": typeof users - } -} - -export const tables = { "_holo_migrations": holoMigrations, "auth_identities": authIdentities, "categories": categories, "comments": comments, "email_verification_tokens": emailVerificationTokens, "notifications": notifications, "password_reset_tokens": passwordResetTokens, "personal_access_tokens": personalAccessTokens, "post_tags": postTags, "posts": posts, "sessions": sessions, "tags": tags, "users": users } as const - -registerGeneratedTables(tables) diff --git a/apps/blog-next/server/models/Comment.ts b/apps/blog-next/server/models/Comment.ts index 8b7a05f6..2165791c 100644 --- a/apps/blog-next/server/models/Comment.ts +++ b/apps/blog-next/server/models/Comment.ts @@ -1,7 +1,7 @@ import { belongsTo, defineModel } from '@holo-js/db' export default defineModel('comments', { - fillable: ['post_id', 'user_id', 'body', 'status'], + fillable: ['post_id', 'user_id', 'body'], relations: { post: belongsTo('Post', { foreignKey: 'post_id' }), user: belongsTo('User', { foreignKey: 'user_id' }), diff --git a/apps/blog-nuxt/config/app.ts b/apps/blog-nuxt/config/app.ts index f60afbed..0b576b25 100644 --- a/apps/blog-nuxt/config/app.ts +++ b/apps/blog-nuxt/config/app.ts @@ -20,6 +20,6 @@ export default defineAppConfig({ jobs: 'server/jobs', events: 'server/events', listeners: 'server/listeners', - generatedSchema: 'server/db/schema.generated.ts', + generatedSchema: '.holo-js/generated/schema.generated.ts', }, }) diff --git a/apps/blog-nuxt/pages/admin/categories/index.vue b/apps/blog-nuxt/pages/admin/categories/index.vue index ae578ef8..3d9bd09b 100644 --- a/apps/blog-nuxt/pages/admin/categories/index.vue +++ b/apps/blog-nuxt/pages/admin/categories/index.vue @@ -1,5 +1,7 @@