From 78c94577b90def9a836bdb695f4bb6a887824893 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:04:40 +0300 Subject: [PATCH 1/4] update events and jobs syntax --- .../.vitepress/theme/components/Landing.vue | 824 ++++++++++++++++++ apps/docs/docs/architecture.md | 7 +- apps/docs/docs/auth/personal-access-tokens.md | 5 +- .../docs/broadcast/deployment-and-scaling.md | 2 + apps/docs/docs/database/commands.md | 11 +- apps/docs/docs/deployment.md | 54 +- apps/docs/docs/directory-structure.md | 17 +- apps/docs/docs/events/api-reference.md | 26 +- apps/docs/docs/events/defining-events.md | 3 + apps/docs/docs/events/dispatching-events.md | 20 +- apps/docs/docs/events/index.md | 6 +- apps/docs/docs/events/queued-listeners.md | 2 + apps/docs/docs/events/setup-and-cli.md | 3 +- .../docs/events/transactions-after-commit.md | 3 +- apps/docs/docs/index.md | 165 +--- apps/docs/docs/mail/queueing-mail.md | 4 +- apps/docs/docs/media.md | 2 + .../notifications/queueing-notifications.md | 2 + apps/docs/docs/queue/index.md | 29 +- apps/docs/docs/queue/jobs.md | 29 +- apps/docs/docs/queue/workers.md | 3 + apps/docs/docs/realtime/index.md | 1 + apps/docs/docs/runtime-services.md | 3 + packages/events/src/contracts.ts | 49 +- packages/events/src/index.ts | 1 + packages/events/src/runtime.ts | 2 + packages/events/tests/contracts.test.ts | 24 + packages/events/tests/contracts.type.test.ts | 9 + packages/events/tests/runtime.test.ts | 18 + packages/queue/src/contracts.ts | 120 ++- packages/queue/src/index.ts | 1 + packages/queue/src/registry.ts | 3 + packages/queue/src/runtime.ts | 4 + packages/queue/tests/config.type.test.ts | 19 + packages/queue/tests/job.test.ts | 51 ++ packages/queue/tests/runtime.test.ts | 31 + 36 files changed, 1336 insertions(+), 217 deletions(-) create mode 100644 apps/docs/docs/.vitepress/theme/components/Landing.vue 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..6ece11ba --- /dev/null +++ b/apps/docs/docs/.vitepress/theme/components/Landing.vue @@ -0,0 +1,824 @@ + + + + + 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/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..f0edd636 100644 --- a/apps/docs/docs/queue/jobs.md +++ b/apps/docs/docs/queue/jobs.md @@ -21,9 +21,10 @@ 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({ queue: 'emails', connection: 'redis', tries: 3, @@ -44,6 +45,8 @@ export default defineJob({ void context }, }) + +export default SendDigest ``` `defineJob(...)` supports: @@ -68,18 +71,21 @@ 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 `handle(payload, context)`. + ## 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 +98,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 +117,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: @@ -184,7 +200,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/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..5dae181b 100644 --- a/packages/queue/src/contracts.ts +++ b/packages/queue/src/contracts.ts @@ -1,6 +1,90 @@ 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 setQueueJobDefinitionName(definition: object, name: string): void { + getQueueJobDefinitionNames().set(definition, name) +} + +function resolveQueueJobDefinitionName(definition: object): string { + const name = getQueueJobDefinitionNames().get(definition) + if (!name) { + throw new Error('[Holo Queue] Job definitions cannot dispatch before the job is registered.') + } + + return name +} + +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 +207,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 +482,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 +503,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 +650,15 @@ export interface NormalizedHoloQueueConfig { } export const queueJobInternals = { + 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..3568b5e3 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 } 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..9ceeea3d 100644 --- a/packages/queue/tests/config.type.test.ts +++ b/packages/queue/tests/config.type.test.ts @@ -38,6 +38,24 @@ describe('@holo-js/queue typing', () => { }) const typedJob: QueueJobDefinition<{ reportId: string }, string> = 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', @@ -134,6 +152,7 @@ describe('@holo-js/queue typing', () => { 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() From 44427a22956b502a71cb2971dbe9ec4b558ded03 Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:33:04 +0300 Subject: [PATCH 2/4] Dispatch blog post saved events and index jobs --- apps/blog-next/package.json | 1 + .../2026_04_25_044031_create_admins.ts | 6 +- .../2026_04_25_044032_create_notifications.ts | 6 +- .../2026_04_25_044032_create_users.ts | 6 +- .../2026_04_25_044033_create_sessions.ts | 6 +- ...026_04_25_044034_create_auth_identities.ts | 6 +- ...25_044035_create_personal_access_tokens.ts | 6 +- ..._25_044036_create_password_reset_tokens.ts | 6 +- ...044037_create_email_verification_tokens.ts | 6 +- ...2026_05_02_115334_create_comments_table.ts | 6 +- ...6_05_25_063242_create_cache_cache_table.ts | 6 +- ...6_06_19_202222_create_failed_jobs_table.ts | 21 +++ .../2026_06_19_202222_create_jobs_table.ts | 25 +++ .../server/events/blog/post-saved.ts | 13 ++ apps/blog-next/server/jobs/blog/index-post.ts | 22 +++ apps/blog-next/server/lib/blog.ts | 35 ++++ .../server/listeners/blog/index-saved-post.ts | 14 ++ apps/blog-next/tests/blog-logic.mjs | 9 + apps/blog-nuxt/package.json | 1 + .../2026_04_25_044031_create_admins.ts | 6 +- .../2026_04_25_044032_create_notifications.ts | 6 +- .../2026_04_25_044032_create_users.ts | 6 +- .../2026_04_25_044033_create_sessions.ts | 6 +- ...026_04_25_044034_create_auth_identities.ts | 6 +- ...25_044035_create_personal_access_tokens.ts | 6 +- ..._25_044036_create_password_reset_tokens.ts | 6 +- ...044037_create_email_verification_tokens.ts | 6 +- ...2026_05_02_115438_create_comments_table.ts | 6 +- ...6_05_25_063246_create_cache_cache_table.ts | 6 +- .../2026_06_19_202226_create_jobs_table.ts | 25 +++ ...6_06_19_202227_create_failed_jobs_table.ts | 21 +++ .../server/events/blog/post-saved.ts | 13 ++ apps/blog-nuxt/server/jobs/blog/index-post.ts | 22 +++ apps/blog-nuxt/server/lib/blog.ts | 35 ++++ .../server/listeners/blog/index-saved-post.ts | 14 ++ apps/blog-nuxt/tests/blog-logic.mjs | 9 + apps/blog-sveltekit/package.json | 1 + .../2026_04_25_044031_create_admins.ts | 6 +- .../2026_04_25_044032_create_notifications.ts | 6 +- .../2026_04_25_044032_create_users.ts | 6 +- .../2026_04_25_044033_create_sessions.ts | 6 +- ...026_04_25_044034_create_auth_identities.ts | 6 +- ...25_044035_create_personal_access_tokens.ts | 6 +- ..._25_044036_create_password_reset_tokens.ts | 6 +- ...044037_create_email_verification_tokens.ts | 6 +- ...2026_05_02_115430_create_comments_table.ts | 6 +- ...6_05_25_063252_create_cache_cache_table.ts | 6 +- .../2026_06_19_202238_create_jobs_table.ts | 25 +++ ...6_06_19_202239_create_failed_jobs_table.ts | 21 +++ .../server/events/blog/post-saved.ts | 13 ++ .../server/jobs/blog/index-post.ts | 22 +++ .../server/listeners/blog/index-saved-post.ts | 14 ++ .../src/routes/admin/posts/+page.server.ts | 13 ++ .../admin/posts/[id]/edit/+page.server.ts | 13 ++ .../routes/admin/posts/new/+page.server.ts | 13 ++ apps/blog-sveltekit/tests/blog-logic.mjs | 9 + .../.vitepress/theme/components/Landing.vue | 26 ++- apps/docs/docs/public/mark.svg | 2 +- apps/docs/docs/queue/jobs.md | 15 +- bun.lock | 178 +++++++++--------- packages/cli/src/cache-migrations.ts | 6 +- packages/cli/src/media-migrations.ts | 6 +- .../src/project/scaffold/project-renderers.ts | 42 ++--- packages/cli/src/queue-migrations.ts | 36 ++-- packages/cli/src/queue.ts | 2 +- packages/cli/tests/queue-migrations.test.ts | 12 +- packages/core/src/portable/holo.ts | 4 +- packages/core/tests/adapter.test.ts | 9 +- packages/core/tests/runtime.test.ts | 6 +- packages/queue/src/contracts.ts | 126 ++++++++++++- packages/queue/src/registry.ts | 8 +- packages/queue/tests/config.type.test.ts | 16 +- 72 files changed, 837 insertions(+), 261 deletions(-) create mode 100644 apps/blog-next/server/db/migrations/2026_06_19_202222_create_failed_jobs_table.ts create mode 100644 apps/blog-next/server/db/migrations/2026_06_19_202222_create_jobs_table.ts create mode 100644 apps/blog-next/server/events/blog/post-saved.ts create mode 100644 apps/blog-next/server/jobs/blog/index-post.ts create mode 100644 apps/blog-next/server/listeners/blog/index-saved-post.ts create mode 100644 apps/blog-nuxt/server/db/migrations/2026_06_19_202226_create_jobs_table.ts create mode 100644 apps/blog-nuxt/server/db/migrations/2026_06_19_202227_create_failed_jobs_table.ts create mode 100644 apps/blog-nuxt/server/events/blog/post-saved.ts create mode 100644 apps/blog-nuxt/server/jobs/blog/index-post.ts create mode 100644 apps/blog-nuxt/server/listeners/blog/index-saved-post.ts create mode 100644 apps/blog-sveltekit/server/db/migrations/2026_06_19_202238_create_jobs_table.ts create mode 100644 apps/blog-sveltekit/server/db/migrations/2026_06_19_202239_create_failed_jobs_table.ts create mode 100644 apps/blog-sveltekit/server/events/blog/post-saved.ts create mode 100644 apps/blog-sveltekit/server/jobs/blog/index-post.ts create mode 100644 apps/blog-sveltekit/server/listeners/blog/index-saved-post.ts 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..fd708758 100644 --- a/apps/blog-next/server/lib/blog.ts +++ b/apps/blog-next/server/lib/blog.ts @@ -4,6 +4,8 @@ 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 IndexBlogPost from '../jobs/blog/index-post' import Category from '../models/Category' import Post from '../models/Post' import Tag from '../models/Tag' @@ -253,6 +255,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'created', + postId: post.id, + }).onQueue('default') return post } @@ -289,6 +302,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'updated', + postId: post.id, + }).onQueue('default') return post } @@ -298,5 +322,16 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'deleted', + postId: post.id, + }).onQueue('default') } } 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..023874c9 --- /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.dispatchSync({ + 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..144dc033 100644 --- a/apps/blog-nuxt/server/lib/blog.ts +++ b/apps/blog-nuxt/server/lib/blog.ts @@ -4,6 +4,8 @@ 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 IndexBlogPost from '../jobs/blog/index-post' import Category from '../models/Category' import Post from '../models/Post' import Tag from '../models/Tag' @@ -231,6 +233,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'created', + postId: post.id, + }).onQueue('default') return post } @@ -267,6 +280,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'updated', + postId: post.id, + }).onQueue('default') return post } @@ -276,5 +300,16 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'deleted', + postId: post.id, + }).onQueue('default') } } 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..023874c9 --- /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.dispatchSync({ + 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..023874c9 --- /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.dispatchSync({ + 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..3ceb97a9 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,8 @@ 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 IndexBlogPost from '../../../../server/jobs/blog/index-post' import Post from '../../../../server/models/Post' import type { Actions, PageServerLoad } from './$types' @@ -25,6 +27,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'deleted', + postId: post.id, + }).onQueue('default') 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..8fdd81f2 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,8 @@ 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 IndexBlogPost from '../../../../../../server/jobs/blog/index-post' import Post from '../../../../../../server/models/Post' import type { Actions, PageServerLoad } from './$types' @@ -63,6 +65,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'updated', + postId: post.id, + }).onQueue('default') 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..7221bd87 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,8 @@ 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 IndexBlogPost from '../../../../../server/jobs/blog/index-post' import Post from '../../../../../server/models/Post' import type { Actions, PageServerLoad } from './$types' @@ -58,6 +60,17 @@ 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, + }) + await IndexBlogPost.dispatch({ + action: 'created', + postId: post.id, + }).onQueue('default') 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 index 6ece11ba..33a31a56 100644 --- a/apps/docs/docs/.vitepress/theme/components/Landing.vue +++ b/apps/docs/docs/.vitepress/theme/components/Landing.vue @@ -10,12 +10,20 @@ const scrollToLearnMore = () => { const palette = [ { name: 'Database', pkg: '@holo-js/db', picked: true }, { name: 'Auth', pkg: '@holo-js/auth', picked: false }, + { name: 'Session', pkg: '@holo-js/session', picked: false }, + { name: 'Authorization', pkg: '@holo-js/authorization', picked: false }, + { name: 'Security', pkg: '@holo-js/security', picked: false }, { name: 'Queue', pkg: '@holo-js/queue', picked: true }, { name: 'Cache', pkg: '@holo-js/cache', picked: false }, { name: 'Storage', pkg: '@holo-js/storage', picked: true }, { name: 'Events', pkg: '@holo-js/events', picked: false }, { name: 'Broadcast', pkg: '@holo-js/broadcast', picked: false }, + { name: 'Realtime', pkg: '@holo-js/realtime', picked: false }, { name: 'Forms', pkg: '@holo-js/forms', picked: false }, + { name: 'Validation', pkg: '@holo-js/validation', picked: false }, + { name: 'Mail', pkg: '@holo-js/mail', picked: false }, + { name: 'Notifications', pkg: '@holo-js/notifications', picked: false }, + { name: 'Media', pkg: '@holo-js/media', picked: false }, ] const stack = [ @@ -54,12 +62,20 @@ const gaps = [ const modules = [ { name: 'Database', pkg: '@holo-js/db', summary: 'Typed query builder, models, migrations, and seeders.' }, { name: 'Auth', pkg: '@holo-js/auth', summary: 'Sessions, providers, tokens, and social login.' }, + { name: 'Session', pkg: '@holo-js/session', summary: 'Cookie sessions, stores, guards, and framework-safe session access.' }, + { name: 'Authorization', pkg: '@holo-js/authorization', summary: 'Policies, abilities, gates, and typed permission checks.' }, + { name: 'Security', pkg: '@holo-js/security', summary: 'CSRF, CORS, rate limits, and request protection primitives.' }, { name: 'Queue', pkg: '@holo-js/queue', summary: 'Background jobs with retries and failed-job tracking.' }, { name: 'Cache', pkg: '@holo-js/cache', summary: 'Drivers, locks, and query-level caching.' }, { name: 'Storage', pkg: '@holo-js/storage', summary: 'File storage across local and cloud drivers.' }, { name: 'Events', pkg: '@holo-js/events', summary: 'Listeners, queued listeners, and after-commit hooks.' }, { name: 'Broadcast', pkg: '@holo-js/broadcast', summary: 'Realtime channels, auth routes, and Flux helpers.' }, + { name: 'Realtime', pkg: '@holo-js/realtime', summary: 'Typed queries, mutations, subscriptions, and live state wiring.' }, { name: 'Forms', pkg: '@holo-js/forms', summary: 'Validated server actions with framework adapters.' }, + { name: 'Validation', pkg: '@holo-js/validation', summary: 'Schema contracts, typed parsing, and reusable validation surfaces.' }, + { name: 'Mail', pkg: '@holo-js/mail', summary: 'Mailables, fluent sending, previews, and delivery contracts.' }, + { name: 'Notifications', pkg: '@holo-js/notifications', summary: 'Notification channels, dispatch helpers, and delivery routing.' }, + { name: 'Media', pkg: '@holo-js/media', summary: 'Media collections, conversions, and model file attachments.' }, ] const partners = [ @@ -90,7 +106,7 @@ const partners = [ -