diff --git a/apps/blog-next/package.json b/apps/blog-next/package.json index 1a7680b8..51a44f27 100644 --- a/apps/blog-next/package.json +++ b/apps/blog-next/package.json @@ -32,6 +32,7 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", + "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-react": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts b/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts index e2eab0a3..85e0e45f 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('admins', (table) => { table.id() table.string('name') @@ -12,7 +12,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('admins') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044032_create_notifications.ts b/apps/blog-next/server/db/migrations/2026_04_25_044032_create_notifications.ts index f1e4cff2..362676b4 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044032_create_notifications.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044032_create_notifications.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('notifications', (table) => { table.string('id').primaryKey() table.string('type').nullable() @@ -15,7 +15,7 @@ export default defineMigration({ table.index(['read_at']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('notifications') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044032_create_users.ts b/apps/blog-next/server/db/migrations/2026_04_25_044032_create_users.ts index 3d9bb065..68c0a206 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044032_create_users.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044032_create_users.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('users', (table) => { table.id() table.string('name') @@ -12,7 +12,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('users') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044033_create_sessions.ts b/apps/blog-next/server/db/migrations/2026_04_25_044033_create_sessions.ts index 3e5d8262..f2513599 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044033_create_sessions.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044033_create_sessions.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('sessions', (table) => { table.string('id').primaryKey() table.string('store').default('database') @@ -14,7 +14,7 @@ export default defineMigration({ table.index(['expires_at']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('sessions') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044034_create_auth_identities.ts b/apps/blog-next/server/db/migrations/2026_04_25_044034_create_auth_identities.ts index 8c8badb4..b0def8bb 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044034_create_auth_identities.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044034_create_auth_identities.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('auth_identities', (table) => { table.id() table.string('user_id') @@ -18,7 +18,7 @@ export default defineMigration({ table.unique(['provider', 'provider_user_id'], 'auth_identities_provider_user_unique') }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('auth_identities') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts b/apps/blog-next/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts index 2f3e8a8a..da6c67b5 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('personal_access_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -16,7 +16,7 @@ export default defineMigration({ table.index(['user_id']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('personal_access_tokens') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts b/apps/blog-next/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts index 7fcb3377..c5b64f44 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('password_reset_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -14,7 +14,7 @@ export default defineMigration({ table.index(['email']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('password_reset_tokens') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts b/apps/blog-next/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts index 6a8acea4..a39da97e 100644 --- a/apps/blog-next/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts +++ b/apps/blog-next/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('email_verification_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -16,7 +16,7 @@ export default defineMigration({ table.index(['email']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('email_verification_tokens') }, }) 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 cb02e19e..718e188a 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 @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('comments', (table) => { table.id() table.integer('post_id') @@ -13,7 +13,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('comments') }, }) diff --git a/apps/blog-next/server/db/migrations/2026_05_25_063242_create_cache_cache_table.ts b/apps/blog-next/server/db/migrations/2026_05_25_063242_create_cache_cache_table.ts index b15a0585..ab8c093d 100644 --- a/apps/blog-next/server/db/migrations/2026_05_25_063242_create_cache_cache_table.ts +++ b/apps/blog-next/server/db/migrations/2026_05_25_063242_create_cache_cache_table.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('cache', (table) => { table.string('key').primaryKey() table.text('payload') @@ -15,7 +15,7 @@ export default defineMigration({ table.index(['expires_at'], 'cache_locks_expires_at_index') }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('cache_locks') await schema.dropTable('cache') }, diff --git a/apps/blog-next/server/db/migrations/2026_06_19_202222_create_failed_jobs_table.ts b/apps/blog-next/server/db/migrations/2026_06_19_202222_create_failed_jobs_table.ts new file mode 100644 index 00000000..ba44bbf6 --- /dev/null +++ b/apps/blog-next/server/db/migrations/2026_06_19_202222_create_failed_jobs_table.ts @@ -0,0 +1,21 @@ +import { defineMigration } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }) { + await schema.createTable('failed_jobs', (table) => { + table.string('id').primaryKey() + table.string('job_id') + table.string('job') + table.string('connection') + table.string('queue') + table.text('payload') + table.text('exception') + table.bigInteger('failed_at') + table.index(['job_id'], 'failed_jobs_job_id_index') + table.index(['failed_at'], 'failed_jobs_failed_at_index') + }) + }, + async down({ schema }) { + await schema.dropTable('failed_jobs') + }, +}) diff --git a/apps/blog-next/server/db/migrations/2026_06_19_202222_create_jobs_table.ts b/apps/blog-next/server/db/migrations/2026_06_19_202222_create_jobs_table.ts new file mode 100644 index 00000000..2e926767 --- /dev/null +++ b/apps/blog-next/server/db/migrations/2026_06_19_202222_create_jobs_table.ts @@ -0,0 +1,25 @@ +import { defineMigration } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }) { + await schema.createTable('jobs', (table) => { + table.string('id').primaryKey() + table.string('job') + table.string('connection') + table.string('queue') + table.text('payload') + table.integer('attempts').default(0) + table.integer('max_attempts').default(1) + table.bigInteger('available_at') + table.bigInteger('reserved_at').nullable() + table.string('reservation_id').nullable() + table.bigInteger('created_at') + table.index(['queue', 'available_at'], 'jobs_queue_available_at_index') + table.index(['queue', 'reserved_at'], 'jobs_queue_reserved_at_index') + table.index(['reservation_id'], 'jobs_reservation_id_index') + }) + }, + async down({ schema }) { + await schema.dropTable('jobs') + }, +}) diff --git a/apps/blog-next/server/events/blog/post-saved.ts b/apps/blog-next/server/events/blog/post-saved.ts new file mode 100644 index 00000000..6f3b2db8 --- /dev/null +++ b/apps/blog-next/server/events/blog/post-saved.ts @@ -0,0 +1,13 @@ +import { defineEvent } from '@holo-js/events' + +export const BlogPostSaved = defineEvent<{ + action: 'created' | 'updated' | 'deleted' + postId: number + title: string + status: string + slug: string +}>({ + name: 'blog.post.saved', +}) + +export default BlogPostSaved diff --git a/apps/blog-next/server/jobs/blog/index-post.ts b/apps/blog-next/server/jobs/blog/index-post.ts new file mode 100644 index 00000000..4950b83c --- /dev/null +++ b/apps/blog-next/server/jobs/blog/index-post.ts @@ -0,0 +1,22 @@ +import { defineJob } from '@holo-js/queue' + +import Post from '../../models/Post' + +export const IndexBlogPost = defineJob<{ + action: 'created' | 'updated' | 'deleted' + postId: number +}>({ + queue: 'default', + async handle(payload) { + const post = await Post.find(payload.postId) + + return { + action: payload.action, + postId: payload.postId, + indexed: post !== undefined, + title: post?.title ?? null, + } + }, +}) + +export default IndexBlogPost diff --git a/apps/blog-next/server/lib/blog.ts b/apps/blog-next/server/lib/blog.ts index fa8664be..ec8edc48 100644 --- a/apps/blog-next/server/lib/blog.ts +++ b/apps/blog-next/server/lib/blog.ts @@ -4,6 +4,7 @@ import { DB, uniqueSlug } from '@holo-js/db' import { ValidationException } from '@holo-js/forms' import { blogPostChanged } from '../broadcast/blog-post-changed' +import BlogPostSaved from '../events/blog/post-saved' import Category from '../models/Category' import Post from '../models/Post' import Tag from '../models/Tag' @@ -253,6 +254,13 @@ export async function createPost(input: { title: string, excerpt?: string, body: } await broadcast(blogPostChanged('created', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'created', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) return post } @@ -289,6 +297,13 @@ export async function updatePost(id: number, input: { title: string, excerpt?: s } await broadcast(blogPostChanged('updated', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'updated', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) return post } @@ -298,5 +313,12 @@ export async function deletePost(id: number) { await Post.delete(id) if (post) { await broadcast(blogPostChanged('deleted', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'deleted', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) } } diff --git a/apps/blog-next/server/listeners/blog/index-saved-post.ts b/apps/blog-next/server/listeners/blog/index-saved-post.ts new file mode 100644 index 00000000..16906200 --- /dev/null +++ b/apps/blog-next/server/listeners/blog/index-saved-post.ts @@ -0,0 +1,14 @@ +import { defineListener } from '@holo-js/events' + +import BlogPostSaved from '../../events/blog/post-saved' +import IndexBlogPost from '../../jobs/blog/index-post' + +export default defineListener({ + listensTo: [BlogPostSaved], + async handle(event) { + await IndexBlogPost.dispatch({ + action: event.payload.action, + postId: event.payload.postId, + }) + }, +}) diff --git a/apps/blog-next/tests/blog-logic.mjs b/apps/blog-next/tests/blog-logic.mjs index 6582c3eb..6c701a10 100644 --- a/apps/blog-next/tests/blog-logic.mjs +++ b/apps/blog-next/tests/blog-logic.mjs @@ -61,6 +61,10 @@ async function getRegisteredCacheKeys() { return [...(await getCacheRuntimeBindings()?.dependencyIndex?.listRegisteredKeys() ?? [])] } +async function countQueuedBlogIndexJobs() { + return await DB.table('jobs').where('job', 'blog.index-post').count() +} + function resolveDriverCacheKey(indexedKey) { const delimiterIndex = indexedKey.indexOf('\u0000') return delimiterIndex === -1 ? indexedKey : indexedKey.slice(delimiterIndex + 1) @@ -286,6 +290,8 @@ try { assert.ok(frameworkTag) assert.ok(releaseTag) + const queuedJobsBeforePostLifecycle = await countQueuedBlogIndexJobs() + await createPost({ title: 'Logic Coverage Post', excerpt: 'Logic excerpt', @@ -294,6 +300,7 @@ try { categoryId: String(updatedCategory.id), tagIds: String(frameworkTag.id), }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 1) assert.equal(await countRegisteredCacheKeys(), 0) assert.ok((await getPublishedPosts()).some(post => post.slug === 'logic-coverage-post')) @@ -369,6 +376,7 @@ try { categoryId: '', tagIds: String(releaseTag.id), }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 2) assert.equal(await countRegisteredCacheKeys(), 0) logicPost = await Post.with('category', 'tags').where('id', logicPost.id).first() @@ -397,6 +405,7 @@ try { assert.equal(await Category.find(updatedCategory.id), undefined) await deletePost(logicPost.id) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 4) await deletePost(cleanupPost.id) if (mediaFailurePost) { await deletePost(mediaFailurePost.id) diff --git a/apps/blog-nuxt/package.json b/apps/blog-nuxt/package.json index cb156058..aabdf04c 100644 --- a/apps/blog-nuxt/package.json +++ b/apps/blog-nuxt/package.json @@ -33,6 +33,7 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", + "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-vue": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts index e2eab0a3..85e0e45f 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('admins', (table) => { table.id() table.string('name') @@ -12,7 +12,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('admins') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_notifications.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_notifications.ts index f1e4cff2..362676b4 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_notifications.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_notifications.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('notifications', (table) => { table.string('id').primaryKey() table.string('type').nullable() @@ -15,7 +15,7 @@ export default defineMigration({ table.index(['read_at']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('notifications') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_users.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_users.ts index 3d9bb065..68c0a206 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_users.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044032_create_users.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('users', (table) => { table.id() table.string('name') @@ -12,7 +12,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('users') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044033_create_sessions.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044033_create_sessions.ts index 3e5d8262..f2513599 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044033_create_sessions.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044033_create_sessions.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('sessions', (table) => { table.string('id').primaryKey() table.string('store').default('database') @@ -14,7 +14,7 @@ export default defineMigration({ table.index(['expires_at']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('sessions') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044034_create_auth_identities.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044034_create_auth_identities.ts index 8c8badb4..b0def8bb 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044034_create_auth_identities.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044034_create_auth_identities.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('auth_identities', (table) => { table.id() table.string('user_id') @@ -18,7 +18,7 @@ export default defineMigration({ table.unique(['provider', 'provider_user_id'], 'auth_identities_provider_user_unique') }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('auth_identities') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts index 2f3e8a8a..da6c67b5 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('personal_access_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -16,7 +16,7 @@ export default defineMigration({ table.index(['user_id']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('personal_access_tokens') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts index 7fcb3377..c5b64f44 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('password_reset_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -14,7 +14,7 @@ export default defineMigration({ table.index(['email']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('password_reset_tokens') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts index 6a8acea4..a39da97e 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('email_verification_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -16,7 +16,7 @@ export default defineMigration({ table.index(['email']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('email_verification_tokens') }, }) 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 index cb02e19e..718e188a 100644 --- 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 @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('comments', (table) => { table.id() table.integer('post_id') @@ -13,7 +13,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('comments') }, }) diff --git a/apps/blog-nuxt/server/db/migrations/2026_05_25_063246_create_cache_cache_table.ts b/apps/blog-nuxt/server/db/migrations/2026_05_25_063246_create_cache_cache_table.ts index b15a0585..ab8c093d 100644 --- a/apps/blog-nuxt/server/db/migrations/2026_05_25_063246_create_cache_cache_table.ts +++ b/apps/blog-nuxt/server/db/migrations/2026_05_25_063246_create_cache_cache_table.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('cache', (table) => { table.string('key').primaryKey() table.text('payload') @@ -15,7 +15,7 @@ export default defineMigration({ table.index(['expires_at'], 'cache_locks_expires_at_index') }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('cache_locks') await schema.dropTable('cache') }, diff --git a/apps/blog-nuxt/server/db/migrations/2026_06_19_202226_create_jobs_table.ts b/apps/blog-nuxt/server/db/migrations/2026_06_19_202226_create_jobs_table.ts new file mode 100644 index 00000000..2e926767 --- /dev/null +++ b/apps/blog-nuxt/server/db/migrations/2026_06_19_202226_create_jobs_table.ts @@ -0,0 +1,25 @@ +import { defineMigration } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }) { + await schema.createTable('jobs', (table) => { + table.string('id').primaryKey() + table.string('job') + table.string('connection') + table.string('queue') + table.text('payload') + table.integer('attempts').default(0) + table.integer('max_attempts').default(1) + table.bigInteger('available_at') + table.bigInteger('reserved_at').nullable() + table.string('reservation_id').nullable() + table.bigInteger('created_at') + table.index(['queue', 'available_at'], 'jobs_queue_available_at_index') + table.index(['queue', 'reserved_at'], 'jobs_queue_reserved_at_index') + table.index(['reservation_id'], 'jobs_reservation_id_index') + }) + }, + async down({ schema }) { + await schema.dropTable('jobs') + }, +}) diff --git a/apps/blog-nuxt/server/db/migrations/2026_06_19_202227_create_failed_jobs_table.ts b/apps/blog-nuxt/server/db/migrations/2026_06_19_202227_create_failed_jobs_table.ts new file mode 100644 index 00000000..ba44bbf6 --- /dev/null +++ b/apps/blog-nuxt/server/db/migrations/2026_06_19_202227_create_failed_jobs_table.ts @@ -0,0 +1,21 @@ +import { defineMigration } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }) { + await schema.createTable('failed_jobs', (table) => { + table.string('id').primaryKey() + table.string('job_id') + table.string('job') + table.string('connection') + table.string('queue') + table.text('payload') + table.text('exception') + table.bigInteger('failed_at') + table.index(['job_id'], 'failed_jobs_job_id_index') + table.index(['failed_at'], 'failed_jobs_failed_at_index') + }) + }, + async down({ schema }) { + await schema.dropTable('failed_jobs') + }, +}) diff --git a/apps/blog-nuxt/server/events/blog/post-saved.ts b/apps/blog-nuxt/server/events/blog/post-saved.ts new file mode 100644 index 00000000..6f3b2db8 --- /dev/null +++ b/apps/blog-nuxt/server/events/blog/post-saved.ts @@ -0,0 +1,13 @@ +import { defineEvent } from '@holo-js/events' + +export const BlogPostSaved = defineEvent<{ + action: 'created' | 'updated' | 'deleted' + postId: number + title: string + status: string + slug: string +}>({ + name: 'blog.post.saved', +}) + +export default BlogPostSaved diff --git a/apps/blog-nuxt/server/jobs/blog/index-post.ts b/apps/blog-nuxt/server/jobs/blog/index-post.ts new file mode 100644 index 00000000..4950b83c --- /dev/null +++ b/apps/blog-nuxt/server/jobs/blog/index-post.ts @@ -0,0 +1,22 @@ +import { defineJob } from '@holo-js/queue' + +import Post from '../../models/Post' + +export const IndexBlogPost = defineJob<{ + action: 'created' | 'updated' | 'deleted' + postId: number +}>({ + queue: 'default', + async handle(payload) { + const post = await Post.find(payload.postId) + + return { + action: payload.action, + postId: payload.postId, + indexed: post !== undefined, + title: post?.title ?? null, + } + }, +}) + +export default IndexBlogPost diff --git a/apps/blog-nuxt/server/lib/blog.ts b/apps/blog-nuxt/server/lib/blog.ts index f48cd807..e6f8558b 100644 --- a/apps/blog-nuxt/server/lib/blog.ts +++ b/apps/blog-nuxt/server/lib/blog.ts @@ -4,6 +4,7 @@ import { DB, uniqueSlug } from '@holo-js/db' import { ValidationException } from '@holo-js/forms' import { blogPostChanged } from '../broadcast/blog-post-changed' +import BlogPostSaved from '../events/blog/post-saved' import Category from '../models/Category' import Post from '../models/Post' import Tag from '../models/Tag' @@ -231,6 +232,13 @@ export async function createPost(input: { title: string, excerpt?: string, body: } await broadcast(blogPostChanged('created', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'created', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) return post } @@ -267,6 +275,13 @@ export async function updatePost(id: number, input: { title: string, excerpt?: s } await broadcast(blogPostChanged('updated', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'updated', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) return post } @@ -276,5 +291,12 @@ export async function deletePost(id: number) { await Post.delete(id) if (post) { await broadcast(blogPostChanged('deleted', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'deleted', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) } } diff --git a/apps/blog-nuxt/server/listeners/blog/index-saved-post.ts b/apps/blog-nuxt/server/listeners/blog/index-saved-post.ts new file mode 100644 index 00000000..16906200 --- /dev/null +++ b/apps/blog-nuxt/server/listeners/blog/index-saved-post.ts @@ -0,0 +1,14 @@ +import { defineListener } from '@holo-js/events' + +import BlogPostSaved from '../../events/blog/post-saved' +import IndexBlogPost from '../../jobs/blog/index-post' + +export default defineListener({ + listensTo: [BlogPostSaved], + async handle(event) { + await IndexBlogPost.dispatch({ + action: event.payload.action, + postId: event.payload.postId, + }) + }, +}) diff --git a/apps/blog-nuxt/tests/blog-logic.mjs b/apps/blog-nuxt/tests/blog-logic.mjs index 23ebbbc4..d6655bb9 100644 --- a/apps/blog-nuxt/tests/blog-logic.mjs +++ b/apps/blog-nuxt/tests/blog-logic.mjs @@ -61,6 +61,10 @@ async function getRegisteredCacheKeys() { return [...(await getCacheRuntimeBindings()?.dependencyIndex?.listRegisteredKeys() ?? [])] } +async function countQueuedBlogIndexJobs() { + return await DB.table('jobs').where('job', 'blog.index-post').count() +} + function resolveDriverCacheKey(indexedKey) { const delimiterIndex = indexedKey.indexOf('\u0000') return delimiterIndex === -1 ? indexedKey : indexedKey.slice(delimiterIndex + 1) @@ -286,6 +290,8 @@ try { assert.ok(frameworkTag) assert.ok(releaseTag) + const queuedJobsBeforePostLifecycle = await countQueuedBlogIndexJobs() + await createPost({ title: 'Logic Coverage Post', excerpt: 'Logic excerpt', @@ -294,6 +300,7 @@ try { categoryId: String(updatedCategory.id), tagIds: String(frameworkTag.id), }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 1) assert.equal(await countRegisteredCacheKeys(), 0) assert.ok((await getPublishedPosts()).some(post => post.slug === 'logic-coverage-post')) @@ -369,6 +376,7 @@ try { categoryId: '', tagIds: String(releaseTag.id), }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 2) assert.equal(await countRegisteredCacheKeys(), 0) logicPost = await Post.with('category', 'tags').where('id', logicPost.id).first() @@ -397,6 +405,7 @@ try { assert.equal(await Category.find(updatedCategory.id), undefined) await deletePost(logicPost.id) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 4) await deletePost(cleanupPost.id) if (mediaFailurePost) { await deletePost(mediaFailurePost.id) diff --git a/apps/blog-sveltekit/package.json b/apps/blog-sveltekit/package.json index e031b85a..a179d618 100644 --- a/apps/blog-sveltekit/package.json +++ b/apps/blog-sveltekit/package.json @@ -32,6 +32,7 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", + "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-svelte": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts index e2eab0a3..85e0e45f 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('admins', (table) => { table.id() table.string('name') @@ -12,7 +12,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('admins') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_notifications.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_notifications.ts index f1e4cff2..362676b4 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_notifications.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_notifications.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('notifications', (table) => { table.string('id').primaryKey() table.string('type').nullable() @@ -15,7 +15,7 @@ export default defineMigration({ table.index(['read_at']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('notifications') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_users.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_users.ts index 3d9bb065..68c0a206 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_users.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044032_create_users.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('users', (table) => { table.id() table.string('name') @@ -12,7 +12,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('users') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044033_create_sessions.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044033_create_sessions.ts index 3e5d8262..f2513599 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044033_create_sessions.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044033_create_sessions.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('sessions', (table) => { table.string('id').primaryKey() table.string('store').default('database') @@ -14,7 +14,7 @@ export default defineMigration({ table.index(['expires_at']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('sessions') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044034_create_auth_identities.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044034_create_auth_identities.ts index 8c8badb4..b0def8bb 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044034_create_auth_identities.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044034_create_auth_identities.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('auth_identities', (table) => { table.id() table.string('user_id') @@ -18,7 +18,7 @@ export default defineMigration({ table.unique(['provider', 'provider_user_id'], 'auth_identities_provider_user_unique') }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('auth_identities') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts index 2f3e8a8a..da6c67b5 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044035_create_personal_access_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('personal_access_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -16,7 +16,7 @@ export default defineMigration({ table.index(['user_id']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('personal_access_tokens') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts index 7fcb3377..c5b64f44 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044036_create_password_reset_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('password_reset_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -14,7 +14,7 @@ export default defineMigration({ table.index(['email']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('password_reset_tokens') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts index 6a8acea4..a39da97e 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044037_create_email_verification_tokens.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('email_verification_tokens', (table) => { table.uuid('id').primaryKey() table.string('provider').default('users') @@ -16,7 +16,7 @@ export default defineMigration({ table.index(['email']) }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('email_verification_tokens') }, }) 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 index cb02e19e..718e188a 100644 --- 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 @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('comments', (table) => { table.id() table.integer('post_id') @@ -13,7 +13,7 @@ export default defineMigration({ table.timestamps() }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('comments') }, }) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_05_25_063252_create_cache_cache_table.ts b/apps/blog-sveltekit/server/db/migrations/2026_05_25_063252_create_cache_cache_table.ts index b15a0585..ab8c093d 100644 --- a/apps/blog-sveltekit/server/db/migrations/2026_05_25_063252_create_cache_cache_table.ts +++ b/apps/blog-sveltekit/server/db/migrations/2026_05_25_063252_create_cache_cache_table.ts @@ -1,7 +1,7 @@ -import { defineMigration, type MigrationContext } from '@holo-js/db' +import { defineMigration } from '@holo-js/db' export default defineMigration({ - async up({ schema }: MigrationContext) { + async up({ schema }) { await schema.createTable('cache', (table) => { table.string('key').primaryKey() table.text('payload') @@ -15,7 +15,7 @@ export default defineMigration({ table.index(['expires_at'], 'cache_locks_expires_at_index') }) }, - async down({ schema }: MigrationContext) { + async down({ schema }) { await schema.dropTable('cache_locks') await schema.dropTable('cache') }, diff --git a/apps/blog-sveltekit/server/db/migrations/2026_06_19_202238_create_jobs_table.ts b/apps/blog-sveltekit/server/db/migrations/2026_06_19_202238_create_jobs_table.ts new file mode 100644 index 00000000..2e926767 --- /dev/null +++ b/apps/blog-sveltekit/server/db/migrations/2026_06_19_202238_create_jobs_table.ts @@ -0,0 +1,25 @@ +import { defineMigration } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }) { + await schema.createTable('jobs', (table) => { + table.string('id').primaryKey() + table.string('job') + table.string('connection') + table.string('queue') + table.text('payload') + table.integer('attempts').default(0) + table.integer('max_attempts').default(1) + table.bigInteger('available_at') + table.bigInteger('reserved_at').nullable() + table.string('reservation_id').nullable() + table.bigInteger('created_at') + table.index(['queue', 'available_at'], 'jobs_queue_available_at_index') + table.index(['queue', 'reserved_at'], 'jobs_queue_reserved_at_index') + table.index(['reservation_id'], 'jobs_reservation_id_index') + }) + }, + async down({ schema }) { + await schema.dropTable('jobs') + }, +}) diff --git a/apps/blog-sveltekit/server/db/migrations/2026_06_19_202239_create_failed_jobs_table.ts b/apps/blog-sveltekit/server/db/migrations/2026_06_19_202239_create_failed_jobs_table.ts new file mode 100644 index 00000000..ba44bbf6 --- /dev/null +++ b/apps/blog-sveltekit/server/db/migrations/2026_06_19_202239_create_failed_jobs_table.ts @@ -0,0 +1,21 @@ +import { defineMigration } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }) { + await schema.createTable('failed_jobs', (table) => { + table.string('id').primaryKey() + table.string('job_id') + table.string('job') + table.string('connection') + table.string('queue') + table.text('payload') + table.text('exception') + table.bigInteger('failed_at') + table.index(['job_id'], 'failed_jobs_job_id_index') + table.index(['failed_at'], 'failed_jobs_failed_at_index') + }) + }, + async down({ schema }) { + await schema.dropTable('failed_jobs') + }, +}) diff --git a/apps/blog-sveltekit/server/events/blog/post-saved.ts b/apps/blog-sveltekit/server/events/blog/post-saved.ts new file mode 100644 index 00000000..6f3b2db8 --- /dev/null +++ b/apps/blog-sveltekit/server/events/blog/post-saved.ts @@ -0,0 +1,13 @@ +import { defineEvent } from '@holo-js/events' + +export const BlogPostSaved = defineEvent<{ + action: 'created' | 'updated' | 'deleted' + postId: number + title: string + status: string + slug: string +}>({ + name: 'blog.post.saved', +}) + +export default BlogPostSaved diff --git a/apps/blog-sveltekit/server/jobs/blog/index-post.ts b/apps/blog-sveltekit/server/jobs/blog/index-post.ts new file mode 100644 index 00000000..4950b83c --- /dev/null +++ b/apps/blog-sveltekit/server/jobs/blog/index-post.ts @@ -0,0 +1,22 @@ +import { defineJob } from '@holo-js/queue' + +import Post from '../../models/Post' + +export const IndexBlogPost = defineJob<{ + action: 'created' | 'updated' | 'deleted' + postId: number +}>({ + queue: 'default', + async handle(payload) { + const post = await Post.find(payload.postId) + + return { + action: payload.action, + postId: payload.postId, + indexed: post !== undefined, + title: post?.title ?? null, + } + }, +}) + +export default IndexBlogPost diff --git a/apps/blog-sveltekit/server/listeners/blog/index-saved-post.ts b/apps/blog-sveltekit/server/listeners/blog/index-saved-post.ts new file mode 100644 index 00000000..16906200 --- /dev/null +++ b/apps/blog-sveltekit/server/listeners/blog/index-saved-post.ts @@ -0,0 +1,14 @@ +import { defineListener } from '@holo-js/events' + +import BlogPostSaved from '../../events/blog/post-saved' +import IndexBlogPost from '../../jobs/blog/index-post' + +export default defineListener({ + listensTo: [BlogPostSaved], + async handle(event) { + await IndexBlogPost.dispatch({ + action: event.payload.action, + postId: event.payload.postId, + }) + }, +}) diff --git a/apps/blog-sveltekit/src/routes/admin/posts/+page.server.ts b/apps/blog-sveltekit/src/routes/admin/posts/+page.server.ts index 18a6c68c..726208b6 100644 --- a/apps/blog-sveltekit/src/routes/admin/posts/+page.server.ts +++ b/apps/blog-sveltekit/src/routes/admin/posts/+page.server.ts @@ -5,6 +5,7 @@ import { csrf } from '@holo-js/security' import { deletePost, getAdminPostsData } from '$lib/server/blog' import { blogPostChanged } from '../../../../server/broadcast/blog-post-changed' +import BlogPostSaved from '../../../../server/events/blog/post-saved' import Post from '../../../../server/models/Post' import type { Actions, PageServerLoad } from './$types' @@ -25,6 +26,13 @@ export const actions = { await authorize('delete', post) await deletePost(id) await broadcast(blogPostChanged('deleted', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'deleted', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) redirect(303, '/admin/posts') }, diff --git a/apps/blog-sveltekit/src/routes/admin/posts/[id]/edit/+page.server.ts b/apps/blog-sveltekit/src/routes/admin/posts/[id]/edit/+page.server.ts index be16e1ab..89deb29b 100644 --- a/apps/blog-sveltekit/src/routes/admin/posts/[id]/edit/+page.server.ts +++ b/apps/blog-sveltekit/src/routes/admin/posts/[id]/edit/+page.server.ts @@ -8,6 +8,7 @@ import { csrf } from '@holo-js/security' import { postForm } from '$lib/schemas/blog' import { getAdminPostById } from '$lib/server/blog' import { blogPostChanged } from '../../../../../../server/broadcast/blog-post-changed' +import BlogPostSaved from '../../../../../../server/events/blog/post-saved' import Post from '../../../../../../server/models/Post' import type { Actions, PageServerLoad } from './$types' @@ -63,6 +64,13 @@ export const actions = { } await broadcast(blogPostChanged('updated', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'updated', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) redirect(303, '/admin/posts') }, diff --git a/apps/blog-sveltekit/src/routes/admin/posts/new/+page.server.ts b/apps/blog-sveltekit/src/routes/admin/posts/new/+page.server.ts index dbe6e9f5..fb6bb15f 100644 --- a/apps/blog-sveltekit/src/routes/admin/posts/new/+page.server.ts +++ b/apps/blog-sveltekit/src/routes/admin/posts/new/+page.server.ts @@ -8,6 +8,7 @@ import { csrf } from '@holo-js/security' import { postForm } from '$lib/schemas/blog' import { ensureAuthorId, getAdminPostsData } from '$lib/server/blog' import { blogPostChanged } from '../../../../../server/broadcast/blog-post-changed' +import BlogPostSaved from '../../../../../server/events/blog/post-saved' import Post from '../../../../../server/models/Post' import type { Actions, PageServerLoad } from './$types' @@ -58,6 +59,13 @@ export const actions = { } await broadcast(blogPostChanged('created', post.id, post.title, post.status, post.slug)) + await BlogPostSaved.dispatch({ + action: 'created', + postId: post.id, + title: post.title, + status: post.status, + slug: post.slug, + }) redirect(303, '/admin/posts') }, diff --git a/apps/blog-sveltekit/tests/blog-logic.mjs b/apps/blog-sveltekit/tests/blog-logic.mjs index fa98ee1f..ad8c68ef 100644 --- a/apps/blog-sveltekit/tests/blog-logic.mjs +++ b/apps/blog-sveltekit/tests/blog-logic.mjs @@ -69,6 +69,10 @@ async function getRegisteredCacheKeys() { return [...(await getCacheRuntimeBindings()?.dependencyIndex?.listRegisteredKeys() ?? [])] } +async function countQueuedBlogIndexJobs() { + return await DB.table('jobs').where('job', 'blog.index-post').count() +} + function resolveDriverCacheKey(indexedKey) { const delimiterIndex = indexedKey.indexOf('\u0000') return delimiterIndex === -1 ? indexedKey : indexedKey.slice(delimiterIndex + 1) @@ -624,6 +628,8 @@ try { }), assertInvalidPostStatusFailure) assert.equal(await Post.where('slug', 'unknown-route-status-post').first(), undefined) + const queuedJobsBeforePostLifecycle = await countQueuedBlogIndexJobs() + await createPost({ title: 'Logic Coverage Post', excerpt: 'Logic excerpt', @@ -632,6 +638,7 @@ try { categoryId: String(updatedCategory.id), tagIds: [frameworkTag.id], }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 1) assert.equal(await countRegisteredCacheKeys(), 0) await getPublishedPosts() assert.equal(await countRegisteredCacheKeys(), 1) @@ -718,6 +725,7 @@ try { categoryId: String(updatedCategory.id), tagIds: [frameworkTag.id], }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 2) assert.equal(await countRegisteredCacheKeys(), 0) await getPublishedPosts() assert.equal(await countRegisteredCacheKeys(), 1) @@ -767,6 +775,7 @@ try { categoryId: '', tagIds: [releaseTag.id], }) + assert.equal(await countQueuedBlogIndexJobs(), queuedJobsBeforePostLifecycle + 4) assert.equal(await countRegisteredCacheKeys(), 0) logicPost = await Post.with('category', 'tags').where('id', logicPost.id).first() diff --git a/apps/docs/docs/.vitepress/theme/components/Landing.vue b/apps/docs/docs/.vitepress/theme/components/Landing.vue new file mode 100644 index 00000000..33a31a56 --- /dev/null +++ b/apps/docs/docs/.vitepress/theme/components/Landing.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/apps/docs/docs/architecture.md b/apps/docs/docs/architecture.md index 2d38d30e..03837723 100644 --- a/apps/docs/docs/architecture.md +++ b/apps/docs/docs/architecture.md @@ -31,8 +31,9 @@ Holo-JS, not the host framework, owns discovery of canonical directories such as - `server/db` - `server/commands` -`holo prepare` turns those directories into generated artifacts under `.holo-js/generated`. Adapters -consume those generated registries instead of re-scanning the filesystem on every runtime path. +Discovery turns those directories into generated artifacts under `.holo-js/generated`. `holo dev` and +`holo build` refresh discovery before starting the framework lifecycle command, and adapters consume the +generated registries instead of re-scanning the filesystem on every runtime path. ## Portable runtime @@ -78,6 +79,8 @@ They should not own database semantics, storage semantics, or their own config m ## Hosting separation Framework support and hosting support are separate concerns. +Deployment planning follows the web-runtime-plus-worker-runtime model described in +[Deployment](/deployment). The adapter should stay free of direct assumptions about: diff --git a/apps/docs/docs/auth/personal-access-tokens.md b/apps/docs/docs/auth/personal-access-tokens.md index 9288943f..3e7d400b 100644 --- a/apps/docs/docs/auth/personal-access-tokens.md +++ b/apps/docs/docs/auth/personal-access-tokens.md @@ -88,8 +88,9 @@ return Response.json({ ``` For session guards, `login()` and `register()` still return session results. For token guards, they return personal -access token results. TypeScript infers the guard driver from `config/auth.ts` through the generated `holo prepare` -types, so `auth.guard('api')` is token-backed when the `api` guard uses `driver: 'token'`. +access token results. TypeScript infers the guard driver from `config/auth.ts` through generated discovery types, +so `auth.guard('api')` is token-backed when the `api` guard uses `driver: 'token'`. +Run `npx holo prepare` after changing guard configuration so those generated types stay current. ## Sending Tokens On Requests diff --git a/apps/docs/docs/broadcast/deployment-and-scaling.md b/apps/docs/docs/broadcast/deployment-and-scaling.md index 8f816d67..4430285b 100644 --- a/apps/docs/docs/broadcast/deployment-and-scaling.md +++ b/apps/docs/docs/broadcast/deployment-and-scaling.md @@ -32,6 +32,8 @@ bunx holo broadcast:work Run it under your process manager (systemd, PM2, containers, orchestration platform). Both Bun and Node runtimes are supported for the worker process. +See [Deployment](/deployment) for the broader host decision when the web app runs on a serverless or edge +platform. ## Running In Production diff --git a/apps/docs/docs/database/commands.md b/apps/docs/docs/database/commands.md index c63808c9..2cd95aff 100644 --- a/apps/docs/docs/database/commands.md +++ b/apps/docs/docs/database/commands.md @@ -57,8 +57,8 @@ npx holo make:migration create_users_table --create users npx holo make:migration add_status_to_users_table --table users ``` -This creates a timestamped migration in `server/db/migrations`. `holo prepare`, `holo dev`, and -`holo build` keep the generated registries current. +This creates a timestamped migration in `server/db/migrations`. `holo dev` and `holo build` keep the +generated registries current; `holo prepare` is available when you only want to refresh discovery output. Use: @@ -94,8 +94,8 @@ migration. npx holo make:seeder RoleSeeder ``` -This creates a seeder in `server/db/seeders` and refreshes generated discovery artifacts when the next -prepare step runs. +This creates a seeder in `server/db/seeders` and refreshes generated discovery artifacts during the next +`holo dev`, `holo build`, or manual discovery run. ### Create an observer @@ -224,7 +224,8 @@ Runtime commands read generated registries under `.holo-js/generated` to find: - commands - discovery metadata -Generator commands and `holo prepare` keep those artifacts in sync for you. +Generator commands refresh those artifacts when they can. `holo dev` and `holo build` also keep them current +before starting the framework lifecycle command. ## App commands diff --git a/apps/docs/docs/deployment.md b/apps/docs/docs/deployment.md index 136066b3..0ca224ad 100644 --- a/apps/docs/docs/deployment.md +++ b/apps/docs/docs/deployment.md @@ -8,31 +8,49 @@ anywhere the selected framework can run. ## Baseline model -Use Node or server output as the baseline mental model. Then let the selected host framework adapt that -to the hosting provider. +Think about a Holo-JS deployment as one web runtime plus zero or more worker runtimes. The web runtime +handles HTTP request work. Worker runtimes handle work that must keep running after a request ends. -That keeps Holo-JS portable across: +This model keeps Holo-JS portable across: - VPS and Docker +- managed servers and container platforms - Vercel - Cloudflare - other framework-supported hosts -Database and storage choices stay independent in that model. A project can change one without forcing a -rewrite of the other. +Database, storage, queue, and broadcast choices stay independent in this model. A project can change one +without forcing a rewrite of the others. -## Prepare artifacts deliberately +## Choose a host by runtime shape + +Deployment support depends on what the application needs to keep running after an HTTP request ends: + +| Runtime shape | Examples | Suitable hosting | +| --- | --- | --- | +| Web runtime only | static pages, server-rendered pages, API routes, auth, database access, storage access, forms, validation | Vercel, Cloudflare, or another framework host when the selected framework supports that provider | +| Web runtime plus workers | async queues, queued mail, queued notifications, queued events, queued media conversions, self-hosted broadcast, realtime browser updates | web app on any supported framework host, plus a managed server, container service, VPS, or worker-capable PaaS for the long-running processes | + +Serverless and edge request platforms are still valid for the web app in that architecture, but they should +not be the only runtime when the application also needs Holo-managed workers. A common split is to deploy the +web app to Vercel or Cloudflare and run queue or broadcast workers on a managed server or container service +with the same environment configuration. + +When the app has no worker runtime, a request-only deployment on Vercel, Cloudflare, or the selected +framework's hosted platform is usually enough. + +## Build artifacts deliberately For deployable artifacts, keep these steps explicit: ```bash -holo prepare holo config:cache npm run build ``` -`holo prepare` refreshes discovery output. `holo config:cache` is optional but useful when you want -production to read cached config instead of resolving live files on startup. +`npm run build` maps to `holo build` in scaffolded apps, and `holo build` refreshes discovery output before +starting the framework build. `holo config:cache` is optional but useful when you want production to read +cached config instead of resolving live files on startup. ## Start production through Holo @@ -49,9 +67,19 @@ starting the selected framework: - Nuxt runs `node .output/server/index.mjs` - SvelteKit runs `node build/index.js` -Use `holo start` in process managers such as PM2, systemd, Docker, or Forge instead of calling the -framework production entrypoint directly. That keeps database, auth, session, storage, and generated schema -runtime state initialized before server-rendered code runs. +Use `holo start` in process managers such as PM2, systemd, Docker, Forge, or container platforms instead of +calling the framework production entrypoint directly. That keeps database, auth, session, storage, and +generated schema runtime state initialized before server-rendered code runs. + +If the deployment includes Holo workers, run them as separate supervised processes. Do not start queue or +broadcast workers from inside the web request process. + +```bash +holo queue:work --connection redis +holo broadcast:work +``` + +Only run the workers the application actually uses. ## Run migrations intentionally @@ -60,7 +88,7 @@ Do not treat schema changes as a hidden startup side effect in production. A common deployment shape is: 1. run typecheck, lint, and tests -2. run `holo prepare` +2. optionally run `holo config:cache` 3. build the application 4. deploy the artifact 5. run migrations or another approved schema step diff --git a/apps/docs/docs/directory-structure.md b/apps/docs/docs/directory-structure.md index 62741806..df74a42b 100644 --- a/apps/docs/docs/directory-structure.md +++ b/apps/docs/docs/directory-structure.md @@ -90,7 +90,8 @@ Put observers here when model lifecycle behavior should move out of the model fi ### `server/commands` -Put app-specific CLI commands here. Holo-JS auto-discovers them during `holo prepare`. +Put app-specific CLI commands here. Holo-JS auto-discovers them during `holo dev`, `holo build`, and +manual discovery. Example: @@ -101,7 +102,7 @@ server/commands/db/reset.ts ### `server/jobs` -Put queue jobs here. Holo-JS auto-discovers them during `holo prepare`. +Put queue jobs here. Holo-JS auto-discovers them during `holo dev`, `holo build`, and manual discovery. Example: @@ -112,7 +113,8 @@ server/jobs/cache/prune.ts ### `server/events` -Put event definitions here. Holo-JS auto-discovers them during `holo prepare`. +Put event definitions here. Holo-JS auto-discovers them during `holo dev`, `holo build`, and manual +discovery. Example: @@ -123,7 +125,8 @@ server/events/billing/invoice-paid.ts ### `server/listeners` -Put event listeners here. Holo-JS auto-discovers them during `holo prepare`. +Put event listeners here. Holo-JS auto-discovers them during `holo dev`, `holo build`, and manual +discovery. Example: @@ -170,9 +173,9 @@ Use: ```bash holo dev holo build -holo prepare ``` `holo dev` and `holo build` keep `.holo-js/generated` in sync with `server/models`, `server/db`, -`server/commands`, `server/jobs`, `server/events`, and `server/listeners`. Use `holo prepare` only when -you want to refresh discovery output without starting the framework runtime. +`server/commands`, `server/jobs`, `server/events`, and `server/listeners`. + +Use `holo prepare` only when you want to refresh discovery output without starting dev or build. diff --git a/apps/docs/docs/events/api-reference.md b/apps/docs/docs/events/api-reference.md index 58ffd82a..d77209dc 100644 --- a/apps/docs/docs/events/api-reference.md +++ b/apps/docs/docs/events/api-reference.md @@ -17,6 +17,7 @@ Key points: - `name` is optional in code, but recommended - discovered app events can fall back to path-derived names - payload type is inferred from the generic +- `dispatch(payload)` dispatches the event with the same payload type ## `defineListener(...)` @@ -48,15 +49,32 @@ Options: - `afterCommit` - `handle(event)` +## Event definition dispatch + +Primary dispatch API: + +```ts +import UserRegistered from '../events/user/registered' + +await UserRegistered.dispatch({ + userId: 'user_1', + email: 'ava@example.com', +}) +``` + +Event definition dispatch returns a pending dispatch builder. + ## `Event` Facade API: ```ts import { Event } from '@holo-js/events' +import UserRegistered from '../events/user/registered' -await Event.dispatch('user.registered', { +await Event.dispatch(UserRegistered, { userId: 'user_1', + email: 'ava@example.com', }) ``` @@ -68,9 +86,11 @@ Helper API with the same semantics: ```ts import { dispatchEvent } from '@holo-js/events' +import UserRegistered from '../events/user/registered' -await dispatchEvent('user.registered', { +await dispatchEvent(UserRegistered, { userId: 'user_1', + email: 'ava@example.com', }) ``` @@ -78,7 +98,7 @@ This helper avoids naming conflicts with queue `dispatch(...)`. ## Pending dispatch fluent methods -Available on `Event.dispatch(...)` and `dispatchEvent(...)`: +Available on event definition dispatch, `Event.dispatch(...)`, and `dispatchEvent(...)`: - `.afterCommit()` - `.onConnection(name)` diff --git a/apps/docs/docs/events/defining-events.md b/apps/docs/docs/events/defining-events.md index 6aea2fbc..8fb2f48e 100644 --- a/apps/docs/docs/events/defining-events.md +++ b/apps/docs/docs/events/defining-events.md @@ -19,6 +19,9 @@ export default defineEvent<{ Event names are normalized by trimming surrounding whitespace. +The returned event definition exposes a typed `dispatch(payload)` method using the payload shape from +`defineEvent<...>()`. + ## Path-derived fallback names Discovered app events can omit `name`. In that case, Holo-JS derives the name from the file path under diff --git a/apps/docs/docs/events/dispatching-events.md b/apps/docs/docs/events/dispatching-events.md index e490a9c6..63874ce0 100644 --- a/apps/docs/docs/events/dispatching-events.md +++ b/apps/docs/docs/events/dispatching-events.md @@ -1,8 +1,19 @@ # Dispatching Events -Use the `Event` facade or `dispatchEvent(...)` helper. +Dispatch through the event definition for the strongest payload inference. -## Primary API: `Event.dispatch(...)` +## Primary API: event dispatch + +```ts +import UserRegistered from '../events/user/registered' + +await UserRegistered.dispatch({ + userId: 'user_1', + email: 'ava@example.com', +}) +``` + +## Facade API: `Event.dispatch(...)` ```ts import { Event } from '@holo-js/events' @@ -26,15 +37,14 @@ await dispatchEvent(UserRegistered, { }) ``` -`dispatchEvent(...)` is the conflict-free helper name. `@holo-js/events` does not use top-level -`dispatch(...)` as the primary API. +`dispatchEvent(...)` is the conflict-free helper name for code that should not call through the event object. ## Fluent controls Dispatch returns a pending builder: ```ts -await Event.dispatch(UserRegistered, { +await UserRegistered.dispatch({ userId: 'user_1', email: 'ava@example.com', }) diff --git a/apps/docs/docs/events/index.md b/apps/docs/docs/events/index.md index 8cf0f2d5..e650e309 100644 --- a/apps/docs/docs/events/index.md +++ b/apps/docs/docs/events/index.md @@ -11,7 +11,7 @@ The `@holo-js/events` package owns: - event definitions (`defineEvent(...)`) - listener definitions (`defineListener(...)`) -- event dispatch (`Event.dispatch(...)`, `dispatchEvent(...)`) +- event dispatch (`UserRegistered.dispatch(...)`, `Event.dispatch(...)`, `dispatchEvent(...)`) - listener fan-out (sync and queued listeners) - dispatch-level and listener-level `afterCommit` behavior @@ -28,7 +28,7 @@ Queued listeners become queue jobs internally, but users still author listeners, ## Quick start ```ts -import { defineEvent, defineListener, Event } from '@holo-js/events' +import { defineEvent, defineListener } from '@holo-js/events' export const UserRegistered = defineEvent<{ userId: string @@ -47,7 +47,7 @@ export const SendWelcomeEmail = defineListener({ }, }) -await Event.dispatch(UserRegistered, { +await UserRegistered.dispatch({ userId: 'user_1', email: 'ava@example.com', }).afterCommit() diff --git a/apps/docs/docs/events/queued-listeners.md b/apps/docs/docs/events/queued-listeners.md index 54ac4007..553c91de 100644 --- a/apps/docs/docs/events/queued-listeners.md +++ b/apps/docs/docs/events/queued-listeners.md @@ -1,6 +1,8 @@ # Queued Listeners Queued listeners are listener definitions with `queue: true`. +If the selected queue connection uses an async driver, production needs a supervised queue worker. See +[Deployment](/deployment) for host selection and [Queue Workers](/queue/workers) for worker commands. ```ts import { defineListener } from '@holo-js/events' diff --git a/apps/docs/docs/events/setup-and-cli.md b/apps/docs/docs/events/setup-and-cli.md index 8dae890c..cc952004 100644 --- a/apps/docs/docs/events/setup-and-cli.md +++ b/apps/docs/docs/events/setup-and-cli.md @@ -55,7 +55,8 @@ This adds the `@holo-js/events` package dependency when needed and creates the d - `server/events` - `server/listeners` -After install, run normal discovery through `holo dev`, `holo build`, or `holo prepare`. +After install, the next `holo dev` or `holo build` refreshes discovery automatically. Use `holo prepare` +only when you want to refresh discovery without starting dev or build. ## Generated metadata diff --git a/apps/docs/docs/events/transactions-after-commit.md b/apps/docs/docs/events/transactions-after-commit.md index 51138e45..caab890a 100644 --- a/apps/docs/docs/events/transactions-after-commit.md +++ b/apps/docs/docs/events/transactions-after-commit.md @@ -13,11 +13,10 @@ Defer the full dispatch fan-out: ```ts import { DB } from '@holo-js/db' -import { Event } from '@holo-js/events' import UserRegistered from '../events/user/registered' await DB.transaction(async () => { - await Event.dispatch(UserRegistered, { + await UserRegistered.dispatch({ userId: 'user_1', email: 'ava@example.com', }).afterCommit() diff --git a/apps/docs/docs/index.md b/apps/docs/docs/index.md index 5782750e..319fb1e2 100644 --- a/apps/docs/docs/index.md +++ b/apps/docs/docs/index.md @@ -1,161 +1,14 @@ --- -layout: home -title: Holo-JS Documentation +layout: page +sidebar: false +aside: false +title: Holo-JS — The backend stack for JavaScript frameworks titleTemplate: false -hero: - name: Holo-JS - text: Build server-backed apps with Nuxt, Next.js, or SvelteKit. - tagline: Holo-JS brings configurable backend services, typed database and storage layers, and generated discovery while the host framework keeps SSR and routing. - actions: - - theme: brand - text: Install Holo-JS - link: /installation - - theme: alt - text: Configure your app - link: /configuration -features: - - title: Broadcast And Flux - details: Self-hosted realtime broadcasting with generated config and auth routes, channel guards, and framework-native Flux helpers. - link: /broadcast/ +pageClass: holo-landing --- -## What Holo-JS gives you + -Holo-JS owns backend runtime concerns: config loading, layered env files, model discovery, migrations, -seeders, storage, media, and typed runtime access. Nuxt, Next.js, and SvelteKit still own routing, SSR, -rendering, and deployment output. The result is a configurable backend layer that stays flexible across -database drivers, storage drivers, and hosting targets. - -::: code-group - -```bash [npm] -npm create holo-js@latest my-app -cd my-app -npm run dev -``` - -```bash [pnpm] -pnpm create holo-js@latest my-app -cd my-app -pnpm dev -``` - -```bash [Yarn] -yarn create holo-js my-app -cd my-app -yarn dev -``` - -```bash [Bun] -bun create holo-js my-app -cd my-app -bun run dev -``` - -::: - -```ts -// config/database.ts -import { defineDatabaseConfig } from '@holo-js/config' - -export default defineDatabaseConfig({ - defaultConnection: 'main', - connections: { - main: { - driver: 'sqlite', - url: './storage/database.sqlite', - }, - }, -}) -``` - -The user-facing setup surface stays small: `config/*.ts`, layered env files, `server/models`, -`server/db`, `server/commands`, `server/jobs`, `server/events`, `server/listeners`, and `server/mail`. - -## Supported frameworks - -- Nuxt -- Next.js -- SvelteKit - -## Start here - -- [Installation](/installation) -- [Configuration](/configuration) -- [Directory Structure](/directory-structure) -- [Application Development](/development/) - -## Working on Holo-JS itself - -These docs mostly describe how to build apps with Holo-JS. - -If you are contributing to the framework packages inside this repository, use: - -- [Contributing to Holo-JS](/development/contributing) - -## Core guides - -- [Architecture](/architecture) -- [Routing](/routing) -- [Runtime Services](/runtime-services) -- [Validation](/validation/) -- [Forms](/forms/) -- [Events](/events/) -- [Broadcast](/broadcast/) -- [Realtime](/realtime/) -- [Cache](/cache/) -- [Notifications](/notifications/) -- [Authorization](/authorization/) -- [Mail](/mail/) -- [Storage](/storage) -- [Queue](/queue/) -- [Media](/media) -- [Security](/security) - -## Backend workflow - -- `npm create holo-js@latest` scaffolds the project and asks for framework, database, package manager, and storage defaults. -- `holo prepare` scans Holo-JS-owned directories and regenerates typed registries under `.holo-js/generated`. -- `config/queue.ts` is scaffolded by default and starts on the `sync` driver. -- `holo install broadcast` scaffolds `config/broadcast.ts`, `server/broadcast`, `server/channels`, and the generated `/broadcasting/config` and `/broadcasting/auth` routes. -- `holo install cache` scaffolds `config/cache.ts` and cache maintenance commands. -- `holo install media` scaffolds `config/media.ts` and the media table migration. -- `holo install authorization` scaffolds `server/policies` and `server/abilities`. -- `holo dev` reruns discovery, watches Holo-JS files, and then starts the selected framework. -- `holo build` refreshes discovery before the framework build. -- `holo config:cache` and `holo config:clear` manage production config caching. - -## Flexible by design - -- use the same model and query APIs across supported database drivers -- pair any supported storage driver with any supported database driver -- keep backend infrastructure configurable through server-side config -- deploy anywhere the selected host framework can run - -## Build your data layer - -- [Database Getting Started](/database/) -- [Database Commands](/database/commands) -- [Query Builder](/database/query-builder/) -- [Pagination](/database/pagination) -- [Migrations](/database/migrations) -- [Seeding](/database/seeding) -- [ORM Getting Started](/orm/) -- [ORM Relationships](/orm/relationships) -- [Mutators & Casts](/orm/mutators-casts) -- [Factories](/orm/factories) - -## Ship safely - -- [Testing](/testing) -- [Deployment](/deployment) -- [Security](/security) - -## Validate and submit - -- [Validation Overview](/validation/) -- [Validation Rules And Errors](/validation/rules-and-errors) -- [Forms Overview](/forms/) -- [Server Validation](/forms/server-validation) -- [Client Usage](/forms/client-usage) -- [Framework Integration](/forms/framework-integration) + diff --git a/apps/docs/docs/mail/queueing-mail.md b/apps/docs/docs/mail/queueing-mail.md index 1e9f14a0..7c624d71 100644 --- a/apps/docs/docs/mail/queueing-mail.md +++ b/apps/docs/docs/mail/queueing-mail.md @@ -12,6 +12,8 @@ await sendMail(invoicePaidMail) ``` This requires the `@holo-js/queue` package to be installed. +If the configured queue connection uses an async driver, production also needs a supervised queue worker. +See [Deployment](/deployment) for host selection and [Queue Workers](/queue/workers) for worker commands. ## Queue Configuration @@ -75,4 +77,4 @@ export default defineMailConfig({ 2. A queue worker processes the job by: - Reconstructing the mail from the serialized data - Sending it using the configured mailer -3. If `.afterCommit()` is used, the mail is only queued after database transactions commit \ No newline at end of file +3. If `.afterCommit()` is used, the mail is only queued after database transactions commit diff --git a/apps/docs/docs/media.md b/apps/docs/docs/media.md index f7efdacc..d2e02437 100644 --- a/apps/docs/docs/media.md +++ b/apps/docs/docs/media.md @@ -321,6 +321,8 @@ Run a worker for async queue drivers: npx holo queue:work --connection redis --queue media ``` +See [Deployment](/deployment) when choosing a production host for queued media conversions. + ## Clearing and deleting media Clear a whole collection: diff --git a/apps/docs/docs/notifications/queueing-notifications.md b/apps/docs/docs/notifications/queueing-notifications.md index 068c1a1d..2bb78e1c 100644 --- a/apps/docs/docs/notifications/queueing-notifications.md +++ b/apps/docs/docs/notifications/queueing-notifications.md @@ -12,6 +12,8 @@ await notify(user, invoicePaid) ``` This requires the `@holo-js/queue` package to be installed. +If the configured queue connection uses an async driver, production also needs a supervised queue worker. +See [Deployment](/deployment) for host selection and [Queue Workers](/queue/workers) for worker commands. ## Queue Configuration diff --git a/apps/docs/docs/public/mark.svg b/apps/docs/docs/public/mark.svg index 58d9d7ff..aa69146f 100644 --- a/apps/docs/docs/public/mark.svg +++ b/apps/docs/docs/public/mark.svg @@ -1,4 +1,4 @@ - + diff --git a/apps/docs/docs/queue/index.md b/apps/docs/docs/queue/index.md index bf8e82f3..0a5cf609 100644 --- a/apps/docs/docs/queue/index.md +++ b/apps/docs/docs/queue/index.md @@ -56,6 +56,8 @@ export default defineQueueConfig({ ``` `sync` runs the job inline. It is the simplest default and needs no worker process. +Async drivers need a separate worker runtime in production. See [Deployment](/deployment) for host selection +and [Workers](/queue/workers) for worker commands. ## End-to-end driver examples @@ -64,9 +66,9 @@ export default defineQueueConfig({ Use `sync` when you want immediate execution in local development, tests, or low-volume app flows. ```ts -import { dispatch } from '@holo-js/queue' +import SendDigest from '../jobs/reports/send-digest' -await dispatch('reports.send-digest', { +await SendDigest.dispatch({ reportId: 'daily-summary', }) ``` @@ -185,12 +187,12 @@ npx holo queue:work --connection database ## Dispatch overview -Use `dispatch()` for normal queued execution: +Use the job definition for typed queued execution: ```ts -import { dispatch } from '@holo-js/queue' +import SendDigest from '../jobs/reports/send-digest' -await dispatch('reports.send-digest', { +await SendDigest.dispatch({ reportId: 'daily-summary', }) .onConnection('redis') @@ -204,13 +206,30 @@ await dispatch('reports.send-digest', { }) ``` +Use the string-based dispatch helper when the job name is dynamic or when you prefer dispatching by name: + +```ts +import { dispatch } from '@holo-js/queue' + +await dispatch('reports.send-digest', { + reportId: 'daily-summary', +}) + .onConnection('redis') + .onQueue('emails') +``` + If dispatch must wait for a successful database commit, compose that explicitly with `DB.afterCommit()` from `@holo-js/db`. Use `dispatchSync()` when the current code path must execute the job immediately: ```ts +import SendDigest from '../jobs/reports/send-digest' import { dispatchSync } from '@holo-js/queue' +await SendDigest.dispatchSync({ + reportId: 'daily-summary', +}) + await dispatchSync('reports.send-digest', { reportId: 'daily-summary', }) diff --git a/apps/docs/docs/queue/jobs.md b/apps/docs/docs/queue/jobs.md index 33528836..922d3b35 100644 --- a/apps/docs/docs/queue/jobs.md +++ b/apps/docs/docs/queue/jobs.md @@ -21,15 +21,18 @@ server/jobs/reports/send-digest.ts Use `defineJob(...)` from `@holo-js/queue`: ```ts +// server/jobs/reports/send-digest.ts import { defineJob } from '@holo-js/queue' -export default defineJob({ +const SendDigest = defineJob<{ + userId: string +}>({ queue: 'emails', connection: 'redis', tries: 3, backoff: [5, 30, 120], timeout: 60, - async handle(payload: { userId: string }, context) { + async handle(payload, context) { void context await sendDigestEmail(payload.userId) }, @@ -44,6 +47,8 @@ export default defineJob({ void context }, }) + +export default SendDigest ``` `defineJob(...)` supports: @@ -68,18 +73,22 @@ Examples: `defineJob(...)` does not need a `name` field for discovered app jobs. +The returned job definition exposes typed `dispatch(payload)` and `dispatchSync(payload)` methods using +the payload shape from `defineJob<...>()`. The `handle(payload, context)` parameter is inferred from that +job payload type. + ## Dispatching jobs Dispatch from any server-side code: ```ts -import { dispatch, dispatchSync, Queue } from '@holo-js/queue' +import SendDigest from '../jobs/reports/send-digest' -await dispatch('reports.send-digest', { +await SendDigest.dispatch({ userId: 'user_1', }) -await dispatch('reports.send-digest', { +await SendDigest.dispatch({ userId: 'user_1', }) .onConnection('redis') @@ -92,7 +101,17 @@ await dispatch('reports.send-digest', { console.error(error) }) -await dispatchSync('reports.send-digest', { +await SendDigest.dispatchSync({ + userId: 'user_1', +}) +``` + +The string-based dispatch APIs remain available when you need to dispatch dynamically: + +```ts +import { dispatch, Queue } from '@holo-js/queue' + +await dispatch('reports.send-digest', { userId: 'user_1', }) @@ -101,7 +120,7 @@ await Queue.connection('redis') .onQueue('emails') ``` -When dispatch must wait for a successful database commit, call `DB.afterCommit(() => dispatch(...).dispatch())` in your application layer instead of relying on queue-managed transaction deferral. +When dispatch must wait for a successful database commit, call `DB.afterCommit(() => SendDigest.dispatch(...).dispatch())` in your application layer instead of relying on queue-managed transaction deferral. Connection and queue defaults are separate from job identity: @@ -119,8 +138,10 @@ The `handle()` callback receives a job context: ```ts import { defineJob } from '@holo-js/queue' -export default defineJob({ - async handle(payload: { userId: string }, context) { +export default defineJob<{ + userId: string +}>({ + async handle(payload, context) { context.jobId context.jobName context.connection @@ -184,7 +205,6 @@ Holo-JS refreshes the generated job registry during: - `holo dev` - `holo build` -- `npx holo prepare` Run `npx holo prepare` directly when you only want to rebuild discovery output. diff --git a/apps/docs/docs/queue/workers.md b/apps/docs/docs/queue/workers.md index 52ebfc88..72c79931 100644 --- a/apps/docs/docs/queue/workers.md +++ b/apps/docs/docs/queue/workers.md @@ -3,6 +3,9 @@ Workers are only needed for async queue drivers such as `redis` and `database`. The `sync` driver runs jobs inline and does not need a worker process. +In production, run queue workers as supervised long-running processes, separate from the web request process. +See [Deployment](/deployment) when choosing a host for applications that use async queues. + ## `queue:work` Run the queue worker: diff --git a/apps/docs/docs/realtime/index.md b/apps/docs/docs/realtime/index.md index 80c74017..04290d81 100644 --- a/apps/docs/docs/realtime/index.md +++ b/apps/docs/docs/realtime/index.md @@ -41,6 +41,7 @@ holo broadcast:work If the worker is not running, realtime queries and mutations still execute normally, but browser live updates cannot be delivered. +See [Deployment](/deployment) when choosing a production host for realtime apps. ## Define a query diff --git a/apps/docs/docs/runtime-services.md b/apps/docs/docs/runtime-services.md index 7401256d..9821151d 100644 --- a/apps/docs/docs/runtime-services.md +++ b/apps/docs/docs/runtime-services.md @@ -15,6 +15,9 @@ Most application code touches runtime services from server-side code: These are not client-side APIs. +At deployment time, these services run inside either the web runtime or a worker runtime. Use +[Deployment](/deployment) to decide whether the application needs separate long-running worker processes. + ## Shared app access In Nuxt, Next.js, and SvelteKit, runtime context is prepared for you by the framework bootstrap. diff --git a/bun.lock b/bun.lock index 631ec77d..a6a04b37 100644 --- a/bun.lock +++ b/bun.lock @@ -134,6 +134,7 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", + "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-react": "workspace:*", "@holo-js/forms": "workspace:*", @@ -221,6 +222,7 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", + "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-svelte": "workspace:*", "@holo-js/forms": "workspace:*", @@ -299,7 +301,7 @@ }, "packages/adapter-next": { "name": "@holo-js/adapter-next", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "@holo-js/core": "catalog:", @@ -325,7 +327,7 @@ }, "packages/adapter-nuxt": { "name": "@holo-js/adapter-nuxt", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "@holo-js/core": "catalog:", @@ -361,7 +363,7 @@ }, "packages/adapter-sveltekit": { "name": "@holo-js/adapter-sveltekit", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "@holo-js/core": "catalog:", @@ -388,7 +390,7 @@ }, "packages/auth": { "name": "@holo-js/auth", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "@holo-js/validation": "catalog:", @@ -421,7 +423,7 @@ }, "packages/auth-clerk": { "name": "@holo-js/auth-clerk", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@clerk/backend": "catalog:", "@holo-js/auth": "catalog:", @@ -437,7 +439,7 @@ }, "packages/auth-social": { "name": "@holo-js/auth-social", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth": "catalog:", "@holo-js/config": "catalog:", @@ -451,7 +453,7 @@ }, "packages/auth-social-apple": { "name": "@holo-js/auth-social-apple", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth": "catalog:", "@holo-js/auth-social": "catalog:", @@ -466,7 +468,7 @@ }, "packages/auth-social-discord": { "name": "@holo-js/auth-social-discord", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth-social": "catalog:", "@holo-js/config": "catalog:", @@ -480,7 +482,7 @@ }, "packages/auth-social-facebook": { "name": "@holo-js/auth-social-facebook", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth-social": "catalog:", }, @@ -493,7 +495,7 @@ }, "packages/auth-social-github": { "name": "@holo-js/auth-social-github", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth-social": "catalog:", }, @@ -506,7 +508,7 @@ }, "packages/auth-social-google": { "name": "@holo-js/auth-social-google", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth-social": "catalog:", "@holo-js/config": "catalog:", @@ -520,7 +522,7 @@ }, "packages/auth-social-linkedin": { "name": "@holo-js/auth-social-linkedin", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth-social": "catalog:", "@holo-js/config": "catalog:", @@ -534,7 +536,7 @@ }, "packages/auth-workos": { "name": "@holo-js/auth-workos", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/auth": "catalog:", "@holo-js/config": "catalog:", @@ -549,7 +551,7 @@ }, "packages/authorization": { "name": "@holo-js/authorization", - "version": "0.1.9", + "version": "0.2.4", "devDependencies": { "@types/node": "catalog:", "tsup": "catalog:", @@ -559,7 +561,7 @@ }, "packages/broadcast": { "name": "@holo-js/broadcast", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "@holo-js/validation": "catalog:", @@ -582,7 +584,7 @@ }, "packages/cache": { "name": "@holo-js/cache", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", }, @@ -595,7 +597,7 @@ }, "packages/cache-db": { "name": "@holo-js/cache-db", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "tslib": "catalog:", }, @@ -614,7 +616,7 @@ }, "packages/cache-redis": { "name": "@holo-js/cache-redis", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "ioredis": "catalog:", "tslib": "catalog:", @@ -632,7 +634,7 @@ }, "packages/cli": { "name": "@holo-js/cli", - "version": "0.1.9", + "version": "0.2.4", "bin": { "holo": "./dist/bin/holo.mjs", "holo-js": "./dist/bin/holo.mjs", @@ -658,7 +660,7 @@ }, "packages/config": { "name": "@holo-js/config", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/db": "catalog:", }, @@ -670,7 +672,7 @@ }, "packages/core": { "name": "@holo-js/core", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "@holo-js/db": "catalog:", @@ -728,7 +730,7 @@ }, "packages/create-holo-js": { "name": "create-holo-js", - "version": "0.1.9", + "version": "0.2.4", "bin": { "create-holo-js": "dist/bin/create-holo-js.mjs", }, @@ -744,7 +746,7 @@ }, "packages/db": { "name": "@holo-js/db", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "inflection": "catalog:", "ulid": "catalog:", @@ -770,7 +772,7 @@ }, "packages/db-mysql": { "name": "@holo-js/db-mysql", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "mysql2": "catalog:", }, @@ -786,7 +788,7 @@ }, "packages/db-postgres": { "name": "@holo-js/db-postgres", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "pg": "catalog:", }, @@ -803,7 +805,7 @@ }, "packages/db-sqlite": { "name": "@holo-js/db-sqlite", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "better-sqlite3": "catalog:", }, @@ -820,7 +822,7 @@ }, "packages/events": { "name": "@holo-js/events", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/db": "catalog:", }, @@ -840,7 +842,7 @@ }, "packages/flux": { "name": "@holo-js/flux", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/broadcast": "catalog:", }, @@ -853,7 +855,7 @@ }, "packages/flux-react": { "name": "@holo-js/flux-react", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/broadcast": "catalog:", "@holo-js/flux": "catalog:", @@ -876,7 +878,7 @@ }, "packages/flux-svelte": { "name": "@holo-js/flux-svelte", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/broadcast": "catalog:", "@holo-js/flux": "catalog:", @@ -894,7 +896,7 @@ }, "packages/flux-vue": { "name": "@holo-js/flux-vue", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/broadcast": "catalog:", "@holo-js/flux": "catalog:", @@ -912,7 +914,7 @@ }, "packages/forms": { "name": "@holo-js/forms", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/validation": "catalog:", }, @@ -931,7 +933,7 @@ }, "packages/mail": { "name": "@holo-js/mail", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", "nodemailer": "catalog:", @@ -953,7 +955,7 @@ }, "packages/media": { "name": "@holo-js/media", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/db": "catalog:", "@holo-js/queue": "catalog:", @@ -968,7 +970,7 @@ }, "packages/notifications": { "name": "@holo-js/notifications", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", }, @@ -989,7 +991,7 @@ }, "packages/queue": { "name": "@holo-js/queue", - "version": "0.1.9", + "version": "0.2.4", "devDependencies": { "@holo-js/queue-redis": "catalog:", "@types/node": "catalog:", @@ -1006,7 +1008,7 @@ }, "packages/queue-db": { "name": "@holo-js/queue-db", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/db": "catalog:", "@holo-js/queue": "catalog:", @@ -1020,7 +1022,7 @@ }, "packages/queue-redis": { "name": "@holo-js/queue-redis", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "bullmq": "catalog:", "ioredis": "catalog:", @@ -1039,7 +1041,7 @@ }, "packages/realtime": { "name": "@holo-js/realtime", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/db": "catalog:", "@holo-js/validation": "catalog:", @@ -1061,7 +1063,7 @@ }, "packages/security": { "name": "@holo-js/security", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", }, @@ -1087,7 +1089,7 @@ }, "packages/session": { "name": "@holo-js/session", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", }, @@ -1107,7 +1109,7 @@ }, "packages/storage": { "name": "@holo-js/storage", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "@holo-js/config": "catalog:", }, @@ -1127,7 +1129,7 @@ }, "packages/storage-s3": { "name": "@holo-js/storage-s3", - "version": "0.1.9", + "version": "0.2.4", "devDependencies": { "@types/node": "catalog:", "tsup": "catalog:", @@ -1137,7 +1139,7 @@ }, "packages/validation": { "name": "@holo-js/validation", - "version": "0.1.9", + "version": "0.2.4", "dependencies": { "valibot": "catalog:", }, @@ -1166,49 +1168,49 @@ "@clack/prompts": "^1.5.1", "@clerk/backend": "^3.4.7", "@eslint/js": "^9.17.0", - "@holo-js/adapter-next": "^0.1.9", - "@holo-js/adapter-nuxt": "^0.1.9", - "@holo-js/adapter-sveltekit": "^0.1.9", - "@holo-js/auth": "^0.1.9", - "@holo-js/auth-clerk": "^0.1.9", - "@holo-js/auth-social": "^0.1.9", - "@holo-js/auth-social-apple": "^0.1.9", - "@holo-js/auth-social-discord": "^0.1.9", - "@holo-js/auth-social-facebook": "^0.1.9", - "@holo-js/auth-social-github": "^0.1.9", - "@holo-js/auth-social-google": "^0.1.9", - "@holo-js/auth-social-linkedin": "^0.1.9", - "@holo-js/auth-workos": "^0.1.9", - "@holo-js/authorization": "^0.1.9", - "@holo-js/broadcast": "^0.1.9", - "@holo-js/cache": "^0.1.9", - "@holo-js/cache-db": "^0.1.9", - "@holo-js/cache-redis": "^0.1.9", - "@holo-js/cli": "^0.1.9", - "@holo-js/config": "^0.1.9", - "@holo-js/core": "^0.1.9", - "@holo-js/db": "^0.1.9", - "@holo-js/db-mysql": "^0.1.9", - "@holo-js/db-postgres": "^0.1.9", - "@holo-js/db-sqlite": "^0.1.9", - "@holo-js/events": "^0.1.9", - "@holo-js/flux": "^0.1.9", - "@holo-js/flux-react": "^0.1.9", - "@holo-js/flux-svelte": "^0.1.9", - "@holo-js/flux-vue": "^0.1.9", - "@holo-js/forms": "^0.1.9", - "@holo-js/mail": "^0.1.9", - "@holo-js/media": "^0.1.9", - "@holo-js/notifications": "^0.1.9", - "@holo-js/queue": "^0.1.9", - "@holo-js/queue-db": "^0.1.9", - "@holo-js/queue-redis": "^0.1.9", - "@holo-js/realtime": "^0.1.9", - "@holo-js/security": "^0.1.9", - "@holo-js/session": "^0.1.9", - "@holo-js/storage": "^0.1.9", - "@holo-js/storage-s3": "^0.1.9", - "@holo-js/validation": "^0.1.9", + "@holo-js/adapter-next": "^0.2.4", + "@holo-js/adapter-nuxt": "^0.2.4", + "@holo-js/adapter-sveltekit": "^0.2.4", + "@holo-js/auth": "^0.2.4", + "@holo-js/auth-clerk": "^0.2.4", + "@holo-js/auth-social": "^0.2.4", + "@holo-js/auth-social-apple": "^0.2.4", + "@holo-js/auth-social-discord": "^0.2.4", + "@holo-js/auth-social-facebook": "^0.2.4", + "@holo-js/auth-social-github": "^0.2.4", + "@holo-js/auth-social-google": "^0.2.4", + "@holo-js/auth-social-linkedin": "^0.2.4", + "@holo-js/auth-workos": "^0.2.4", + "@holo-js/authorization": "^0.2.4", + "@holo-js/broadcast": "^0.2.4", + "@holo-js/cache": "^0.2.4", + "@holo-js/cache-db": "^0.2.4", + "@holo-js/cache-redis": "^0.2.4", + "@holo-js/cli": "^0.2.4", + "@holo-js/config": "^0.2.4", + "@holo-js/core": "^0.2.4", + "@holo-js/db": "^0.2.4", + "@holo-js/db-mysql": "^0.2.4", + "@holo-js/db-postgres": "^0.2.4", + "@holo-js/db-sqlite": "^0.2.4", + "@holo-js/events": "^0.2.4", + "@holo-js/flux": "^0.2.4", + "@holo-js/flux-react": "^0.2.4", + "@holo-js/flux-svelte": "^0.2.4", + "@holo-js/flux-vue": "^0.2.4", + "@holo-js/forms": "^0.2.4", + "@holo-js/mail": "^0.2.4", + "@holo-js/media": "^0.2.4", + "@holo-js/notifications": "^0.2.4", + "@holo-js/queue": "^0.2.4", + "@holo-js/queue-db": "^0.2.4", + "@holo-js/queue-redis": "^0.2.4", + "@holo-js/realtime": "^0.2.4", + "@holo-js/security": "^0.2.4", + "@holo-js/session": "^0.2.4", + "@holo-js/storage": "^0.2.4", + "@holo-js/storage-s3": "^0.2.4", + "@holo-js/validation": "^0.2.4", "@nuxt/kit": "^4.4.4", "@nuxt/module-builder": "^1.0.2", "@sveltejs/adapter-node": "^5.5.4", @@ -1225,7 +1227,7 @@ "@vitest/coverage-v8": "^4.1.5", "better-sqlite3": "^11.7.0", "bullmq": "^5.71.0", - "create-holo-js": "^0.1.9", + "create-holo-js": "^0.2.4", "esbuild": "^0.27.4", "eslint": "^9.17.0", "fast-check": "^4.5.3", diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 00000000..21d0015e --- /dev/null +++ b/netlify.toml @@ -0,0 +1,8 @@ +[build] + base = "apps/docs" + command = "bun run build" + publish = "docs/.vitepress/dist" + +[build.environment] + BUN_VERSION = "1.3.9" + BUN_FLAGS = "--ignore-scripts" diff --git a/packages/cli/src/cache-migrations.ts b/packages/cli/src/cache-migrations.ts index f883f10e..273fb39d 100644 --- a/packages/cli/src/cache-migrations.ts +++ b/packages/cli/src/cache-migrations.ts @@ -138,14 +138,14 @@ export function renderCacheTableMigration( const lockTableDefinition = resolveCacheDatabaseTableDefinition('locks') return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ...renderCacheTableCreateStatement(tableName, entryTableDefinition), ...renderCacheTableCreateStatement(lockTableName, lockTableDefinition), ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ` await schema.dropTable('${escapeSingleQuotedString(lockTableName)}')`, ` await schema.dropTable('${escapeSingleQuotedString(tableName)}')`, ' },', diff --git a/packages/cli/src/media-migrations.ts b/packages/cli/src/media-migrations.ts index 7f198a2a..88ee79a5 100644 --- a/packages/cli/src/media-migrations.ts +++ b/packages/cli/src/media-migrations.ts @@ -27,10 +27,10 @@ export function renderMediaTableMigration(tableName = DEFAULT_MEDIA_TABLE): stri const tableNameLiteral = JSON.stringify(tableName) return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ` await schema.createTable(${tableNameLiteral}, (table) => {`, ' table.id()', ' table.uuid(\'uuid\').unique()', @@ -52,7 +52,7 @@ export function renderMediaTableMigration(tableName = DEFAULT_MEDIA_TABLE): stri ' table.index([\'model_type\', \'model_id\', \'collection_name\'])', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ` await schema.dropTable(${tableNameLiteral})`, ' },', '})', diff --git a/packages/cli/src/project/scaffold/project-renderers.ts b/packages/cli/src/project/scaffold/project-renderers.ts index 7a0b7957..6f5a59e4 100644 --- a/packages/cli/src/project/scaffold/project-renderers.ts +++ b/packages/cli/src/project/scaffold/project-renderers.ts @@ -194,10 +194,10 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { switch (slug) { case 'create_users': return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'users\', (table) => {', ' table.id()', ' table.string(\'name\')', @@ -208,7 +208,7 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ' table.timestamps()', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'users\')', ' },', '})', @@ -216,10 +216,10 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ].join('\n') case 'create_sessions': return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'sessions\', (table) => {', ' table.string(\'id\').primaryKey()', ' table.string(\'store\').default(\'database\')', @@ -232,7 +232,7 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ' table.index([\'expires_at\'])', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'sessions\')', ' },', '})', @@ -240,10 +240,10 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ].join('\n') case 'create_auth_identities': return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'auth_identities\', (table) => {', ' table.id()', ' table.string(\'user_id\')', @@ -260,7 +260,7 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ' table.unique([\'provider\', \'provider_user_id\'], \'auth_identities_provider_user_unique\')', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'auth_identities\')', ' },', '})', @@ -268,10 +268,10 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ].join('\n') case 'create_personal_access_tokens': return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'personal_access_tokens\', (table) => {', ' table.uuid(\'id\').primaryKey()', ' table.string(\'provider\').default(\'users\')', @@ -286,7 +286,7 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ' table.index([\'user_id\'])', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'personal_access_tokens\')', ' },', '})', @@ -294,10 +294,10 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ].join('\n') case 'create_password_reset_tokens': return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'password_reset_tokens\', (table) => {', ' table.uuid(\'id\').primaryKey()', ' table.string(\'provider\').default(\'users\')', @@ -310,7 +310,7 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ' table.index([\'email\'])', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'password_reset_tokens\')', ' },', '})', @@ -318,10 +318,10 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ].join('\n') case 'create_email_verification_tokens': return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'email_verification_tokens\', (table) => {', ' table.uuid(\'id\').primaryKey()', ' table.string(\'provider\').default(\'users\')', @@ -336,7 +336,7 @@ export function renderAuthMigration(slug: AuthMigrationSlug): string { ' table.index([\'email\'])', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'email_verification_tokens\')', ' },', '})', @@ -354,10 +354,10 @@ export function createAuthMigrationFiles(date = new Date()): readonly Scaffolded export function renderNotificationsMigration(): string { return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', + ' async up({ schema }) {', ' await schema.createTable(\'notifications\', (table) => {', ' table.string(\'id\').primaryKey()', ' table.string(\'type\').nullable()', @@ -371,7 +371,7 @@ export function renderNotificationsMigration(): string { ' table.index([\'read_at\'])', ' })', ' },', - ' async down({ schema }: MigrationContext) {', + ' async down({ schema }) {', ' await schema.dropTable(\'notifications\')', ' },', '})', diff --git a/packages/cli/src/queue-migrations.ts b/packages/cli/src/queue-migrations.ts index 0fc82450..7041e18a 100644 --- a/packages/cli/src/queue-migrations.ts +++ b/packages/cli/src/queue-migrations.ts @@ -30,16 +30,19 @@ export function normalizeQueueMigrationName(tableName: string): string { return normalizeMigrationSlug(`create_${tableName.replaceAll('.', '_')}_table`) } +function renderStringLiteral(value: string): string { + return JSON.stringify(value) +} + export function renderQueueTableMigration(tableName: string): string { - const tableNameLiteral = JSON.stringify(tableName) const indexPrefix = tableName.replaceAll('.', '_') return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ` await schema.createTable(${tableNameLiteral}, (table) => {`, + ' async up({ schema }) {', + ` await schema.createTable(${renderStringLiteral(tableName)}, (table) => {`, ' table.string(\'id\').primaryKey()', ' table.string(\'job\')', ' table.string(\'connection\')', @@ -51,13 +54,13 @@ export function renderQueueTableMigration(tableName: string): string { ' table.bigInteger(\'reserved_at\').nullable()', ' table.string(\'reservation_id\').nullable()', ' table.bigInteger(\'created_at\')', - ` table.index(['queue', 'available_at'], ${JSON.stringify(`${indexPrefix}_queue_available_at_index`)})`, - ` table.index(['queue', 'reserved_at'], ${JSON.stringify(`${indexPrefix}_queue_reserved_at_index`)})`, - ` table.index(['reservation_id'], ${JSON.stringify(`${indexPrefix}_reservation_id_index`)})`, + ` table.index(['queue', 'available_at'], ${renderStringLiteral(`${indexPrefix}_queue_available_at_index`)})`, + ` table.index(['queue', 'reserved_at'], ${renderStringLiteral(`${indexPrefix}_queue_reserved_at_index`)})`, + ` table.index(['reservation_id'], ${renderStringLiteral(`${indexPrefix}_reservation_id_index`)})`, ' })', ' },', - ' async down({ schema }: MigrationContext) {', - ` await schema.dropTable(${tableNameLiteral})`, + ' async down({ schema }) {', + ` await schema.dropTable(${renderStringLiteral(tableName)})`, ' },', '})', '', @@ -65,15 +68,14 @@ export function renderQueueTableMigration(tableName: string): string { } export function renderFailedJobsTableMigration(tableName: string): string { - const tableNameLiteral = JSON.stringify(tableName) const indexPrefix = tableName.replaceAll('.', '_') return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + 'import { defineMigration } from \'@holo-js/db\'', '', 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ` await schema.createTable(${tableNameLiteral}, (table) => {`, + ' async up({ schema }) {', + ` await schema.createTable(${renderStringLiteral(tableName)}, (table) => {`, ' table.string(\'id\').primaryKey()', ' table.string(\'job_id\')', ' table.string(\'job\')', @@ -82,12 +84,12 @@ export function renderFailedJobsTableMigration(tableName: string): string { ' table.text(\'payload\')', ' table.text(\'exception\')', ' table.bigInteger(\'failed_at\')', - ` table.index(['job_id'], ${JSON.stringify(`${indexPrefix}_job_id_index`)})`, - ` table.index(['failed_at'], ${JSON.stringify(`${indexPrefix}_failed_at_index`)})`, + ` table.index(['job_id'], ${renderStringLiteral(`${indexPrefix}_job_id_index`)})`, + ` table.index(['failed_at'], ${renderStringLiteral(`${indexPrefix}_failed_at_index`)})`, ' })', ' },', - ' async down({ schema }: MigrationContext) {', - ` await schema.dropTable(${tableNameLiteral})`, + ' async down({ schema }) {', + ` await schema.dropTable(${renderStringLiteral(tableName)})`, ' },', '})', '', diff --git a/packages/cli/src/queue.ts b/packages/cli/src/queue.ts index a411b2ef..c5b46827 100644 --- a/packages/cli/src/queue.ts +++ b/packages/cli/src/queue.ts @@ -326,7 +326,7 @@ export async function getQueueRuntimeEnvironment(projectRoot: string): Promise { it('escapes configured queue table names and derived index names', () => { - const queueTable = 'jobs\'); throw new Error(\'owned\');//.queue' - const failedJobsTable = 'failed"jobs.queue' + const queueTable = 'jobs\'); throw new Error(\'owned\');//\n.queue' + const failedJobsTable = 'failed"jobs\r.queue' const queueIndexPrefix = queueTable.replaceAll('.', '_') const failedJobsIndexPrefix = failedJobsTable.replaceAll('.', '_') const queueMigration = renderQueueTableMigration(queueTable) diff --git a/packages/core/src/portable/holo.ts b/packages/core/src/portable/holo.ts index 0297a806..031f0dfe 100644 --- a/packages/core/src/portable/holo.ts +++ b/packages/core/src/portable/holo.ts @@ -3543,7 +3543,7 @@ async function registerProjectQueueJobs( throw new Error(`Discovered job "${entry.sourcePath}" does not export a Holo job.`) } - queueModule.registerQueueJob(queueModule.normalizeQueueJobDefinition(job), { + queueModule.registerQueueJob(job, { name: entry.name, sourcePath: entry.sourcePath, replaceExisting: !!existing?.sourcePath, @@ -4406,7 +4406,7 @@ export async function createHolo( runtimeOwnedAuthorizationPolicyNames.splice(0, runtimeOwnedAuthorizationPolicyNames.length, ...authorizationRegistration.policyNames) runtimeOwnedAuthorizationAbilityNames.splice(0, runtimeOwnedAuthorizationAbilityNames.length, ...authorizationRegistration.abilityNames) - if (options.registerProjectQueueJobs === true && registryHasJobs(registry)) { + if (options.registerProjectQueueJobs !== false && registryHasJobs(registry)) { /* v8 ignore start -- exercised only when the optional package is absent outside the monorepo test graph */ if (!activeQueueModule) { throw new Error('[@holo-js/core] Project jobs require @holo-js/queue to be installed.') diff --git a/packages/core/tests/adapter.test.ts b/packages/core/tests/adapter.test.ts index 10baed02..69fb04f1 100644 --- a/packages/core/tests/adapter.test.ts +++ b/packages/core/tests/adapter.test.ts @@ -353,7 +353,7 @@ describe('@holo-js/core adapter helpers', () => { }) }) - it('does not import discovered queue jobs when initializing an adapter project runtime directly by default', async () => { + it('does not import discovered queue jobs when adapter project queue registration is disabled', async () => { const root = await createProjectRoot() await mkdir(join(root, '.holo-js/generated'), { recursive: true }) @@ -383,7 +383,9 @@ describe('@holo-js/core adapter helpers', () => { }, null, 2)}\n`, 'utf8') const loadEsbuildSpy = vi.spyOn(runtimeModuleInternals, 'loadEsbuild') - const project = await createHoloAdapterProject(root) + const project = await createHoloAdapterProject(root, { + registerProjectQueueJobs: false, + }) await expect(project.runtime.initialize()).resolves.toBeUndefined() expect(listRegisteredQueueJobs().map(job => job.name)).toEqual(['holo.events.invoke-listener']) @@ -503,7 +505,7 @@ export default defineAbility('manage-posts', () => true) await expect(Queue.dispatchSync('report', {})).resolves.toBe('ok') }) - it('does not import discovered queue jobs during framework adapter initialization by default', async () => { + it('does not import discovered queue jobs during framework adapter initialization when queue registration is disabled', async () => { const adapter = createHoloFrameworkAdapter({ stateKey: '__holoTestAdapter__', displayName: 'Test', @@ -540,6 +542,7 @@ export default defineAbility('manage-posts', () => true) const project = await adapter.initializeProject({ projectRoot: root, + registerProjectQueueJobs: false, }) expect(project.runtime.initialized).toBe(true) diff --git a/packages/core/tests/runtime.test.ts b/packages/core/tests/runtime.test.ts index 421bfe5f..c6883247 100644 --- a/packages/core/tests/runtime.test.ts +++ b/packages/core/tests/runtime.test.ts @@ -576,7 +576,7 @@ export default {} ).rejects.toThrow(/missing-transitive-dependency/) }) - it('does not import discovered queue jobs during default runtime initialization', async () => { + it('does not import discovered queue jobs when runtime queue registration is disabled', async () => { const root = await createProject() await writeBaseConfig(root) await mkdir(join(root, 'server/jobs/bad'), { recursive: true }) @@ -590,7 +590,9 @@ export default {} }], }) - const runtime = await initializeHolo(root) + const runtime = await initializeHolo(root, { + registerProjectQueueJobs: false, + }) expect(listRegisteredQueueJobs().map(job => job.name)).toEqual(['holo.events.invoke-listener']) diff --git a/packages/events/src/contracts.ts b/packages/events/src/contracts.ts index 27bf4350..ec241d19 100644 --- a/packages/events/src/contracts.ts +++ b/packages/events/src/contracts.ts @@ -3,6 +3,37 @@ export type EventDelayValue = number | Date const HOLO_EVENT_DEFINITION_MARKER = Symbol.for('holo-js.events.definition') const HOLO_LISTENER_DEFINITION_MARKER = Symbol.for('holo-js.events.listener') +type EventDefinitionDispatcher = ( + event: EventDefinition, + payload: TPayload, +) => EventPendingDispatch + +function getEventDefinitionDispatcher(): EventDefinitionDispatcher | undefined { + return (globalThis as typeof globalThis & { + __holoEventDefinitionDispatcher__?: EventDefinitionDispatcher + }).__holoEventDefinitionDispatcher__ +} + +function setEventDefinitionDispatcher(dispatcher: EventDefinitionDispatcher): void { + const runtime = globalThis as typeof globalThis & { + __holoEventDefinitionDispatcher__?: EventDefinitionDispatcher + } + + runtime.__holoEventDefinitionDispatcher__ = dispatcher +} + +function dispatchDefinedEvent( + event: EventDefinition, + payload: TPayload, +): EventPendingDispatch { + const dispatcher = getEventDefinitionDispatcher() + if (!dispatcher) { + throw new Error('[Holo Events] Event definitions cannot dispatch before the events runtime is loaded.') + } + + return dispatcher(event, payload) +} + function normalizeOptionalString( value: unknown, label: string, @@ -113,6 +144,13 @@ export interface EventDefinition< readonly __payloadType?: TPayload } +export interface DefinedEventDefinition< + TPayload = unknown, + TName extends string | undefined = string | undefined, +> extends EventDefinition { + dispatch(payload: TPayload): EventPendingDispatch +} + export interface RegisteredEvent< TPayload = unknown, TName extends string = string, @@ -300,12 +338,18 @@ export function normalizeEventDefinition(event: export function defineEvent( event: EventDefinition, -): EventDefinition { - const normalized = { ...normalizeEventDefinition(event) } +): DefinedEventDefinition { + const normalized = { ...normalizeEventDefinition(event) } as DefinedEventDefinition Object.defineProperty(normalized, HOLO_EVENT_DEFINITION_MARKER, { value: true, enumerable: false, }) + Object.defineProperty(normalized, 'dispatch', { + value(payload: TPayload): EventPendingDispatch { + return dispatchDefinedEvent(normalized, payload) + }, + enumerable: false, + }) return Object.freeze(normalized) } @@ -406,6 +450,7 @@ export const eventInternals = { normalizeOptionalBoolean, normalizeOptionalDelay, normalizeOptionalString, + setEventDefinitionDispatcher, normalizeListensTo, normalizeListenerDefinition, toPosixPath, diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index b36fb4a3..e500a573 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -20,6 +20,7 @@ export type { EventQueuedListenerDispatch, EventRuntimeBinding, EventRuntimeHooks, + DefinedEventDefinition, EventReference, EventReferenceInput, ExportedEventDefinition, diff --git a/packages/events/src/runtime.ts b/packages/events/src/runtime.ts index 61133047..57cbc99e 100644 --- a/packages/events/src/runtime.ts +++ b/packages/events/src/runtime.ts @@ -495,6 +495,8 @@ export function dispatchEvent( return new PendingEventDispatch(event, payload) } +eventInternals.setEventDefinitionDispatcher(dispatchEvent) + export const Event: EventFacade = { dispatch: dispatchEvent, } diff --git a/packages/events/tests/contracts.test.ts b/packages/events/tests/contracts.test.ts index 93e461f8..3022d8f2 100644 --- a/packages/events/tests/contracts.test.ts +++ b/packages/events/tests/contracts.test.ts @@ -21,8 +21,12 @@ describe('@holo-js/events contracts', () => { expect(event).toEqual({ name: 'user.registered', }) + expect(typeof event.dispatch).toBe('function') + expect(Object.keys(event)).toEqual(['name']) expect(Object.isFrozen(event)).toBe(true) expect(unnamed).toEqual({}) + expect(typeof unnamed.dispatch).toBe('function') + expect(Object.keys(unnamed)).toEqual([]) expect(Object.isFrozen(unnamed)).toBe(true) }) @@ -43,6 +47,26 @@ describe('@holo-js/events contracts', () => { expect(eventInternals.hasListenerDefinitionMarker({ listensTo: ['audit.entry'], async handle() {} })).toBe(false) }) + it('rejects event definition dispatch before the runtime dispatcher is loaded', () => { + const runtime = globalThis as typeof globalThis & { + __holoEventDefinitionDispatcher__?: unknown + } + const existingDispatcher = runtime.__holoEventDefinitionDispatcher__ + const event = defineEvent({ + name: 'audit.entry', + }) + + try { + delete runtime.__holoEventDefinitionDispatcher__ + + expect(() => event.dispatch({})).toThrow( + 'Event definitions cannot dispatch before the events runtime is loaded.', + ) + } finally { + runtime.__holoEventDefinitionDispatcher__ = existingDispatcher + } + }) + it('rejects invalid event definitions and derived naming inputs', () => { expect(() => defineEvent({ name: ' ', diff --git a/packages/events/tests/contracts.type.test.ts b/packages/events/tests/contracts.type.test.ts index c3849e62..bdb3c0a6 100644 --- a/packages/events/tests/contracts.type.test.ts +++ b/packages/events/tests/contracts.type.test.ts @@ -56,6 +56,14 @@ describe('@holo-js/events typing', () => { type ListenerContract = ListenerDefinition const listenerContract: ListenerContract = multiListener + const eventMethodPending = userRegistered.dispatch({ + userId: 'usr-1', + email: 'ava@example.com', + }) + // @ts-expect-error wrong payload shape for userRegistered.dispatch + userRegistered.dispatch({ + userId: 'usr-1', + }) const explicitPending = dispatchEvent(userRegistered, { userId: 'usr-1', email: 'ava@example.com', @@ -100,6 +108,7 @@ describe('@holo-js/events typing', () => { } void listenerContract + void eventMethodPending void explicitPending void namedPending void facadePending diff --git a/packages/events/tests/runtime.test.ts b/packages/events/tests/runtime.test.ts index 811ffef5..5ab40044 100644 --- a/packages/events/tests/runtime.test.ts +++ b/packages/events/tests/runtime.test.ts @@ -47,6 +47,24 @@ describe('@holo-js/events runtime', () => { expect(helperResult.occurredAt).toBeGreaterThan(0) }) + it('dispatches through event definitions', async () => { + const userRegistered = defineEvent<{ userId: string }, 'user.registered'>({ + name: 'user.registered', + }) + registerEvent(userRegistered) + + const result = await userRegistered.dispatch({ + userId: 'usr-1', + }) + + expect(result).toMatchObject({ + eventName: 'user.registered', + deferred: false, + syncListeners: 0, + queuedListeners: 0, + }) + }) + it('executes synchronous listeners inline in registration order', async () => { registerEvent(defineEvent<{ userId: string }, 'user.registered'>({ name: 'user.registered', diff --git a/packages/queue/src/contracts.ts b/packages/queue/src/contracts.ts index 20cfe4e8..cc93346d 100644 --- a/packages/queue/src/contracts.ts +++ b/packages/queue/src/contracts.ts @@ -1,6 +1,206 @@ type QueueJsonPrimitive = string | number | boolean | null export type QueueJsonValue = QueueJsonPrimitive | readonly QueueJsonValue[] | { readonly [key: string]: QueueJsonValue } +type QueueJobDispatcher = ( + jobName: string, + payload: TPayload, +) => QueuePendingDispatch + +type QueueJobSyncDispatcher = ( + jobName: string, + payload: TPayload, +) => Promise + +function getQueueJobDispatcher(): QueueJobDispatcher | undefined { + return (globalThis as typeof globalThis & { + __holoQueueJobDispatcher__?: QueueJobDispatcher + }).__holoQueueJobDispatcher__ +} + +function setQueueJobDispatcher(dispatcher: QueueJobDispatcher): void { + const runtime = globalThis as typeof globalThis & { + __holoQueueJobDispatcher__?: QueueJobDispatcher + } + + runtime.__holoQueueJobDispatcher__ = dispatcher +} + +function getQueueJobSyncDispatcher(): QueueJobSyncDispatcher | undefined { + return (globalThis as typeof globalThis & { + __holoQueueJobSyncDispatcher__?: QueueJobSyncDispatcher + }).__holoQueueJobSyncDispatcher__ +} + +function setQueueJobSyncDispatcher(dispatcher: QueueJobSyncDispatcher): void { + const runtime = globalThis as typeof globalThis & { + __holoQueueJobSyncDispatcher__?: QueueJobSyncDispatcher + } + + runtime.__holoQueueJobSyncDispatcher__ = dispatcher +} + +function getQueueJobDefinitionNames(): WeakMap { + const runtime = globalThis as typeof globalThis & { + __holoQueueJobDefinitionNames__?: WeakMap + } + + runtime.__holoQueueJobDefinitionNames__ ??= new WeakMap() + return runtime.__holoQueueJobDefinitionNames__ +} + +function getQueueJobDefinitionFingerprints(): Map> { + const runtime = globalThis as typeof globalThis & { + __holoQueueJobDefinitionFingerprints__?: Map> + } + + runtime.__holoQueueJobDefinitionFingerprints__ ??= new Map>() + return runtime.__holoQueueJobDefinitionFingerprints__ +} + +function getQueueJobDefinitionOptionFingerprints(): Map> { + const runtime = globalThis as typeof globalThis & { + __holoQueueJobDefinitionOptionFingerprints__?: Map> + } + + runtime.__holoQueueJobDefinitionOptionFingerprints__ ??= new Map>() + return runtime.__holoQueueJobDefinitionOptionFingerprints__ +} + +function createQueueJobDefinitionOptionsFingerprint(definition: QueueJobDefinition): string { + return JSON.stringify({ + connection: definition.connection, + queue: definition.queue, + tries: definition.tries, + backoff: definition.backoff, + timeout: definition.timeout, + }) +} + +function getQueueJobDefinitionFingerprint(definition: object): string | undefined { + if (!isQueueJobDefinition(definition)) { + return undefined + } + + return JSON.stringify({ + options: createQueueJobDefinitionOptionsFingerprint(definition), + handle: definition.handle.toString(), + }) +} + +function addQueueJobDefinitionNameByFingerprint( + fingerprints: Map>, + fingerprint: string, + name: string, +): void { + const names = fingerprints.get(fingerprint) ?? new Set() + names.add(name) + fingerprints.set(fingerprint, names) +} + +function setQueueJobDefinitionName(definition: object, name: string): void { + getQueueJobDefinitionNames().set(definition, name) + + if (!isQueueJobDefinition(definition)) { + return + } + + addQueueJobDefinitionNameByFingerprint( + getQueueJobDefinitionFingerprints(), + getQueueJobDefinitionFingerprint(definition)!, + name, + ) + addQueueJobDefinitionNameByFingerprint( + getQueueJobDefinitionOptionFingerprints(), + createQueueJobDefinitionOptionsFingerprint(definition), + name, + ) +} + +function deleteQueueJobDefinitionName(name: string): void { + for (const fingerprints of [ + getQueueJobDefinitionFingerprints(), + getQueueJobDefinitionOptionFingerprints(), + ]) { + for (const [fingerprint, names] of fingerprints) { + names.delete(name) + if (names.size === 0) { + fingerprints.delete(fingerprint) + } + } + } +} + +function clearQueueJobDefinitionNames(): void { + getQueueJobDefinitionFingerprints().clear() + getQueueJobDefinitionOptionFingerprints().clear() +} + +function resolveSingleQueueJobDefinitionFingerprintName( + names: Set | undefined, +): string | undefined { + if (!names) { + return undefined + } + + if (names.size === 1) { + return [...names][0]! + } + + throw new Error('[Holo Queue] Job definition dispatch is ambiguous because multiple registered jobs match the same definition.') +} + +function resolveQueueJobDefinitionName(definition: object): string { + const name = getQueueJobDefinitionNames().get(definition) + if (name) { + return name + } + + const fingerprint = getQueueJobDefinitionFingerprint(definition) + const fingerprintName = fingerprint + ? resolveSingleQueueJobDefinitionFingerprintName(getQueueJobDefinitionFingerprints().get(fingerprint)) + : undefined + + if (fingerprintName) { + return fingerprintName + } + + if (isQueueJobDefinition(definition)) { + const optionFingerprintName = resolveSingleQueueJobDefinitionFingerprintName( + getQueueJobDefinitionOptionFingerprints().get(createQueueJobDefinitionOptionsFingerprint(definition)), + ) + + if (optionFingerprintName) { + return optionFingerprintName + } + } + + throw new Error('[Holo Queue] Job definitions cannot dispatch before the job is registered.') +} + +function dispatchDefinedQueueJob( + definition: object, + payload: TPayload, +): QueuePendingDispatch { + const dispatcher = getQueueJobDispatcher() + if (!dispatcher) { + throw new Error('[Holo Queue] Job definitions cannot dispatch before the queue runtime is loaded.') + } + + return dispatcher(resolveQueueJobDefinitionName(definition), payload) +} + +function dispatchDefinedQueueJobSync( + definition: object, + payload: TPayload, +): Promise { + const dispatcher = getQueueJobSyncDispatcher() + if (!dispatcher) { + throw new Error('[Holo Queue] Job definitions cannot dispatch before the queue runtime is loaded.') + } + + return dispatcher(resolveQueueJobDefinitionName(definition), payload) +} + function normalizeOptionalString( value: string | undefined, label: string, @@ -123,6 +323,11 @@ export interface QueueJobDefinition | TResult } +export interface DefinedQueueJobDefinition extends QueueJobDefinition { + dispatch(payload: TPayload): QueuePendingDispatch + dispatchSync(payload: TPayload): Promise +} + export interface QueueJobEnvelope { readonly id: string readonly name: string @@ -393,7 +598,11 @@ export function isQueueJobDefinition(value: unknown): value is QueueJobDefinitio && typeof (value as QueueJobDefinition).handle === 'function' } -export function normalizeQueueJobDefinition(job: TJob): TJob { +export function normalizeQueueJobDefinition< + TPayload extends QueueJsonValue, + TResult, + TJob extends QueueJobDefinition, +>(job: TJob): TJob { if (!isQueueJobDefinition(job)) { throw new Error('[Holo Queue] Jobs must define a "handle" function.') } @@ -410,8 +619,23 @@ export function normalizeQueueJobDefinition(job } as TJob } -export function defineJob(job: TJob): TJob { - return Object.freeze(normalizeQueueJobDefinition(job)) +export function defineJob( + job: QueueJobDefinition, +): DefinedQueueJobDefinition { + const normalized = normalizeQueueJobDefinition>(job) as DefinedQueueJobDefinition + Object.defineProperty(normalized, 'dispatch', { + value(payload: TPayload): QueuePendingDispatch { + return dispatchDefinedQueueJob(normalized, payload) + }, + enumerable: false, + }) + Object.defineProperty(normalized, 'dispatchSync', { + value(payload: TPayload): Promise { + return dispatchDefinedQueueJobSync(normalized, payload) + }, + enumerable: false, + }) + return Object.freeze(normalized) } export interface QueueFailedStoreConfig { @@ -542,9 +766,17 @@ export interface NormalizedHoloQueueConfig { } export const queueJobInternals = { + clearQueueJobDefinitionNames, + deleteQueueJobDefinitionName, + dispatchDefinedQueueJob, + dispatchDefinedQueueJobSync, normalizeQueueJobDefinition, normalizeBackoff, normalizeOptionalHook, normalizeOptionalInteger, normalizeOptionalString, + resolveQueueJobDefinitionName, + setQueueJobDefinitionName, + setQueueJobDispatcher, + setQueueJobSyncDispatcher, } diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 41b846b7..d8a373ed 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -10,6 +10,7 @@ export function defineQueueConfig(config: TConf export { defineJob, isQueueJobDefinition, normalizeQueueJobDefinition, queueJobInternals } from './contracts' export type { + DefinedQueueJobDefinition, ExportedQueueJobDefinition, QueueAsyncDriver, NormalizedQueueConnectionConfig, diff --git a/packages/queue/src/registry.ts b/packages/queue/src/registry.ts index 7a509edc..c3236aa4 100644 --- a/packages/queue/src/registry.ts +++ b/packages/queue/src/registry.ts @@ -7,6 +7,7 @@ import type { import { isQueueJobDefinition, normalizeQueueJobDefinition, + queueJobInternals, } from './contracts' function toPosixPath(value: string): string { @@ -83,6 +84,8 @@ export function registerQueueJob( }), }) as QueueRegisteredJob + queueJobInternals.setQueueJobDefinitionName(definition, name) + queueJobInternals.setQueueJobDefinitionName(normalizedDefinition, name) registry.set(name, entry as unknown as QueueRegisteredJob) return entry } @@ -105,11 +108,17 @@ export function listRegisteredQueueJobs(): readonly QueueRegisteredJob[] { } export function unregisterQueueJob(name: string): boolean { - return getQueueRegistryState().jobs.delete(name) + const deleted = getQueueRegistryState().jobs.delete(name) + if (deleted) { + queueJobInternals.deleteQueueJobDefinitionName(name) + } + + return deleted } export function resetQueueRegistry(): void { getQueueRegistryState().jobs.clear() + queueJobInternals.clearQueueJobDefinitionNames() } export const queueRegistryInternals = { diff --git a/packages/queue/src/runtime.ts b/packages/queue/src/runtime.ts index cbd608c1..26e0f087 100644 --- a/packages/queue/src/runtime.ts +++ b/packages/queue/src/runtime.ts @@ -24,6 +24,7 @@ import type { HoloQueueJobRegistry, HoloQueueConfig, } from './contracts' +import { queueJobInternals } from './contracts' import { DEFAULT_QUEUE_NAME } from './config' import { normalizeQueueConfig, holoQueueDefaults } from './config' import { redisQueueDriverFactory } from './drivers/redis' @@ -743,6 +744,9 @@ export function dispatch( return new PendingQueueDispatch(jobName, payload, options) } +queueJobInternals.setQueueJobDispatcher(dispatch) +queueJobInternals.setQueueJobSyncDispatcher(dispatchSync) + export const Queue = { connection(name?: string): QueueConnectionFacade { return createQueueConnection(name) diff --git a/packages/queue/tests/config.type.test.ts b/packages/queue/tests/config.type.test.ts index 49407b6f..054a17a0 100644 --- a/packages/queue/tests/config.type.test.ts +++ b/packages/queue/tests/config.type.test.ts @@ -30,14 +30,32 @@ declare module '../src/contracts' { describe('@holo-js/queue typing', () => { it('preserves typing for job definitions, envelopes, dispatch calls, and normalized config', () => { - const job = defineJob({ + const job = defineJob<{ reportId: string }>({ queue: 'reports', - async handle(payload: { reportId: string }) { + async handle(payload) { return payload.reportId }, }) - const typedJob: QueueJobDefinition<{ reportId: string }, string> = job + const typedJob: QueueJobDefinition<{ reportId: string }, unknown> = job + const expectDefinedJobPayloadTypes = () => { + const jobDispatch: QueuePendingDispatch<{ reportId: string }> = job.dispatch({ + reportId: 'rep-1', + }) + const jobDispatchSync: Promise = job.dispatchSync({ + reportId: 'rep-1', + }) + job.dispatch({ + // @ts-expect-error defined job dispatch uses the handle payload type + wrong: 123, + }) + job.dispatchSync({ + // @ts-expect-error defined job dispatchSync uses the handle payload type + wrong: 123, + }) + void jobDispatch + void jobDispatchSync + } const normalized: NormalizedHoloQueueConfig = normalizeQueueConfig() const sharedRedisConfig: QueueSharedRedisConfig = { default: 'cache', @@ -112,8 +130,8 @@ describe('@holo-js/queue typing', () => { }) type SyncDispatchResult = Awaited>> const syncResult: SyncDispatchResult = { ok: true } - const exportedJob = defineJob({ - async handle(payload: { reportId: string }) { + const exportedJob = defineJob<{ reportId: string }>({ + async handle(payload) { return { ok: payload.reportId.length > 0, } @@ -125,15 +143,14 @@ describe('@holo-js/queue typing', () => { const selectedPayload: SelectedPayload = { reportId: 'rep-1', } - const selectedResult: SelectedResult = { - ok: true, - } + const selectedResult: SelectedResult = undefined void typedJob void normalized void normalizedWithSharedRedis void envelope void driver + void expectDefinedJobPayloadTypes void typedPending void expectRegisteredQueuePayloadTypes void dynamicPending diff --git a/packages/queue/tests/job.test.ts b/packages/queue/tests/job.test.ts index 67225ce6..c22ecc74 100644 --- a/packages/queue/tests/job.test.ts +++ b/packages/queue/tests/job.test.ts @@ -21,6 +21,9 @@ describe('@holo-js/queue jobs', () => { backoff: [5, 30, 120], timeout: 60, }) + expect(typeof job.dispatch).toBe('function') + expect(typeof job.dispatchSync).toBe('function') + expect(Object.keys(job)).toEqual(['connection', 'queue', 'tries', 'backoff', 'timeout', 'handle']) expect(Object.isFrozen(job)).toBe(true) expect(Object.isFrozen(job.backoff)).toBe(true) }) @@ -46,6 +49,54 @@ describe('@holo-js/queue jobs', () => { expect(isQueueJobDefinition({})).toBe(false) }) + it('rejects job definition dispatch before registration', () => { + const job = defineJob({ + async handle(payload: { readonly userId: string }) { + return payload.userId + }, + }) + + expect(() => job.dispatch({ + userId: 'usr-1', + })).toThrow('Job definitions cannot dispatch before the job is registered.') + + expect(() => job.dispatchSync({ + userId: 'usr-1', + })).toThrow('Job definitions cannot dispatch before the job is registered.') + }) + + it('rejects job definition dispatch before runtime dispatchers are loaded', () => { + const runtime = globalThis as typeof globalThis & { + __holoQueueJobDispatcher__?: unknown + __holoQueueJobSyncDispatcher__?: unknown + } + const existingDispatcher = runtime.__holoQueueJobDispatcher__ + const existingSyncDispatcher = runtime.__holoQueueJobSyncDispatcher__ + const job = defineJob({ + async handle(payload: { readonly userId: string }) { + return payload.userId + }, + }) + + queueJobInternals.setQueueJobDefinitionName(job, 'users.digest') + + try { + delete runtime.__holoQueueJobDispatcher__ + delete runtime.__holoQueueJobSyncDispatcher__ + + expect(() => job.dispatch({ + userId: 'usr-1', + })).toThrow('Job definitions cannot dispatch before the queue runtime is loaded.') + + expect(() => job.dispatchSync({ + userId: 'usr-1', + })).toThrow('Job definitions cannot dispatch before the queue runtime is loaded.') + } finally { + runtime.__holoQueueJobDispatcher__ = existingDispatcher + runtime.__holoQueueJobSyncDispatcher__ = existingSyncDispatcher + } + }) + it('rejects invalid job metadata and malformed definitions', () => { expect(() => defineJob({ tries: 0, diff --git a/packages/queue/tests/runtime.test.ts b/packages/queue/tests/runtime.test.ts index 92bb3fac..c02657cc 100644 --- a/packages/queue/tests/runtime.test.ts +++ b/packages/queue/tests/runtime.test.ts @@ -4,6 +4,7 @@ import { Queue, QueueReleaseUnsupportedError, configureQueueRuntime, + defineJob, dispatch, dispatchSync, getQueueRuntime, @@ -279,6 +280,36 @@ describe('@holo-js/queue runtime', () => { expect(handled).toHaveBeenCalledTimes(2) }) + it('dispatches through registered job definitions', async () => { + const job = defineJob({ + tries: 2, + async handle(payload: { readonly userId: string }, context) { + return { + userId: payload.userId, + attempt: context.attempt, + } + }, + }) + registerNamedJob('users.digest', job) + + const asyncResult = await job.dispatch({ + userId: 'usr-1', + }) + const syncResult = await job.dispatchSync({ + userId: 'usr-2', + }) + + expect(asyncResult).toMatchObject({ + connection: 'sync', + queue: 'default', + synchronous: true, + }) + expect(syncResult).toEqual({ + userId: 'usr-2', + attempt: 1, + }) + }) + it('supports PromiseLike catch(), finally(), onComplete(), and onFailed() on pending dispatches', async () => { const onFinally = vi.fn() const onComplete = vi.fn()