@warlock.js/core 5.12.0 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +86 -54
  2. package/esm/cli/commands/build.command.mjs.map +1 -1
  3. package/esm/cli/commands/dev-server.command.mjs +2 -0
  4. package/esm/cli/commands/dev-server.command.mjs.map +1 -1
  5. package/esm/database/utils.d.mts +5 -1
  6. package/esm/database/utils.d.mts.map +1 -1
  7. package/esm/database/utils.mjs +7 -3
  8. package/esm/database/utils.mjs.map +1 -1
  9. package/esm/dev-server/file-event-handler.mjs +23 -5
  10. package/esm/dev-server/file-event-handler.mjs.map +1 -1
  11. package/esm/dev-server/files-watcher.mjs +6 -3
  12. package/esm/dev-server/files-watcher.mjs.map +1 -1
  13. package/esm/dev-server/translation-type-generator.mjs +28 -0
  14. package/esm/dev-server/translation-type-generator.mjs.map +1 -0
  15. package/esm/dev-server/tsconfig-manager.mjs +1 -0
  16. package/esm/dev-server/tsconfig-manager.mjs.map +1 -1
  17. package/esm/dev-server/type-generator.mjs +41 -5
  18. package/esm/dev-server/type-generator.mjs.map +1 -1
  19. package/esm/encryption/index.mjs +1 -1
  20. package/esm/errors/esbuild-binary-missing-error.mjs +20 -0
  21. package/esm/errors/esbuild-binary-missing-error.mjs.map +1 -0
  22. package/esm/generations/features/auth-google.feature.mjs +18 -0
  23. package/esm/generations/features/auth-google.feature.mjs.map +1 -0
  24. package/esm/generations/features/auth-passkeys.feature.mjs +19 -0
  25. package/esm/generations/features/auth-passkeys.feature.mjs.map +1 -0
  26. package/esm/generations/features/bull-board.feature.mjs +65 -0
  27. package/esm/generations/features/bull-board.feature.mjs.map +1 -0
  28. package/esm/generations/features/index.mjs +8 -0
  29. package/esm/generations/features/index.mjs.map +1 -1
  30. package/esm/generations/features/queue.feature.mjs +70 -0
  31. package/esm/generations/features/queue.feature.mjs.map +1 -0
  32. package/esm/generations/features/shared/insert-connector-entry.mjs +68 -0
  33. package/esm/generations/features/shared/insert-connector-entry.mjs.map +1 -0
  34. package/esm/generations/features/shared/insert-queue-dashboard-block.mjs +55 -0
  35. package/esm/generations/features/shared/insert-queue-dashboard-block.mjs.map +1 -0
  36. package/esm/generations/features/web.feature.mjs +4 -1
  37. package/esm/generations/features/web.feature.mjs.map +1 -1
  38. package/esm/generations/stubs.mjs +4 -4
  39. package/esm/generations/stubs.mjs.map +1 -1
  40. package/esm/http/middleware/cache-response-middleware.d.mts +12 -0
  41. package/esm/http/middleware/cache-response-middleware.d.mts.map +1 -1
  42. package/esm/http/middleware/cache-response-middleware.mjs +15 -3
  43. package/esm/http/middleware/cache-response-middleware.mjs.map +1 -1
  44. package/esm/index.mjs +1 -1
  45. package/esm/production/esbuild-preflight.mjs +23 -13
  46. package/esm/production/esbuild-preflight.mjs.map +1 -1
  47. package/llms-full.txt +56 -24
  48. package/llms.txt +2 -2
  49. package/package.json +11 -12
  50. package/skills/run-app/SKILL.md +6 -2
  51. package/skills/use-localization/SKILL.md +24 -21
  52. package/skills/use-middleware/SKILL.md +24 -0
  53. package/skills/write-cli-command/SKILL.md +2 -1
@@ -1 +1 @@
1
- {"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\r\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\r\n\r\n/**\r\n * Authorization configuration — read by @warlock.js/access on boot.\r\n *\r\n * The resolver is the one required piece: it tells the engine how to read a\r\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\r\n * from the user_roles table and maps them through the roles catalog table (so\r\n * roles + their permissions are managed at runtime, in the DB).\r\n *\r\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\r\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\r\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\r\n *\r\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\r\n * tenant from the request; checks then scope to it automatically.\r\n */\r\nconst access: AccessConfigurations = {\r\n resolver: new DatabaseAccessResolver(),\r\n\r\n // Cache resolved permission sets (default \"10m\").\r\n // cache: { ttl: \"10m\" },\r\n};\r\n\r\nexport default access;\r\n`;\r\n\r\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\r\n\r\n// >>> warlock:ai-packages (auto-managed) >>>\r\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\r\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\r\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\r\n// side-effect import below; keep them so the augmentation + runtime registration\r\n// load before the ai connector applies this config.\r\n// <<< warlock:ai-packages <<<\r\n\r\n/**\r\n * AI configuration — applied on boot by the ai connector, which calls\r\n * ai.config(...) with the object below. Cross-cutting defaults live here\r\n * (shared cache / snapshot stores, observability); per-call options always win.\r\n *\r\n * Wire a default model from a provider you installed, e.g.:\r\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\r\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\r\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\r\n */\r\nconst ai: Partial<AIConfig> = {\r\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\r\n // defaultStore: cache.driver(\"redis\", { client }),\r\n\r\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\r\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\r\n};\r\n\r\nexport default ai;\r\n`;\r\n\r\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the roles catalog — mirrors the migration columns\r\n * (snake_case). Each row is a role name plus the permission strings it grants;\r\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\r\n * assigned role names through this table to their effective permissions.\r\n */\r\nexport const roleSchema = v.object({\r\n name: v.string(),\r\n permissions: v.array(v.string()).default([]),\r\n});\r\n\r\nexport type RoleSchema = Infer<typeof roleSchema>;\r\n\r\n/**\r\n * The roles catalog — role name → the permissions it grants. Managed at runtime\r\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\r\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\r\n */\r\n@RegisterModel()\r\nexport class Role extends Model<RoleSchema> {\r\n public static table = \"roles\";\r\n\r\n public static schema = roleSchema;\r\n\r\n /** The permission strings this role grants. */\r\n public get permissions(): string[] {\r\n return this.get<string[]>(\"permissions\", []);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\r\n`;\r\n\r\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\r\nimport { Role } from \"../role.model\";\r\n\r\n/**\r\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\r\n * text array of the permission strings the role grants.\r\n */\r\nexport default Migration.create(Role, {\r\n name: text().notNullable().unique(),\r\n permissions: arrayText().nullable(),\r\n});\r\n`;\r\n\r\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for a role assignment — mirrors the migration columns\r\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\r\n */\r\nexport const userRoleSchema = v.object({\r\n user_id: v.string(),\r\n user_type: v.string(),\r\n role: v.string(),\r\n tenant: v.string().optional(),\r\n});\r\n\r\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\r\n\r\n/**\r\n * The role-assignment table — which roles a user holds, optionally per tenant.\r\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\r\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\r\n * never need to call \\`access.flush(user, tenant)\\` themselves.\r\n */\r\n@RegisterModel()\r\nexport class UserRole extends Model<UserRoleSchema> {\r\n public static table = \"user_roles\";\r\n\r\n public static schema = userRoleSchema;\r\n\r\n /**\r\n * Role names assigned to the user in the given tenant.\r\n *\r\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\r\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\r\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\r\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\r\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\r\n */\r\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\r\n const rows = await this.query()\r\n .where({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n tenant: tenant ?? null,\r\n })\r\n .get();\r\n\r\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\r\n // existence check) can't distort the resolved set.\r\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\r\n }\r\n\r\n /**\r\n * Assign a role to the user. No-op if the assignment already exists.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\r\n const existing = await this.first({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n if (existing) return;\r\n\r\n await this.create({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n\r\n /**\r\n * Remove a role assignment from the user.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\r\n await this.delete({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\r\n`;\r\n\r\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\r\nimport { UserRole } from \"../user-role.model\";\r\n\r\n/**\r\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\r\n * user ids are integers. The composite index powers the per-user (per-tenant)\r\n * lookup the resolver runs on every check.\r\n */\r\nexport default Migration.create(\r\n UserRole,\r\n {\r\n user_id: uuid().notNullable().index(),\r\n user_type: text().notNullable(),\r\n role: text().notNullable().index(),\r\n tenant: text().nullable().index(),\r\n },\r\n {\r\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\r\n },\r\n);\r\n`;\r\n\r\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Role } from \"app/access/models/role\";\r\nimport { UserRole } from \"app/access/models/user-role\";\r\n\r\n/**\r\n * The app's access adapter — connects @warlock.js/access to the ejected role\r\n * tables. Roles come from the user_roles assignment table; permissions are\r\n * expanded by mapping those role names through the roles catalog table. Both\r\n * are managed at runtime (in the DB), so admins can add roles + edit their\r\n * permissions without a deploy.\r\n *\r\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\r\n * resolver only fetches — keep it dumb, never cache inside it.\r\n */\r\nexport class DatabaseAccessResolver implements AccessResolver {\r\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\r\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\r\n return UserRole.rolesFor(user, tenant);\r\n }\r\n\r\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\r\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\r\n const names = await this.resolveRoles(user, tenant);\r\n\r\n if (names.length === 0) return [];\r\n\r\n const roles = await Role.query().whereIn(\"name\", names).get();\r\n\r\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\r\n return [...new Set(roles.flatMap((role) => role.permissions))];\r\n }\r\n\r\n /**\r\n * Optional. Resolve the ambient tenant when a check doesn't pass one\r\n * explicitly — derive it from the authenticated user (safer than reading\r\n * client request input, which a caller could spoof). Uncomment + adapt for a\r\n * multi-tenant app (single-tenant apps leave this off and return undefined).\r\n */\r\n // public resolveTenant(user: Auth): string | undefined {\r\n // return user.get(\"organization_id\");\r\n // }\r\n}\r\n`;\r\n\r\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\r\n\r\n/**\r\n * Socket.IO configuration — read by the framework's socket connector\r\n * on boot. When the HTTP server is running the socket server attaches\r\n * to it; otherwise it listens on its own configured port.\r\n *\r\n * Remove this file to disable the socket server entirely.\r\n */\r\nexport default {\r\n options: {\r\n cors: {\r\n origin: \"*\",\r\n },\r\n },\r\n} as SocketOptions;\r\n`;\r\n\r\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\r\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\r\n\r\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\r\n driver: \"rabbitmq\",\r\n name: \"default\",\r\n isDefault: true,\r\n\r\n // ============================================================================\r\n // Connection Settings\r\n // ============================================================================\r\n\r\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\r\n port: env(\"RABBITMQ_PORT\", 5672),\r\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\r\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\r\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\r\n\r\n // Or use connection URI (takes precedence over host/port)\r\n // uri: env(\"RABBITMQ_URL\"),\r\n\r\n // ============================================================================\r\n // Connection Options\r\n // ============================================================================\r\n\r\n /** Heartbeat interval in seconds */\r\n heartbeat: 60,\r\n\r\n /** Connection timeout in milliseconds */\r\n connectionTimeout: 10000,\r\n\r\n /** Enable automatic reconnection on disconnect */\r\n reconnect: true,\r\n\r\n /** Delay between reconnection attempts in milliseconds */\r\n reconnectDelay: 5_000,\r\n\r\n // ============================================================================\r\n // Consumer Options\r\n // ============================================================================\r\n\r\n /** Default prefetch count (number of unacknowledged messages per consumer) */\r\n prefetch: 10,\r\n\r\n // ============================================================================\r\n // Client Options (Native amqplib options)\r\n // ============================================================================\r\n // These options are passed directly to amqplib.connect()\r\n // for low-level configuration like frame size, TLS, socket options, etc.\r\n // ============================================================================\r\n clientOptions: {\r\n // Frame max size in bytes (0 = no limit)\r\n // frameMax: 0,\r\n\r\n // Channel max (0 = unlimited)\r\n // channelMax: 0,\r\n\r\n // Socket options\r\n socket: {\r\n // Enable TCP keep-alive\r\n keepAlive: true,\r\n\r\n // Disable Nagle's algorithm for lower latency\r\n noDelay: true,\r\n\r\n // Socket timeout (in addition to heartbeat)\r\n // timeout: 30000,\r\n },\r\n\r\n // TLS/SSL options (uncomment for secure connections)\r\n // socket: {\r\n // ca: fs.readFileSync('/path/to/ca.pem'),\r\n // cert: fs.readFileSync('/path/to/cert.pem'),\r\n // key: fs.readFileSync('/path/to/key.pem'),\r\n // rejectUnauthorized: true,\r\n // },\r\n },\r\n};\r\n\r\nexport default heraldConfigurations;\r\n`;\r\n\r\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"app/notifications/notification.model\";\r\n\r\n/**\r\n * Notifications configuration. Auto-loaded from src/config on boot — the\r\n * framework's notifications connector reads this default export and hands it to\r\n * setNotificationConfig, so this file stays declarative (no side-effect call).\r\n *\r\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\r\n * and defineNotification are type-checked against the registry.\r\n *\r\n * Channels enabled here:\r\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\r\n * The \"from\" address defaults to config/mail.ts; override per\r\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\r\n * - database in-app store backed by the Notification model. The \"inApp\"\r\n * facade exposes the recipient-scoped read API: listUnread,\r\n * countUnread, markAsRead, dismiss, ...\r\n *\r\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\r\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\r\n * queue line below.\r\n */\r\nconst config: NotificationConfig = {\r\n channels: {\r\n mail: mailChannel(),\r\n database: inApp.configure({ model: Notification }),\r\n },\r\n\r\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\r\n // queue: heraldQueue(),\r\n};\r\n\r\nexport default config;\r\n`;\r\n\r\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\r\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\r\nimport { v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the notifications table — mirrors the migration\r\n * columns (snake_case). Cascade validates + casts every write against it:\r\n * nullable columns use .nullish() (may be absent or null), and payload is\r\n * free-form JSON. Keep this in sync with the migration + columnMap when you\r\n * add or rename columns.\r\n */\r\nconst notificationSchema = v.object({\r\n user_id: v.string(),\r\n type: v.string(),\r\n title: v.string(),\r\n body: v.string().nullish(),\r\n payload: v.record(v.any()).nullish(),\r\n read_at: v.date().nullish(),\r\n idempotency_key: v.string().nullish(),\r\n});\r\n\r\n/**\r\n * In-app notification model.\r\n *\r\n * Extends the package's DatabaseNotification base, which provides the stable\r\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\r\n * from the columnMap below. The read/write API lives on the inApp facade\r\n * (configured in config/notifications.ts); you rarely touch this class directly.\r\n */\r\n@RegisterModel()\r\nexport class Notification extends DatabaseNotification {\r\n public static table = \"notifications\";\r\n public static schema = notificationSchema;\r\n\r\n /**\r\n * Maps the in-app store's roles to your columns. This default is\r\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\r\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\r\n * track a boolean read flag. The migration + accessors all follow this map.\r\n */\r\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\r\n}\r\n`;\r\n\r\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\r\nimport { notificationColumns } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"../notification.model\";\r\n\r\n/**\r\n * Notifications table.\r\n *\r\n * Columns come from notificationColumns(Notification) — the recipient / tenant\r\n * / read-state names follow the model's columnMap; type / title / body /\r\n * payload / idempotency_key are fixed. Spread it to add your own columns\r\n * (remember to mirror them in the model schema):\r\n *\r\n * import { uuid } from \"@warlock.js/cascade\";\r\n *\r\n * export default Migration.create(Notification, {\r\n * ...notificationColumns(Notification),\r\n * // category_id: uuid().index().nullable(),\r\n * });\r\n */\r\nexport default Migration.create(Notification, notificationColumns(Notification));\r\n`;\r\n\r\nexport const notificationControllersStub = `import { type RequestHandler } from \"@warlock.js/core\";\r\nimport { inApp, type Id } from \"@warlock.js/notifications\";\r\n\r\n/**\r\n * The authenticated user's notification HTTP surface — thin wrappers over the\r\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\r\n * rows). Notifications are produced by domain events, never over HTTP, so there\r\n * is no create. Trim or split these as your app grows.\r\n */\r\n\r\n/**\r\n * Read \\`id\\` off \\`request.locals.user\\` without assuming this app's\r\n * \\`RequestUser\\` augmentation declares it — \\`RequestUser\\` (declared by\r\n * \\`@warlock.js/auth\\`) is empty by default, so a narrow runtime read survives\r\n * any augmentation shape instead of assuming \\`.id\\` exists at the type level.\r\n * \\`inApp\\` only ever needs the id (it reduces a \\`Notifiable\\` to one via\r\n * \\`recipient.id\\` internally), so reading it here — rather than forwarding\r\n * \\`request.locals.user\\` itself — also skips a needless \\`Notifiable\\` cast.\r\n */\r\nfunction recipientId(user: unknown): Id {\r\n if (user && typeof user === \"object\" && \"id\" in user) {\r\n const id = (user as { id?: unknown }).id;\r\n\r\n if (typeof id === \"string\" || typeof id === \"number\") return id;\r\n }\r\n\r\n throw new Error(\"Authenticated request is missing a usable user id\");\r\n}\r\n\r\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\r\nexport const listNotificationsController: RequestHandler = async ({ request, response }) => {\r\n const { data, pagination } = await inApp.list(recipientId(request.locals.user), request.all());\r\n\r\n return response.success({ notifications: data, pagination });\r\n};\r\n\r\nlistNotificationsController.description = \"List notifications\";\r\n\r\n/** GET /notifications/unread-count — drives the bell badge. */\r\nexport const unreadNotificationsCountController: RequestHandler = async ({\r\n request,\r\n response,\r\n}) => {\r\n const count = await inApp.countUnread(recipientId(request.locals.user));\r\n\r\n return response.success({ count });\r\n};\r\n\r\nunreadNotificationsCountController.description = \"Unread notifications count\";\r\n\r\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\r\nexport const markNotificationReadController: RequestHandler = async ({ request, response }) => {\r\n const id = request.input(\"id\");\r\n const userId = recipientId(request.locals.user);\r\n\r\n await inApp.markAsRead(userId, id);\r\n const notification = await inApp.find(userId, id);\r\n\r\n return response.success({ notification });\r\n};\r\n\r\nmarkNotificationReadController.description = \"Mark notification read\";\r\n\r\n/** PATCH /notifications/read-all — mark every unread one read. */\r\nexport const markAllNotificationsReadController: RequestHandler = async ({\r\n request,\r\n response,\r\n}) => {\r\n const count = await inApp.markAsRead(recipientId(request.locals.user));\r\n\r\n return response.success({ count });\r\n};\r\n\r\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\r\n\r\n/** DELETE /notifications — dismiss all for the user. */\r\nexport const clearNotificationsController: RequestHandler = async ({ request, response }) => {\r\n await inApp.dismiss(recipientId(request.locals.user));\r\n\r\n return response.noContent();\r\n};\r\n\r\nclearNotificationsController.description = \"Clear notifications\";\r\n\r\n/** DELETE /notifications/:id — dismiss one. */\r\nexport const deleteNotificationController: RequestHandler = async ({ request, response }) => {\r\n await inApp.dismiss(recipientId(request.locals.user), request.input(\"id\"));\r\n\r\n return response.noContent();\r\n};\r\n\r\ndeleteNotificationController.description = \"Delete notification\";\r\n`;\r\n\r\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\r\nimport { router } from \"@warlock.js/core\";\r\nimport {\r\n clearNotificationsController,\r\n deleteNotificationController,\r\n listNotificationsController,\r\n markAllNotificationsReadController,\r\n markNotificationReadController,\r\n unreadNotificationsCountController,\r\n} from \"./controllers/notifications.controller\";\r\n\r\n/**\r\n * Notification routes — the authenticated user's read + dismiss surface.\r\n *\r\n * Notifications are produced by domain events (never created over HTTP), so\r\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\r\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\r\n * don't need; if your app reads notifications over sockets/GraphQL instead,\r\n * delete this file + the controllers entirely.\r\n */\r\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\r\n router.get(\"/\", listNotificationsController);\r\n router.get(\"/unread-count\", unreadNotificationsCountController);\r\n router.patch(\"/read-all\", markAllNotificationsReadController);\r\n router.patch(\"/:id/read\", markNotificationReadController);\r\n router.delete(\"/\", clearNotificationsController);\r\n router.delete(\"/:id\", deleteNotificationController);\r\n});\r\n`;\r\n\r\n/**\r\n * `src/web/root.tsx` — the application root for the SSR page layer.\r\n *\r\n * Deliberately minimal. The framework ships a default root, so this exists to\r\n * give you a place to start rather than because anything requires it. The\r\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\r\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\r\n */\r\nexport const webRootStub = `import type { AppProps } from \"@warlock.js/web\";\r\nimport { Head, Scripts } from \"@warlock.js/web\";\r\n\r\n/**\r\n * The application root.\r\n *\r\n * NOT async, and it receives no request/response: it renders on the server and\r\n * again in the browser during hydration, where neither exists.\r\n */\r\nexport default function App({ children }: AppProps) {\r\n return (\r\n <html lang=\"en\">\r\n <head>\r\n {/*\r\n Placement only. The framework injects the page's \\`metadata\\`, the\r\n stylesheet and preload tags for this route, and the canonical links\r\n into <head> by default — <Head /> just says WHERE they land.\r\n\r\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\r\n that emits one too produces two.\r\n */}\r\n <Head />\r\n <link rel=\"icon\" href=\"data:,\" />\r\n </head>\r\n <body>\r\n {/*\r\n REQUIRED — this is the hydration mount point, not a styling wrapper.\r\n\r\n The browser runtime looks up \\`#root\\` and hydrates that element only.\r\n Remove this div, or rename the id, and the page still renders from the\r\n server but never becomes interactive: the runtime throws in the console\r\n and nothing on screen changes.\r\n\r\n Wrap it in your own markup freely, and put anything that must live\r\n outside the hydrated tree (a static footer, a portal target) outside\r\n it — just keep an element with \\`id=\"root\"\\` around {children}.\r\n */}\r\n <div id=\"root\">{children}</div>\r\n {/*\r\n The hydration payload and module tags. Written explicitly because\r\n placement occasionally matters — a CSP nonce, or ordering against\r\n your own scripts.\r\n */}\r\n <Scripts />\r\n </body>\r\n </html>\r\n );\r\n}\r\n`;\r\n\r\n/**\r\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\r\n * for the Web starter's contact form. It intentionally has no persistence\r\n * dependency: replace the acknowledgement with a mail/job/database action.\r\n */\r\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\nexport const contactSchema = v.object({\r\n name: v.string().min(2),\r\n email: v.email(),\r\n message: v.string().min(10),\r\n});\r\n\r\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\r\n\r\n/** POST /api/contact — validates the starter contact form. */\r\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({\r\n request,\r\n response,\r\n}) => {\r\n const contact = request.validated();\r\n\r\n // Replace this with delivery/persistence for your app. Keeping the accepted\r\n // payload visible makes the endpoint useful while remaining side-effect free.\r\n return response.success({\r\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\r\n });\r\n};\r\n\r\ncontactController.validation = { schema: contactSchema };\r\n`;\r\n\r\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\r\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\r\nimport { contactController } from \"./controllers/contact.controller\";\r\n\r\nrouter.post(\"/api/contact\", contactController);\r\n`;\r\n\r\n/**\r\n * `src/web/index.register.ts` — universal static setup for the starter page.\r\n *\r\n * The page re-exports this stable binding so Warlock's `register()` lifecycle\r\n * still sees it in both realms without making React Fast Refresh treat every\r\n * JSX edit as an incompatible function-export replacement.\r\n */\r\nexport const webHomeRegisterStub = `import { extend } from \"@mongez/localization\";\r\n\r\nexport function register() {\r\n extend(\"en\", {\r\n starter: {\r\n title: \"Your Warlock app is running.\",\r\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\r\n language: \"العربية\",\r\n contact: \"Send a message\",\r\n name: \"Name\",\r\n email: \"Email\",\r\n message: \"Message\",\r\n submit: \"Send message\",\r\n sent: \"Thanks — your message has been received.\",\r\n },\r\n });\r\n extend(\"ar\", {\r\n starter: {\r\n title: \"تطبيق Warlock يعمل الآن.\",\r\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\r\n language: \"English\",\r\n contact: \"أرسل رسالة\",\r\n name: \"الاسم\",\r\n email: \"البريد الإلكتروني\",\r\n message: \"الرسالة\",\r\n submit: \"إرسال الرسالة\",\r\n sent: \"شكرًا — تم استلام رسالتك.\",\r\n },\r\n });\r\n}\r\n`;\r\n\r\n/**\r\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\r\n * the moment this finishes.\r\n */\r\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\r\nimport { setCurrentLocaleCode } from \"@mongez/localization\";\r\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\r\nimport { transX } from \"@mongez/react-localization\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { Link, type PageProps } from \"@warlock.js/web\";\r\nimport { useState } from \"react\";\r\n\r\nexport { register } from \"./index.register\";\r\n\r\n/**\r\n * A page route is an ordinary Warlock route whose handler renders React\r\n * instead of returning JSON.\r\n *\r\n * The URL and stable hydration name are the ones this file DECLARES below.\r\n * This page answers \\`GET \"/\"\\` because \\`route.path = \"/\"\\`, not because of\r\n * where the file lives. A page file with\r\n * no \\`route\\` export is REFUSED by both the dev server and the build.\r\n */\r\nexport const route = { path: \"/\", name: \"index\" } as const;\r\n\r\nexport const metadata = { title: \"Home\" };\r\n\r\nconst contactSchema = v.object({\r\n name: v.string().min(2),\r\n email: v.email(),\r\n message: v.string().min(10),\r\n});\r\n\r\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\r\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor={controlProps.name}>{label}</label>\r\n <input {...getInputProps()} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n\r\n/**\r\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\r\n * \\`data\\`, typed:\r\n *\r\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\r\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\r\n */\r\nexport default function HomePage(_props: PageProps) {\r\n // Live state. If the button below does nothing, the page rendered on the\r\n // server but never hydrated — the runtime never mounted at \\`#root\\`. This is\r\n // deliberately here so that failure is impossible to miss.\r\n const [count, setCount] = useState(0);\r\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\r\n const [submitted, setSubmitted] = useState(false);\r\n const [submitError, setSubmitError] = useState<string | null>(null);\r\n\r\n const toggleLocale = () => {\r\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\r\n setCurrentLocaleCode(nextLocale);\r\n setLocale(nextLocale);\r\n };\r\n\r\n return (\r\n <>\r\n {/*\r\n Self-contained, dependency-free styling: plain CSS, system fonts, and\r\n CSS custom properties, scoped to this page. No CSS framework, no utility\r\n classes, no external stylesheet — this page looks the same whether or\r\n not \\`warlock add tailwind\\` has ever been run.\r\n */}\r\n <style>{\\`\r\n .wk-home {\r\n --wk-fg: #0f172a;\r\n --wk-muted: #64748b;\r\n --wk-accent: #4f46e5;\r\n --wk-border: #e2e8f0;\r\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\r\n color: var(--wk-fg);\r\n max-width: 42rem;\r\n margin: 4rem auto;\r\n padding: 0 1.5rem;\r\n line-height: 1.6;\r\n }\r\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\r\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\r\n .wk-home code {\r\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\r\n background: #f1f5f9;\r\n padding: 0.1rem 0.35rem;\r\n border-radius: 0.25rem;\r\n }\r\n .wk-check {\r\n border: 1px solid var(--wk-border);\r\n border-radius: 0.75rem;\r\n padding: 1.25rem 1.5rem;\r\n margin: 2rem 0;\r\n }\r\n .wk-check strong { display: block; font-size: 1.5rem; }\r\n .wk-check button {\r\n font: inherit;\r\n cursor: pointer;\r\n background: var(--wk-accent);\r\n color: #fff;\r\n border: 0;\r\n border-radius: 0.5rem;\r\n padding: 0.5rem 1rem;\r\n margin-top: 0.75rem;\r\n }\r\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\r\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\r\n .wk-links a:hover { text-decoration: underline; }\r\n .wk-language { margin-left: auto; }\r\n .wk-contact { margin-top: 2rem; }\r\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\r\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\r\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\r\n .wk-success { color: #047857; }\r\n \\`}</style>\r\n\r\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\r\n <nav className=\"wk-links\" aria-label=\"Starter links\">\r\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">\r\n Docs\r\n </a>\r\n <Link href=\"/\" aria-current=\"page\">\r\n Home\r\n </Link>\r\n <button\r\n className=\"wk-language\"\r\n type=\"button\"\r\n aria-pressed={locale === \"ar\"}\r\n onClick={toggleLocale}\r\n >\r\n {transX(\"starter.language\")}\r\n </button>\r\n </nav>\r\n\r\n <h1>{transX(\"starter.title\")}</h1>\r\n <p>{transX(\"starter.introduction\")}</p>\r\n\r\n <section className=\"wk-check\">\r\n <label>If this number goes up when you click, React is hydrated:</label>\r\n <strong>{count}</strong>\r\n <button type=\"button\" onClick={() => setCount((c) => c + 1)}>\r\n Count up\r\n </button>\r\n </section>\r\n\r\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\r\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\r\n <Form<typeof contactSchema>\r\n id=\"contact-form\"\r\n schema={contactSchema}\r\n onSubmit={async ({ form, values }) => {\r\n setSubmitted(false);\r\n setSubmitError(null);\r\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\r\n\r\n if (result.error) {\r\n if (result.error.isValidationError) {\r\n const body = result.error.body as {\r\n errors?: Array<{ input: string; error: string }>;\r\n message?: string;\r\n };\r\n form.setErrors(\r\n Object.fromEntries(\r\n (body.errors ?? []).map(({ input, error }) => [input, error]),\r\n ),\r\n );\r\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\r\n } else {\r\n setSubmitError(\"Your message could not be sent. Please try again.\");\r\n }\r\n return;\r\n }\r\n\r\n setSubmitted(true);\r\n form.reset();\r\n }}\r\n >\r\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\r\n <TextInput\r\n name=\"email\"\r\n label={transX(\"starter.email\")}\r\n type=\"email\"\r\n autoComplete=\"email\"\r\n />\r\n <ContactMessage />\r\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\r\n {submitError && (\r\n <p className=\"wk-submit-error\" role=\"alert\">\r\n {submitError}\r\n </p>\r\n )}\r\n {submitted && (\r\n <p className=\"wk-success\" role=\"status\">\r\n {transX(\"starter.sent\")}\r\n </p>\r\n )}\r\n </Form>\r\n </section>\r\n </main>\r\n </>\r\n );\r\n}\r\n\r\nfunction ContactMessage() {\r\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\r\n <textarea {...getInputProps()} rows={5} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n`;\r\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8F3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BxC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnC,MAAa,kBAAkB"}
1
+ {"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\n\n/**\n * Authorization configuration — read by @warlock.js/access on boot.\n *\n * The resolver is the one required piece: it tells the engine how to read a\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\n * from the user_roles table and maps them through the roles catalog table (so\n * roles + their permissions are managed at runtime, in the DB).\n *\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\n *\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\n * tenant from the request; checks then scope to it automatically.\n */\nconst access: AccessConfigurations = {\n resolver: new DatabaseAccessResolver(),\n\n // Cache resolved permission sets (default \"10m\").\n // cache: { ttl: \"10m\" },\n};\n\nexport default access;\n`;\n\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\n\n// >>> warlock:ai-packages (auto-managed) >>>\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\n// side-effect import below; keep them so the augmentation + runtime registration\n// load before the ai connector applies this config.\n// <<< warlock:ai-packages <<<\n\n/**\n * AI configuration — applied on boot by the ai connector, which calls\n * ai.config(...) with the object below. Cross-cutting defaults live here\n * (shared cache / snapshot stores, observability); per-call options always win.\n *\n * Wire a default model from a provider you installed, e.g.:\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\n */\nconst ai: Partial<AIConfig> = {\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\n // defaultStore: cache.driver(\"redis\", { client }),\n\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\n};\n\nexport default ai;\n`;\n\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for the roles catalog — mirrors the migration columns\n * (snake_case). Each row is a role name plus the permission strings it grants;\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\n * assigned role names through this table to their effective permissions.\n */\nexport const roleSchema = v.object({\n name: v.string(),\n permissions: v.array(v.string()).default([]),\n});\n\nexport type RoleSchema = Infer<typeof roleSchema>;\n\n/**\n * The roles catalog — role name → the permissions it grants. Managed at runtime\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\n */\n@RegisterModel()\nexport class Role extends Model<RoleSchema> {\n public static table = \"roles\";\n\n public static schema = roleSchema;\n\n /** The permission strings this role grants. */\n public get permissions(): string[] {\n return this.get<string[]>(\"permissions\", []);\n }\n}\n`;\n\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\n`;\n\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\nimport { Role } from \"../role.model\";\n\n/**\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\n * text array of the permission strings the role grants.\n */\nexport default Migration.create(Role, {\n name: text().notNullable().unique(),\n permissions: arrayText().nullable(),\n});\n`;\n\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\nimport type { Auth } from \"@warlock.js/auth\";\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for a role assignment — mirrors the migration columns\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\n */\nexport const userRoleSchema = v.object({\n user_id: v.string(),\n user_type: v.string(),\n role: v.string(),\n tenant: v.string().optional(),\n});\n\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\n\n/**\n * The role-assignment table — which roles a user holds, optionally per tenant.\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\n * never need to call \\`access.flush(user, tenant)\\` themselves.\n */\n@RegisterModel()\nexport class UserRole extends Model<UserRoleSchema> {\n public static table = \"user_roles\";\n\n public static schema = userRoleSchema;\n\n /**\n * Role names assigned to the user in the given tenant.\n *\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\n */\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n const rows = await this.query()\n .where({\n user_id: user.id,\n user_type: user.userType,\n tenant: tenant ?? null,\n })\n .get();\n\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\n // existence check) can't distort the resolved set.\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\n }\n\n /**\n * Assign a role to the user. No-op if the assignment already exists.\n * Flushes the user's cached permission set automatically.\n */\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\n const existing = await this.first({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant: tenant ?? null,\n });\n\n if (existing) return;\n\n await this.create({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant,\n });\n\n await access.flush(user, tenant);\n }\n\n /**\n * Remove a role assignment from the user.\n * Flushes the user's cached permission set automatically.\n */\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\n await this.delete({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant: tenant ?? null,\n });\n\n await access.flush(user, tenant);\n }\n}\n`;\n\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\n`;\n\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\nimport { UserRole } from \"../user-role.model\";\n\n/**\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\n * user ids are integers. The composite index powers the per-user (per-tenant)\n * lookup the resolver runs on every check.\n */\nexport default Migration.create(\n UserRole,\n {\n user_id: uuid().notNullable().index(),\n user_type: text().notNullable(),\n role: text().notNullable().index(),\n tenant: text().nullable().index(),\n },\n {\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\n },\n);\n`;\n\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\nimport type { Auth } from \"@warlock.js/auth\";\nimport { Role } from \"app/access/models/role\";\nimport { UserRole } from \"app/access/models/user-role\";\n\n/**\n * The app's access adapter — connects @warlock.js/access to the ejected role\n * tables. Roles come from the user_roles assignment table; permissions are\n * expanded by mapping those role names through the roles catalog table. Both\n * are managed at runtime (in the DB), so admins can add roles + edit their\n * permissions without a deploy.\n *\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\n * resolver only fetches — keep it dumb, never cache inside it.\n */\nexport class DatabaseAccessResolver implements AccessResolver {\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\n return UserRole.rolesFor(user, tenant);\n }\n\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\n const names = await this.resolveRoles(user, tenant);\n\n if (names.length === 0) return [];\n\n const roles = await Role.query().whereIn(\"name\", names).get();\n\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\n return [...new Set(roles.flatMap((role) => role.permissions))];\n }\n\n /**\n * Optional. Resolve the ambient tenant when a check doesn't pass one\n * explicitly — derive it from the authenticated user (safer than reading\n * client request input, which a caller could spoof). Uncomment + adapt for a\n * multi-tenant app (single-tenant apps leave this off and return undefined).\n */\n // public resolveTenant(user: Auth): string | undefined {\n // return user.get(\"organization_id\");\n // }\n}\n`;\n\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\n\n/**\n * Socket.IO configuration — read by the framework's socket connector\n * on boot. When the HTTP server is running the socket server attaches\n * to it; otherwise it listens on its own configured port.\n *\n * Remove this file to disable the socket server entirely.\n */\nexport default {\n options: {\n cors: {\n origin: \"*\",\n },\n },\n} as SocketOptions;\n`;\n\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\n\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\n driver: \"rabbitmq\",\n name: \"default\",\n isDefault: true,\n\n // ============================================================================\n // Connection Settings\n // ============================================================================\n\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\n port: env(\"RABBITMQ_PORT\", 5672),\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\n\n // Or use connection URI (takes precedence over host/port)\n // uri: env(\"RABBITMQ_URL\"),\n\n // ============================================================================\n // Connection Options\n // ============================================================================\n\n /** Heartbeat interval in seconds */\n heartbeat: 60,\n\n /** Connection timeout in milliseconds */\n connectionTimeout: 10000,\n\n /** Enable automatic reconnection on disconnect */\n reconnect: true,\n\n /** Delay between reconnection attempts in milliseconds */\n reconnectDelay: 5_000,\n\n // ============================================================================\n // Consumer Options\n // ============================================================================\n\n /** Default prefetch count (number of unacknowledged messages per consumer) */\n prefetch: 10,\n\n // ============================================================================\n // Client Options (Native amqplib options)\n // ============================================================================\n // These options are passed directly to amqplib.connect()\n // for low-level configuration like frame size, TLS, socket options, etc.\n // ============================================================================\n clientOptions: {\n // Frame max size in bytes (0 = no limit)\n // frameMax: 0,\n\n // Channel max (0 = unlimited)\n // channelMax: 0,\n\n // Socket options\n socket: {\n // Enable TCP keep-alive\n keepAlive: true,\n\n // Disable Nagle's algorithm for lower latency\n noDelay: true,\n\n // Socket timeout (in addition to heartbeat)\n // timeout: 30000,\n },\n\n // TLS/SSL options (uncomment for secure connections)\n // socket: {\n // ca: fs.readFileSync('/path/to/ca.pem'),\n // cert: fs.readFileSync('/path/to/cert.pem'),\n // key: fs.readFileSync('/path/to/key.pem'),\n // rejectUnauthorized: true,\n // },\n },\n};\n\nexport default heraldConfigurations;\n`;\n\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\nimport { Notification } from \"app/notifications/notification.model\";\n\n/**\n * Notifications configuration. Auto-loaded from src/config on boot — the\n * framework's notifications connector reads this default export and hands it to\n * setNotificationConfig, so this file stays declarative (no side-effect call).\n *\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\n * and defineNotification are type-checked against the registry.\n *\n * Channels enabled here:\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\n * The \"from\" address defaults to config/mail.ts; override per\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\n * - database in-app store backed by the Notification model. The \"inApp\"\n * facade exposes the recipient-scoped read API: listUnread,\n * countUnread, markAsRead, dismiss, ...\n *\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\n * queue line below.\n */\nconst config: NotificationConfig = {\n channels: {\n mail: mailChannel(),\n database: inApp.configure({ model: Notification }),\n },\n\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\n // queue: heraldQueue(),\n};\n\nexport default config;\n`;\n\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\nimport { v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for the notifications table — mirrors the migration\n * columns (snake_case). Cascade validates + casts every write against it:\n * nullable columns use .nullish() (may be absent or null), and payload is\n * free-form JSON. Keep this in sync with the migration + columnMap when you\n * add or rename columns.\n */\nconst notificationSchema = v.object({\n user_id: v.string(),\n type: v.string(),\n title: v.string(),\n body: v.string().nullish(),\n payload: v.record(v.any()).nullish(),\n read_at: v.date().nullish(),\n idempotency_key: v.string().nullish(),\n});\n\n/**\n * In-app notification model.\n *\n * Extends the package's DatabaseNotification base, which provides the stable\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\n * from the columnMap below. The read/write API lives on the inApp facade\n * (configured in config/notifications.ts); you rarely touch this class directly.\n */\n@RegisterModel()\nexport class Notification extends DatabaseNotification {\n public static table = \"notifications\";\n public static schema = notificationSchema;\n\n /**\n * Maps the in-app store's roles to your columns. This default is\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\n * track a boolean read flag. The migration + accessors all follow this map.\n */\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\n}\n`;\n\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\nimport { notificationColumns } from \"@warlock.js/notifications\";\nimport { Notification } from \"../notification.model\";\n\n/**\n * Notifications table.\n *\n * Columns come from notificationColumns(Notification) — the recipient / tenant\n * / read-state names follow the model's columnMap; type / title / body /\n * payload / idempotency_key are fixed. Spread it to add your own columns\n * (remember to mirror them in the model schema):\n *\n * import { uuid } from \"@warlock.js/cascade\";\n *\n * export default Migration.create(Notification, {\n * ...notificationColumns(Notification),\n * // category_id: uuid().index().nullable(),\n * });\n */\nexport default Migration.create(Notification, notificationColumns(Notification));\n`;\n\nexport const notificationControllersStub = `import { type RequestHandler } from \"@warlock.js/core\";\nimport { inApp, type Id } from \"@warlock.js/notifications\";\n\n/**\n * The authenticated user's notification HTTP surface — thin wrappers over the\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\n * rows). Notifications are produced by domain events, never over HTTP, so there\n * is no create. Trim or split these as your app grows.\n */\n\n/**\n * Read \\`id\\` off \\`request.locals.user\\` without assuming this app's\n * \\`RequestUser\\` augmentation declares it — \\`RequestUser\\` (declared by\n * \\`@warlock.js/auth\\`) is empty by default, so a narrow runtime read survives\n * any augmentation shape instead of assuming \\`.id\\` exists at the type level.\n * \\`inApp\\` only ever needs the id (it reduces a \\`Notifiable\\` to one via\n * \\`recipient.id\\` internally), so reading it here — rather than forwarding\n * \\`request.locals.user\\` itself — also skips a needless \\`Notifiable\\` cast.\n */\nfunction recipientId(user: unknown): Id {\n if (user && typeof user === \"object\" && \"id\" in user) {\n const id = (user as { id?: unknown }).id;\n\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n\n throw new Error(\"Authenticated request is missing a usable user id\");\n}\n\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\nexport const listNotificationsController: RequestHandler = async ({ request, response }) => {\n const { data, pagination } = await inApp.list(recipientId(request.locals.user), request.all());\n\n return response.success({ notifications: data, pagination });\n};\n\nlistNotificationsController.description = \"List notifications\";\n\n/** GET /notifications/unread-count — drives the bell badge. */\nexport const unreadNotificationsCountController: RequestHandler = async ({\n request,\n response,\n}) => {\n const count = await inApp.countUnread(recipientId(request.locals.user));\n\n return response.success({ count });\n};\n\nunreadNotificationsCountController.description = \"Unread notifications count\";\n\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\nexport const markNotificationReadController: RequestHandler = async ({ request, response }) => {\n const id = request.input(\"id\");\n const userId = recipientId(request.locals.user);\n\n await inApp.markAsRead(userId, id);\n const notification = await inApp.find(userId, id);\n\n return response.success({ notification });\n};\n\nmarkNotificationReadController.description = \"Mark notification read\";\n\n/** PATCH /notifications/read-all — mark every unread one read. */\nexport const markAllNotificationsReadController: RequestHandler = async ({\n request,\n response,\n}) => {\n const count = await inApp.markAsRead(recipientId(request.locals.user));\n\n return response.success({ count });\n};\n\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\n\n/** DELETE /notifications — dismiss all for the user. */\nexport const clearNotificationsController: RequestHandler = async ({ request, response }) => {\n await inApp.dismiss(recipientId(request.locals.user));\n\n return response.noContent();\n};\n\nclearNotificationsController.description = \"Clear notifications\";\n\n/** DELETE /notifications/:id — dismiss one. */\nexport const deleteNotificationController: RequestHandler = async ({ request, response }) => {\n await inApp.dismiss(recipientId(request.locals.user), request.input(\"id\"));\n\n return response.noContent();\n};\n\ndeleteNotificationController.description = \"Delete notification\";\n`;\n\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\nimport { router } from \"@warlock.js/core\";\nimport {\n clearNotificationsController,\n deleteNotificationController,\n listNotificationsController,\n markAllNotificationsReadController,\n markNotificationReadController,\n unreadNotificationsCountController,\n} from \"./controllers/notifications.controller\";\n\n/**\n * Notification routes — the authenticated user's read + dismiss surface.\n *\n * Notifications are produced by domain events (never created over HTTP), so\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\n * don't need; if your app reads notifications over sockets/GraphQL instead,\n * delete this file + the controllers entirely.\n */\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\n router.get(\"/\", listNotificationsController);\n router.get(\"/unread-count\", unreadNotificationsCountController);\n router.patch(\"/read-all\", markAllNotificationsReadController);\n router.patch(\"/:id/read\", markNotificationReadController);\n router.delete(\"/\", clearNotificationsController);\n router.delete(\"/:id\", deleteNotificationController);\n});\n`;\n\n/**\n * `src/web/root.tsx` — the application root for the SSR page layer.\n *\n * Deliberately minimal. The framework ships a default root, so this exists to\n * give you a place to start rather than because anything requires it. The\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\n */\nexport const webRootStub = `import type { AppProps } from \"@warlock.js/web\";\nimport { Head, Scripts } from \"@warlock.js/web\";\n\n/**\n * The application root.\n *\n * NOT async, and it receives no request/response: it renders on the server and\n * again in the browser during hydration, where neither exists.\n */\nexport default function App({ children }: AppProps) {\n return (\n <html lang=\"en\">\n <head>\n {/*\n Placement only. The framework injects the page's \\`metadata\\`, the\n stylesheet and preload tags for this route, and the canonical links\n into <head> by default — <Head /> just says WHERE they land.\n\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\n that emits one too produces two.\n */}\n <Head />\n <link rel=\"icon\" href=\"data:,\" />\n </head>\n <body>\n {/*\n REQUIRED — this is the hydration mount point, not a styling wrapper.\n\n The browser runtime looks up \\`#vessel\\` and hydrates that element only.\n Remove this div, or rename the id, and the page still renders from the\n server but never becomes interactive: the runtime throws in the console\n and nothing on screen changes.\n\n Wrap it in your own markup freely, and put anything that must live\n outside the hydrated tree (a static footer, a portal target) outside\n it — just keep an element with \\`id=\"vessel\"\\` around {children}.\n */}\n <div id=\"vessel\">{children}</div>\n {/*\n The hydration payload and module tags. Written explicitly because\n placement occasionally matters — a CSP nonce, or ordering against\n your own scripts.\n */}\n <Scripts />\n </body>\n </html>\n );\n}\n`;\n\n/**\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\n * for the Web starter's contact form. It intentionally has no persistence\n * dependency: replace the acknowledgement with a mail/job/database action.\n */\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\nexport const contactSchema = v.object({\n name: v.string().min(2),\n email: v.email(),\n message: v.string().min(10),\n});\n\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\n\n/** POST /api/contact — validates the starter contact form. */\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({\n request,\n response,\n}) => {\n const contact = request.validated();\n\n // Replace this with delivery/persistence for your app. Keeping the accepted\n // payload visible makes the endpoint useful while remaining side-effect free.\n return response.success({\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\n });\n};\n\ncontactController.validation = { schema: contactSchema };\n`;\n\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\nimport { contactController } from \"./controllers/contact.controller\";\n\nrouter.post(\"/api/contact\", contactController);\n`;\n\n/**\n * `src/web/index.register.ts` — universal static setup for the starter page.\n *\n * The page re-exports this stable binding so Warlock's `register()` lifecycle\n * still sees it in both realms without making React Fast Refresh treat every\n * JSX edit as an incompatible function-export replacement.\n */\nexport const webHomeRegisterStub = `import { extend } from \"@mongez/localization\";\n\nexport function register() {\n extend(\"en\", {\n starter: {\n title: \"Your Warlock app is running.\",\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\n language: \"العربية\",\n contact: \"Send a message\",\n name: \"Name\",\n email: \"Email\",\n message: \"Message\",\n submit: \"Send message\",\n sent: \"Thanks — your message has been received.\",\n },\n });\n extend(\"ar\", {\n starter: {\n title: \"تطبيق Warlock يعمل الآن.\",\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\n language: \"English\",\n contact: \"أرسل رسالة\",\n name: \"الاسم\",\n email: \"البريد الإلكتروني\",\n message: \"الرسالة\",\n submit: \"إرسال الرسالة\",\n sent: \"شكرًا — تم استلام رسالتك.\",\n },\n });\n}\n`;\n\n/**\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\n * the moment this finishes.\n */\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\nimport { setCurrentLocaleCode } from \"@mongez/localization\";\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\nimport { transX } from \"@mongez/react-localization\";\nimport { v } from \"@warlock.js/seal\";\nimport { Link, type PageProps } from \"@warlock.js/web\";\nimport { useState } from \"react\";\n\nexport { register } from \"./index.register\";\n\n/**\n * A page route is an ordinary Warlock route whose handler renders React\n * instead of returning JSON.\n *\n * The URL and stable hydration name are the ones this file DECLARES below.\n * This page answers \\`GET \"/\"\\` because \\`route.path = \"/\"\\`, not because of\n * where the file lives. A page file with\n * no \\`route\\` export is REFUSED by both the dev server and the build.\n */\nexport const route = { path: \"/\", name: \"index\" } as const;\n\nexport const metadata = { title: \"Home\" };\n\nconst contactSchema = v.object({\n name: v.string().min(2),\n email: v.email(),\n message: v.string().min(10),\n});\n\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\n\n return (\n <div className=\"wk-field\">\n <label htmlFor={controlProps.name}>{label}</label>\n <input {...getInputProps()} />\n {error && <p {...getErrorProps()}>{error}</p>}\n </div>\n );\n}\n\n/**\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\n * \\`data\\`, typed:\n *\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\n */\nexport default function HomePage(_props: PageProps) {\n // Live state. If the button below does nothing, the page rendered on the\n // server but never hydrated — the runtime never mounted at \\`#vessel\\`. This is\n // deliberately here so that failure is impossible to miss.\n const [count, setCount] = useState(0);\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\n const [submitted, setSubmitted] = useState(false);\n const [submitError, setSubmitError] = useState<string | null>(null);\n\n const toggleLocale = () => {\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\n setCurrentLocaleCode(nextLocale);\n setLocale(nextLocale);\n };\n\n return (\n <>\n {/*\n Self-contained, dependency-free styling: plain CSS, system fonts, and\n CSS custom properties, scoped to this page. No CSS framework, no utility\n classes, no external stylesheet — this page looks the same whether or\n not \\`warlock add tailwind\\` has ever been run.\n */}\n <style>{\\`\n .wk-home {\n --wk-fg: #0f172a;\n --wk-muted: #64748b;\n --wk-accent: #4f46e5;\n --wk-border: #e2e8f0;\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n color: var(--wk-fg);\n max-width: 42rem;\n margin: 4rem auto;\n padding: 0 1.5rem;\n line-height: 1.6;\n }\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\n .wk-home code {\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n background: #f1f5f9;\n padding: 0.1rem 0.35rem;\n border-radius: 0.25rem;\n }\n .wk-check {\n border: 1px solid var(--wk-border);\n border-radius: 0.75rem;\n padding: 1.25rem 1.5rem;\n margin: 2rem 0;\n }\n .wk-check strong { display: block; font-size: 1.5rem; }\n .wk-check button {\n font: inherit;\n cursor: pointer;\n background: var(--wk-accent);\n color: #fff;\n border: 0;\n border-radius: 0.5rem;\n padding: 0.5rem 1rem;\n margin-top: 0.75rem;\n }\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\n .wk-links a:hover { text-decoration: underline; }\n .wk-language { margin-left: auto; }\n .wk-contact { margin-top: 2rem; }\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\n .wk-success { color: #047857; }\n \\`}</style>\n\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\n <nav className=\"wk-links\" aria-label=\"Starter links\">\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">\n Docs\n </a>\n <Link href=\"/\" aria-current=\"page\">\n Home\n </Link>\n <button\n className=\"wk-language\"\n type=\"button\"\n aria-pressed={locale === \"ar\"}\n onClick={toggleLocale}\n >\n {transX(\"starter.language\")}\n </button>\n </nav>\n\n <h1>{transX(\"starter.title\")}</h1>\n <p>{transX(\"starter.introduction\")}</p>\n\n <section className=\"wk-check\">\n <label>If this number goes up when you click, React is hydrated:</label>\n <strong>{count}</strong>\n <button type=\"button\" onClick={() => setCount((c) => c + 1)}>\n Count up\n </button>\n </section>\n\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\n <Form<typeof contactSchema>\n id=\"contact-form\"\n schema={contactSchema}\n onSubmit={async ({ form, values }) => {\n setSubmitted(false);\n setSubmitError(null);\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\n\n if (result.error) {\n if (result.error.isValidationError) {\n const body = result.error.body as {\n errors?: Array<{ input: string; error: string }>;\n message?: string;\n };\n form.setErrors(\n Object.fromEntries(\n (body.errors ?? []).map(({ input, error }) => [input, error]),\n ),\n );\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\n } else {\n setSubmitError(\"Your message could not be sent. Please try again.\");\n }\n return;\n }\n\n setSubmitted(true);\n form.reset();\n }}\n >\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\n <TextInput\n name=\"email\"\n label={transX(\"starter.email\")}\n type=\"email\"\n autoComplete=\"email\"\n />\n <ContactMessage />\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\n {submitError && (\n <p className=\"wk-submit-error\" role=\"alert\">\n {submitError}\n </p>\n )}\n {submitted && (\n <p className=\"wk-success\" role=\"status\">\n {transX(\"starter.sent\")}\n </p>\n )}\n </Form>\n </section>\n </main>\n </>\n );\n}\n\nfunction ContactMessage() {\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\n\n return (\n <div className=\"wk-field\">\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\n <textarea {...getInputProps()} rows={5} />\n {error && <p {...getErrorProps()}>{error}</p>}\n </div>\n );\n}\n`;\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8F3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BxC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnC,MAAa,kBAAkB"}
@@ -31,6 +31,18 @@ type CacheMiddlewareOptions = {
31
31
  * @default cache manager
32
32
  */
33
33
  driver?: string;
34
+ /**
35
+ * Tags this cached response is stored under, mirroring `route.cache.tags`
36
+ * on `@warlock.js/web`'s page cache (`PageCacheOptIn.tags`,
37
+ * `web/src/routing/route-identity.ts`) so the two caches share one mental
38
+ * model: `cache.tags([...]).invalidate()` from `@warlock.js/cache` evicts
39
+ * a tagged API response the same way it evicts a tagged page. Either a
40
+ * static list, or a function of the request — resolved once per request,
41
+ * right before the response is stored.
42
+ *
43
+ * @default undefined (untagged — behaves exactly as before this option existed)
44
+ */
45
+ tags?: string[] | ((request: Request) => string[]);
34
46
  };
35
47
  declare function cacheMiddleware(responseCacheOptions: CacheMiddlewareOptions | string): Middleware;
36
48
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"cache-response-middleware.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"mappings":";;;;KAsBY,sBAAA;;AAAZ;;EAIE,QAAA,aAAqB,OAAA,EAAS,OAAA,iBAAwB,OAAA,EAAS,OAAA,KAAY,OAAA;EAA7C;;;;;;EAO9B,UAAA;EAPqB;;;;;EAarB,IAAA;EAIA;;;EAAA,GAAA;EA+Cc;;;;;;EAxCd,MAAA;AAAA;AAAA,iBAwCc,eAAA,CAAgB,oBAAA,EAAsB,sBAAA,YAAkC,UAAU"}
1
+ {"version":3,"file":"cache-response-middleware.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"mappings":";;;;KAsBY,sBAAA;;AAAZ;;EAIE,QAAA,aAAqB,OAAA,EAAS,OAAA,iBAAwB,OAAA,EAAS,OAAA,KAAY,OAAA;EAA7C;;;;;;EAO9B,UAAA;EAP8B;;;;;EAa9B,IAAA;EAAA;;;EAIA,GAAA;EAmB6B;;;AAAO;AAyDtC;;EArEE,MAAA;EAqEgG;;;;;AAAA;;;;;;EAzDhG,IAAA,gBAAoB,OAAA,EAAS,OAAA;AAAA;AAAA,iBAyDf,eAAA,CAAgB,oBAAA,EAAsB,sBAAA,YAAkC,UAAU"}
@@ -4,12 +4,24 @@ import { cache } from "@warlock.js/cache";
4
4
 
5
5
  //#region ../core/src/http/middleware/cache-response-middleware.ts
6
6
  const defaultCacheOptions = { withLocale: true };
7
+ /**
8
+ * Resolves `CacheMiddlewareOptions.tags` — a static list or a function of
9
+ * the request — into a concrete list at store time. Mirrors
10
+ * `resolveCacheTags` in `@warlock.js/web`'s `create-page-route-handler.ts`,
11
+ * the equivalent seam for `route.cache.tags`.
12
+ */
13
+ function resolveCacheTags(tags, request) {
14
+ if (tags === void 0) return [];
15
+ return typeof tags === "function" ? tags(request) : tags;
16
+ }
7
17
  async function parseCacheOptions(cacheOptions, request) {
8
18
  if (typeof cacheOptions === "string") cacheOptions = { cacheKey: cacheOptions };
9
19
  if (typeof cacheOptions.cacheKey === "function") cacheOptions.cacheKey = await cacheOptions.cacheKey(request);
20
+ const tags = resolveCacheTags(cacheOptions.tags, request);
10
21
  const finalCacheOptions = {
11
22
  ...defaultCacheOptions,
12
- ...cacheOptions
23
+ ...cacheOptions,
24
+ tags
13
25
  };
14
26
  if (finalCacheOptions.withLocale) {
15
27
  const locale = request.getLocaleCode();
@@ -20,7 +32,7 @@ async function parseCacheOptions(cacheOptions, request) {
20
32
  }
21
33
  function cacheMiddleware(responseCacheOptions) {
22
34
  return async function({ request, response }) {
23
- const { ttl, omit, cacheKey, driver } = await parseCacheOptions(responseCacheOptions, request);
35
+ const { ttl, omit, cacheKey, driver, tags } = await parseCacheOptions(responseCacheOptions, request);
24
36
  const cacheDriver = driver ? await cache.use(driver) : cache;
25
37
  const content = await cacheDriver.get(cacheKey);
26
38
  if (content) return response.replay({
@@ -36,7 +48,7 @@ function cacheMiddleware(responseCacheOptions) {
36
48
  data: except(response.parsedBody, omit),
37
49
  contentType: typeof sentContentType === "string" ? sentContentType : void 0
38
50
  };
39
- cacheDriver.set(cacheKey, content, ttl).catch((error) => {
51
+ (tags.length > 0 ? cacheDriver.tags(tags).set(cacheKey, content, ttl) : cacheDriver.set(cacheKey, content, ttl)).catch((error) => {
40
52
  log.error("cache-middleware", "set", error);
41
53
  });
42
54
  });
@@ -1 +1 @@
1
- {"version":3,"file":"cache-response-middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"sourcesContent":["import { except } from \"@mongez/reinforcements\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { Middleware } from \"../../router/types\";\nimport type { Request } from \"./../request\";\nimport type { Response } from \"./../response\";\n\n/**\n * Shape persisted to the cache for a cached response. Stores the status and\n * content-type alongside the body so the HIT path can replay the response\n * faithfully via {@link Response.replay} instead of re-entering `send()`.\n */\ntype CachedResponsePayload = {\n status: number;\n data: unknown;\n contentType?: string;\n};\n\n// TODO: Add option to determine whether to cache the response or not\n// TODO: add option to determine what to be cached from the response\n// TODO: add cache middleware config options for example to set the default driver, ttl, etc\n\nexport type CacheMiddlewareOptions = {\n /**\n * Cache key\n */\n cacheKey: string | ((request: Request) => string) | ((request: Request) => Promise<string>);\n /**\n * If true, then the response will be cached based on the current locale code\n * This is useful when you have a multi-language website, and you want to cache the response based on the current locale\n *\n * @default true\n */\n withLocale?: boolean;\n /**\n * List of keys from the response object to omit from the cached response\n *\n * @default ['user']\n */\n omit?: string[];\n /**\n * Expires after number of seconds\n */\n ttl?: number;\n /**\n * Cache driver\n *\n * @see config/cache.ts: drivers object\n * @default cache manager\n */\n driver?: string;\n};\n\nconst defaultCacheOptions: Partial<CacheMiddlewareOptions> = {\n withLocale: true,\n};\n\ntype ParsedCacheOptions = Required<CacheMiddlewareOptions> & {\n cacheKey: string;\n};\n\nasync function parseCacheOptions(cacheOptions: CacheMiddlewareOptions | string, request: Request) {\n if (typeof cacheOptions === \"string\") {\n cacheOptions = {\n cacheKey: cacheOptions,\n };\n }\n\n if (typeof cacheOptions.cacheKey === \"function\") {\n cacheOptions.cacheKey = await cacheOptions.cacheKey(request);\n }\n\n const finalCacheOptions = {\n ...defaultCacheOptions,\n ...cacheOptions,\n } as ParsedCacheOptions;\n\n if (finalCacheOptions.withLocale) {\n const locale = request.getLocaleCode();\n\n finalCacheOptions.cacheKey = `${finalCacheOptions.cacheKey}:${locale}`;\n }\n\n if (!finalCacheOptions.omit) {\n finalCacheOptions.omit = [\"user\", \"settings\"];\n }\n\n return finalCacheOptions;\n}\n\nexport function cacheMiddleware(responseCacheOptions: CacheMiddlewareOptions | string): Middleware {\n // The `Middleware` return annotation is load-bearing: without it, tsc never\n // checks this factory's calling convention, which is how the positional v4\n // shape survived an earlier refactor unnoticed.\n return async function ({ request, response }) {\n const { ttl, omit, cacheKey, driver } = await parseCacheOptions(responseCacheOptions, request);\n const cacheDriver = driver ? await cache.use(driver) : cache;\n\n const content = (await cacheDriver.get(cacheKey)) as CachedResponsePayload | null;\n\n if (content) {\n // Replay through the standard pipeline (status + content-type preserved)\n // instead of `baseResponse.send()`, which would re-enter Response.send()\n // on an already-sent reply, trip the double-send guard, and drop the\n // status / content-type.\n return response.replay({\n status: content.status ?? 200,\n body: content.data,\n contentType: content.contentType,\n });\n }\n\n response.onSent((response: Response) => {\n if (!response.isOk || response.request.path !== request.path) {\n return;\n }\n\n const sentContentType = response.contentType;\n\n const content: CachedResponsePayload = {\n status: response.statusCode,\n data: except(response.parsedBody, omit),\n contentType: typeof sentContentType === \"string\" ? sentContentType : undefined,\n };\n\n // `set` is fire-and-forget inside `onSent`; without a `.catch` a rejected\n // write (e.g. Redis down) would surface as an unhandledRejection.\n cacheDriver.set(cacheKey, content, ttl).catch((error: unknown) => {\n log.error(\"cache-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;AAqDA,MAAM,sBAAuD,EAC3D,YAAY,KACd;AAMA,eAAe,kBAAkB,cAA+C,SAAkB;CAChG,IAAI,OAAO,iBAAiB,UAC1B,eAAe,EACb,UAAU,aACZ;CAGF,IAAI,OAAO,aAAa,aAAa,YACnC,aAAa,WAAW,MAAM,aAAa,SAAS,OAAO;CAG7D,MAAM,oBAAoB;EACxB,GAAG;EACH,GAAG;CACL;CAEA,IAAI,kBAAkB,YAAY;EAChC,MAAM,SAAS,QAAQ,cAAc;EAErC,kBAAkB,WAAW,GAAG,kBAAkB,SAAS,GAAG;CAChE;CAEA,IAAI,CAAC,kBAAkB,MACrB,kBAAkB,OAAO,CAAC,QAAQ,UAAU;CAG9C,OAAO;AACT;AAEA,SAAgB,gBAAgB,sBAAmE;CAIjG,OAAO,eAAgB,EAAE,SAAS,YAAY;EAC5C,MAAM,EAAE,KAAK,MAAM,UAAU,WAAW,MAAM,kBAAkB,sBAAsB,OAAO;EAC7F,MAAM,cAAc,SAAS,MAAM,MAAM,IAAI,MAAM,IAAI;EAEvD,MAAM,UAAW,MAAM,YAAY,IAAI,QAAQ;EAE/C,IAAI,SAKF,OAAO,SAAS,OAAO;GACrB,QAAQ,QAAQ,UAAU;GAC1B,MAAM,QAAQ;GACd,aAAa,QAAQ;EACvB,CAAC;EAGH,SAAS,QAAQ,aAAuB;GACtC,IAAI,CAAC,SAAS,QAAQ,SAAS,QAAQ,SAAS,QAAQ,MACtD;GAGF,MAAM,kBAAkB,SAAS;GAEjC,MAAM,UAAiC;IACrC,QAAQ,SAAS;IACjB,MAAM,OAAO,SAAS,YAAY,IAAI;IACtC,aAAa,OAAO,oBAAoB,WAAW,kBAAkB;GACvE;GAIA,YAAY,IAAI,UAAU,SAAS,GAAG,CAAC,CAAC,OAAO,UAAmB;IAChE,IAAI,MAAM,oBAAoB,OAAO,KAAK;GAC5C,CAAC;EACH,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"cache-response-middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"sourcesContent":["import { except } from \"@mongez/reinforcements\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { Middleware } from \"../../router/types\";\nimport type { Request } from \"./../request\";\nimport type { Response } from \"./../response\";\n\n/**\n * Shape persisted to the cache for a cached response. Stores the status and\n * content-type alongside the body so the HIT path can replay the response\n * faithfully via {@link Response.replay} instead of re-entering `send()`.\n */\ntype CachedResponsePayload = {\n status: number;\n data: unknown;\n contentType?: string;\n};\n\n// TODO: Add option to determine whether to cache the response or not\n// TODO: add option to determine what to be cached from the response\n// TODO: add cache middleware config options for example to set the default driver, ttl, etc\n\nexport type CacheMiddlewareOptions = {\n /**\n * Cache key\n */\n cacheKey: string | ((request: Request) => string) | ((request: Request) => Promise<string>);\n /**\n * If true, then the response will be cached based on the current locale code\n * This is useful when you have a multi-language website, and you want to cache the response based on the current locale\n *\n * @default true\n */\n withLocale?: boolean;\n /**\n * List of keys from the response object to omit from the cached response\n *\n * @default ['user']\n */\n omit?: string[];\n /**\n * Expires after number of seconds\n */\n ttl?: number;\n /**\n * Cache driver\n *\n * @see config/cache.ts: drivers object\n * @default cache manager\n */\n driver?: string;\n /**\n * Tags this cached response is stored under, mirroring `route.cache.tags`\n * on `@warlock.js/web`'s page cache (`PageCacheOptIn.tags`,\n * `web/src/routing/route-identity.ts`) so the two caches share one mental\n * model: `cache.tags([...]).invalidate()` from `@warlock.js/cache` evicts\n * a tagged API response the same way it evicts a tagged page. Either a\n * static list, or a function of the request — resolved once per request,\n * right before the response is stored.\n *\n * @default undefined (untagged — behaves exactly as before this option existed)\n */\n tags?: string[] | ((request: Request) => string[]);\n};\n\nconst defaultCacheOptions: Partial<CacheMiddlewareOptions> = {\n withLocale: true,\n};\n\ntype ParsedCacheOptions = Required<Omit<CacheMiddlewareOptions, \"tags\">> & {\n cacheKey: string;\n /** Resolved, concrete tag list — see {@link resolveCacheTags}. */\n tags: string[];\n};\n\n/**\n * Resolves `CacheMiddlewareOptions.tags` — a static list or a function of\n * the request — into a concrete list at store time. Mirrors\n * `resolveCacheTags` in `@warlock.js/web`'s `create-page-route-handler.ts`,\n * the equivalent seam for `route.cache.tags`.\n */\nfunction resolveCacheTags(tags: CacheMiddlewareOptions[\"tags\"], request: Request): string[] {\n if (tags === undefined) return [];\n\n return typeof tags === \"function\" ? tags(request) : tags;\n}\n\nasync function parseCacheOptions(cacheOptions: CacheMiddlewareOptions | string, request: Request) {\n if (typeof cacheOptions === \"string\") {\n cacheOptions = {\n cacheKey: cacheOptions,\n };\n }\n\n if (typeof cacheOptions.cacheKey === \"function\") {\n cacheOptions.cacheKey = await cacheOptions.cacheKey(request);\n }\n\n const tags = resolveCacheTags(cacheOptions.tags, request);\n\n const finalCacheOptions = {\n ...defaultCacheOptions,\n ...cacheOptions,\n tags,\n } as ParsedCacheOptions;\n\n if (finalCacheOptions.withLocale) {\n const locale = request.getLocaleCode();\n\n finalCacheOptions.cacheKey = `${finalCacheOptions.cacheKey}:${locale}`;\n }\n\n if (!finalCacheOptions.omit) {\n finalCacheOptions.omit = [\"user\", \"settings\"];\n }\n\n return finalCacheOptions;\n}\n\nexport function cacheMiddleware(responseCacheOptions: CacheMiddlewareOptions | string): Middleware {\n // The `Middleware` return annotation is load-bearing: without it, tsc never\n // checks this factory's calling convention, which is how the positional v4\n // shape survived an earlier refactor unnoticed.\n return async function ({ request, response }) {\n const { ttl, omit, cacheKey, driver, tags } = await parseCacheOptions(\n responseCacheOptions,\n request,\n );\n const cacheDriver = driver ? await cache.use(driver) : cache;\n\n const content = (await cacheDriver.get(cacheKey)) as CachedResponsePayload | null;\n\n if (content) {\n // Replay through the standard pipeline (status + content-type preserved)\n // instead of `baseResponse.send()`, which would re-enter Response.send()\n // on an already-sent reply, trip the double-send guard, and drop the\n // status / content-type.\n return response.replay({\n status: content.status ?? 200,\n body: content.data,\n contentType: content.contentType,\n });\n }\n\n response.onSent((response: Response) => {\n if (!response.isOk || response.request.path !== request.path) {\n return;\n }\n\n const sentContentType = response.contentType;\n\n const content: CachedResponsePayload = {\n status: response.statusCode,\n data: except(response.parsedBody, omit),\n contentType: typeof sentContentType === \"string\" ? sentContentType : undefined,\n };\n\n // `set` is fire-and-forget inside `onSent`; without a `.catch` a rejected\n // write (e.g. Redis down) would surface as an unhandledRejection.\n //\n // Tagged and untagged writes go through separate calls rather than a\n // shared branch-free path so an untagged route's write stays byte\n // identical to what it was before `tags` existed.\n const write =\n tags.length > 0\n ? cacheDriver.tags(tags).set(cacheKey, content, ttl)\n : cacheDriver.set(cacheKey, content, ttl);\n\n write.catch((error: unknown) => {\n log.error(\"cache-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;AAiEA,MAAM,sBAAuD,EAC3D,YAAY,KACd;;;;;;;AAcA,SAAS,iBAAiB,MAAsC,SAA4B;CAC1F,IAAI,SAAS,QAAW,OAAO,CAAC;CAEhC,OAAO,OAAO,SAAS,aAAa,KAAK,OAAO,IAAI;AACtD;AAEA,eAAe,kBAAkB,cAA+C,SAAkB;CAChG,IAAI,OAAO,iBAAiB,UAC1B,eAAe,EACb,UAAU,aACZ;CAGF,IAAI,OAAO,aAAa,aAAa,YACnC,aAAa,WAAW,MAAM,aAAa,SAAS,OAAO;CAG7D,MAAM,OAAO,iBAAiB,aAAa,MAAM,OAAO;CAExD,MAAM,oBAAoB;EACxB,GAAG;EACH,GAAG;EACH;CACF;CAEA,IAAI,kBAAkB,YAAY;EAChC,MAAM,SAAS,QAAQ,cAAc;EAErC,kBAAkB,WAAW,GAAG,kBAAkB,SAAS,GAAG;CAChE;CAEA,IAAI,CAAC,kBAAkB,MACrB,kBAAkB,OAAO,CAAC,QAAQ,UAAU;CAG9C,OAAO;AACT;AAEA,SAAgB,gBAAgB,sBAAmE;CAIjG,OAAO,eAAgB,EAAE,SAAS,YAAY;EAC5C,MAAM,EAAE,KAAK,MAAM,UAAU,QAAQ,SAAS,MAAM,kBAClD,sBACA,OACF;EACA,MAAM,cAAc,SAAS,MAAM,MAAM,IAAI,MAAM,IAAI;EAEvD,MAAM,UAAW,MAAM,YAAY,IAAI,QAAQ;EAE/C,IAAI,SAKF,OAAO,SAAS,OAAO;GACrB,QAAQ,QAAQ,UAAU;GAC1B,MAAM,QAAQ;GACd,aAAa,QAAQ;EACvB,CAAC;EAGH,SAAS,QAAQ,aAAuB;GACtC,IAAI,CAAC,SAAS,QAAQ,SAAS,QAAQ,SAAS,QAAQ,MACtD;GAGF,MAAM,kBAAkB,SAAS;GAEjC,MAAM,UAAiC;IACrC,QAAQ,SAAS;IACjB,MAAM,OAAO,SAAS,YAAY,IAAI;IACtC,aAAa,OAAO,oBAAoB,WAAW,kBAAkB;GACvE;GAaA,CAJE,KAAK,SAAS,IACV,YAAY,KAAK,IAAI,CAAC,CAAC,IAAI,UAAU,SAAS,GAAG,IACjD,YAAY,IAAI,UAAU,SAAS,GAAG,EAEvC,CAAC,OAAO,UAAmB;IAC9B,IAAI,MAAM,oBAAoB,OAAO,KAAK;GAC5C,CAAC;EACH,CAAC;CACH;AACF"}
package/esm/index.mjs CHANGED
@@ -129,11 +129,11 @@ import { registerConfiguredConnectors } from "./connectors/register-configured-c
129
129
  import "./connectors/index.mjs";
130
130
  import { seeder } from "./database/seeds/seeder.mjs";
131
131
  import { SeederDependencyCycleError, UnknownSeederDependencyError } from "./database/seeds/seeder.errors.mjs";
132
+ import { hashPassword, verifyPassword } from "./encryption/password.mjs";
132
133
  import { useComputedModel, useComputedSlug, useHashedPassword } from "./database/utils.mjs";
133
134
  import "./database/index.mjs";
134
135
  import { decrypt, encrypt } from "./encryption/encrypt.mjs";
135
136
  import { hmacHash } from "./encryption/hash.mjs";
136
- import { hashPassword, verifyPassword } from "./encryption/password.mjs";
137
137
  import "./encryption/index.mjs";
138
138
  import { CascadeQueryBuilder } from "./repositories/adapters/cascade/cascade-query-builder.mjs";
139
139
  import { CascadeAdapter } from "./repositories/adapters/cascade/cascade-adapter.mjs";
@@ -1,3 +1,4 @@
1
+ import { EsbuildBinaryMissingError } from "../errors/esbuild-binary-missing-error.mjs";
1
2
  import esbuild from "esbuild";
2
3
 
3
4
  //#region ../core/src/production/esbuild-preflight.ts
@@ -13,24 +14,33 @@ import esbuild from "esbuild";
13
14
  */
14
15
  const UNLINKED_BINARY_SIGNATURE = "could not be found, and is needed by esbuild";
15
16
  /**
16
- * Fail fast, before bundling, when esbuild's native binary is not linked.
17
+ * The default probe: a trivial `transformSync` call. Trivial input keeps the
18
+ * cost negligible while still forcing esbuild to resolve and run its native
19
+ * binary, which is the only way an unlinked binary actually surfaces.
20
+ */
21
+ function runDefaultProbe() {
22
+ esbuild.transformSync("", { loader: "js" });
23
+ }
24
+ /**
25
+ * Fail fast, before dev or build does any other work, when esbuild's native
26
+ * binary is not linked.
17
27
  *
18
- * Runs a single trivial `transformSync` call the cheapest operation that
19
- * forces esbuild to resolve its platform binary so it adds no measurable
20
- * delay to `warlock build`. Only called from the production build path;
21
- * never from `warlock dev`.
28
+ * A healthy esbuild install is a no-op. A binary genuinely missing — because
29
+ * a platform package was never installed, or a package manager blocked its
30
+ * postinstall script is turned into {@link EsbuildBinaryMissingError}, a
31
+ * message that names the cause and the fix. Any other failure (a real syntax
32
+ * error, an unrelated platform mismatch, …) is rethrown unchanged — this
33
+ * preflight only owns the one known failure mode.
22
34
  *
23
- * A healthy esbuild install is a no-op. A binary genuinely missing because
24
- * pnpm blocked its postinstall script is turned into a message that names
25
- * the cause and the fix. Any other failure (a real syntax error, a platform
26
- * mismatch, …) is rethrown unchanged — this preflight only owns the one
27
- * known failure mode.
35
+ * @param probe Overrides the default `esbuild.transformSync` invocation.
36
+ * Intended for tests only.
37
+ * @throws {EsbuildBinaryMissingError} when the platform binary is missing or unlinked.
28
38
  */
29
- function assertEsbuildBinaryIsLinked() {
39
+ function assertEsbuildBinaryIsLinked(probe = runDefaultProbe) {
30
40
  try {
31
- esbuild.transformSync("", { loader: "js" });
41
+ probe();
32
42
  } catch (error) {
33
- if (isUnlinkedBinaryError(error)) throw new Error("esbuild's native binary is not installed for this platform, so `warlock build` cannot bundle.\n\nThis usually happens when pnpm's build-script approval gate blocked esbuild's postinstall script, so the platform binary was never linked. Fix it with:\n\n pnpm approve-builds\n\nthen reinstall, or reinstall dependencies with build scripts enabled if esbuild was excluded on purpose.");
43
+ if (isUnlinkedBinaryError(error)) throw new EsbuildBinaryMissingError({ cause: error });
34
44
  throw error;
35
45
  }
36
46
  }
@@ -1 +1 @@
1
- {"version":3,"file":"esbuild-preflight.mjs","names":[],"sources":["../../../../../../../core/src/production/esbuild-preflight.ts"],"sourcesContent":["import esbuild from \"esbuild\";\n\n/**\n * The exact substring esbuild's own loader throws when the platform-specific\n * binary package (e.g. `@esbuild/win32-x64`) never got installed. This is\n * the signature pnpm's build-script approval gate leaves behind: it blocks\n * esbuild's postinstall script, so the binary is never linked into place,\n * and any later call into esbuild dies with this message instead of a\n * project-shaped one.\n *\n * Source: `pkgAndSubpathForCurrentPlatform` in esbuild's `lib/main.js`.\n */\nconst UNLINKED_BINARY_SIGNATURE = \"could not be found, and is needed by esbuild\";\n\n/**\n * Fail fast, before bundling, when esbuild's native binary is not linked.\n *\n * Runs a single trivial `transformSync` call the cheapest operation that\n * forces esbuild to resolve its platform binary so it adds no measurable\n * delay to `warlock build`. Only called from the production build path;\n * never from `warlock dev`.\n *\n * A healthy esbuild install is a no-op. A binary genuinely missing because\n * pnpm blocked its postinstall script is turned into a message that names\n * the cause and the fix. Any other failure (a real syntax error, a platform\n * mismatch, …) is rethrown unchanged — this preflight only owns the one\n * known failure mode.\n */\nexport function assertEsbuildBinaryIsLinked(): void {\n try {\n esbuild.transformSync(\"\", { loader: \"js\" });\n } catch (error) {\n if (isUnlinkedBinaryError(error)) {\n throw new Error(\n \"esbuild's native binary is not installed for this platform, so \" +\n \"`warlock build` cannot bundle.\\n\\n\" +\n \"This usually happens when pnpm's build-script approval gate \" +\n \"blocked esbuild's postinstall script, so the platform binary \" +\n \"was never linked. Fix it with:\\n\\n\" +\n \" pnpm approve-builds\\n\\n\" +\n \"then reinstall, or reinstall dependencies with build scripts \" +\n \"enabled if esbuild was excluded on purpose.\",\n );\n }\n\n throw error;\n }\n}\n\n/**\n * Narrow an unknown thrown value down to esbuild's known \"binary not\n * linked\" failure, identified by the fixed substring esbuild itself throws.\n */\nfunction isUnlinkedBinaryError(error: unknown): boolean {\n return error instanceof Error && error.message.includes(UNLINKED_BINARY_SIGNATURE);\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,MAAM,4BAA4B;;;;;;;;;;;;;;;AAgBlC,SAAgB,8BAAoC;CAClD,IAAI;EACF,QAAQ,cAAc,IAAI,EAAE,QAAQ,KAAK,CAAC;CAC5C,SAAS,OAAO;EACd,IAAI,sBAAsB,KAAK,GAC7B,MAAM,IAAI,MACR,+XAQF;EAGF,MAAM;CACR;AACF;;;;;AAMA,SAAS,sBAAsB,OAAyB;CACtD,OAAO,iBAAiB,SAAS,MAAM,QAAQ,SAAS,yBAAyB;AACnF"}
1
+ {"version":3,"file":"esbuild-preflight.mjs","names":[],"sources":["../../../../../../../core/src/production/esbuild-preflight.ts"],"sourcesContent":["import esbuild from \"esbuild\";\nimport { EsbuildBinaryMissingError } from \"../errors/esbuild-binary-missing-error\";\n\nexport { EsbuildBinaryMissingError };\n\n/**\n * The exact substring esbuild's own loader throws when the platform-specific\n * binary package (e.g. `@esbuild/win32-x64`) never got installed. This is\n * the signature pnpm's build-script approval gate leaves behind: it blocks\n * esbuild's postinstall script, so the binary is never linked into place,\n * and any later call into esbuild dies with this message instead of a\n * project-shaped one.\n *\n * Source: `pkgAndSubpathForCurrentPlatform` in esbuild's `lib/main.js`.\n */\nconst UNLINKED_BINARY_SIGNATURE = \"could not be found, and is needed by esbuild\";\n\n/**\n * A cheap operation that forces esbuild to resolve and invoke its platform\n * binary. Injectable so tests can simulate a missing binary without deleting\n * anything from a real install.\n */\nexport type EsbuildProbe = () => void;\n\n/**\n * The default probe: a trivial `transformSync` call. Trivial input keeps the\n * cost negligible while still forcing esbuild to resolve and run its native\n * binary, which is the only way an unlinked binary actually surfaces.\n */\nfunction runDefaultProbe(): void {\n esbuild.transformSync(\"\", { loader: \"js\" });\n}\n\n/**\n * Fail fast, before dev or build does any other work, when esbuild's native\n * binary is not linked.\n *\n * A healthy esbuild install is a no-op. A binary genuinely missing because\n * a platform package was never installed, or a package manager blocked its\n * postinstall script is turned into {@link EsbuildBinaryMissingError}, a\n * message that names the cause and the fix. Any other failure (a real syntax\n * error, an unrelated platform mismatch, …) is rethrown unchanged — this\n * preflight only owns the one known failure mode.\n *\n * @param probe Overrides the default `esbuild.transformSync` invocation.\n * Intended for tests only.\n * @throws {EsbuildBinaryMissingError} when the platform binary is missing or unlinked.\n */\nexport function assertEsbuildBinaryIsLinked(probe: EsbuildProbe = runDefaultProbe): void {\n try {\n probe();\n } catch (error) {\n if (isUnlinkedBinaryError(error)) {\n throw new EsbuildBinaryMissingError({ cause: error });\n }\n\n throw error;\n }\n}\n\n/**\n * Narrow an unknown thrown value down to esbuild's known \"binary not\n * linked\" failure, identified by the fixed substring esbuild itself throws.\n */\nfunction isUnlinkedBinaryError(error: unknown): boolean {\n return error instanceof Error && error.message.includes(UNLINKED_BINARY_SIGNATURE);\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,MAAM,4BAA4B;;;;;;AAclC,SAAS,kBAAwB;CAC/B,QAAQ,cAAc,IAAI,EAAE,QAAQ,KAAK,CAAC;AAC5C;;;;;;;;;;;;;;;;AAiBA,SAAgB,4BAA4B,QAAsB,iBAAuB;CACvF,IAAI;EACF,MAAM;CACR,SAAS,OAAO;EACd,IAAI,sBAAsB,KAAK,GAC7B,MAAM,IAAI,0BAA0B,EAAE,OAAO,MAAM,CAAC;EAGtD,MAAM;CACR;AACF;;;;;AAMA,SAAS,sBAAsB,OAAyB;CACtD,OAAO,iBAAiB,SAAS,MAAM,QAAQ,SAAS,yBAAyB;AACnF"}
package/llms-full.txt CHANGED
@@ -3520,7 +3520,7 @@ const result = await measure("publish-event", () =>
3520
3520
 
3521
3521
  ---
3522
3522
  name: run-app
3523
- description: 'Three operational commands — `warlock dev` (HMR + type-gen + health checks), `warlock build` (esbuild bundle), `warlock start` (spawn the production bundle). All flags, all `warlock.config.ts` knobs that shape them. Triggers: `warlock dev`, `warlock build`, `warlock start`, `devServer`, `--fresh`, `--skip-typings`, `--skip-health`, `outdir`, `outFile`, `sourcemap`, `PortInUseError`, `assertPortIsAvailable`, `EADDRINUSE`; "start the dev server", "build for production", "run the bundle", "skip type generation", "tune watch globs", "dev server keyboard shortcuts", "press r to restart", "press q to quit", "restart the dev server", "port already in use"; typical config `warlock.config.ts > devServer / build`. Skip: writing a custom CLI — `@warlock.js/core/write-cli-command/SKILL.md`; config shape — `@warlock.js/core/configure-app/SKILL.md`; competing tooling `nodemon`, `tsx`, `ts-node-dev`, `esbuild` direct.'
3523
+ description: 'Three operational commands — `warlock dev` (HMR + type-gen + health checks), `warlock build` (esbuild bundle), `warlock start` (spawn the production bundle). All flags, all `warlock.config.ts` knobs that shape them. Triggers: `warlock dev`, `warlock build`, `warlock start`, `devServer`, `--fresh`, `--skip-typings`, `--skip-health`, `outdir`, `outFile`, `sourcemap`, `PortInUseError`, `assertPortIsAvailable`, `EADDRINUSE`, `EsbuildBinaryMissingError`; "start the dev server", "build for production", "run the bundle", "skip type generation", "tune watch globs", "dev server keyboard shortcuts", "press r to restart", "press q to quit", "restart the dev server", "port already in use"; typical config `warlock.config.ts > devServer / build`. Skip: writing a custom CLI — `@warlock.js/core/write-cli-command/SKILL.md`; config shape — `@warlock.js/core/configure-app/SKILL.md`; competing tooling `nodemon`, `tsx`, `ts-node-dev`, `esbuild` direct.'
3524
3524
  ---
3525
3525
 
3526
3526
  # Warlock — run the app
@@ -3663,6 +3663,7 @@ export default defineConfig({
3663
3663
  restartOnConfigChange: true, // restart when warlock.config.ts / .env* changes
3664
3664
  healthCheckers: [...] /* or false */,
3665
3665
  transpileCacheDebug: false, // name cache files <slug>.<hash>.js w/ // @source markers
3666
+ timings: false, // print a per-phase reload timing breakdown
3666
3667
  },
3667
3668
  });
3668
3669
  ```
@@ -3673,6 +3674,7 @@ export default defineConfig({
3673
3674
  - **`transpileCacheDebug`** — diagnostic only. Names `.warlock/transpile/*.js` files `<slug>.<hash>.js` and appends `// @source <path>` markers so you can eyeball which cache entry came from which source. Leave off in normal use.
3674
3675
  - **`checkForUpdates`** — on `warlock dev` start, check npm for a newer `@warlock.js/core` and print a one-line notice if one exists. Best-effort and non-blocking; auto-skipped in CI and non-TTY shells. In an interactive terminal the notice arms a **`u` shortcut** that updates every `@warlock.js/*` package, installs, and restarts the server; elsewhere it prints `npx warlock update` instead. The registry answer is cached for 24h in `.warlock/update-check.json`, so a day of restarts costs one lookup. See [`update-packages/SKILL.md`](../update-packages/SKILL.md).
3675
3676
  - **`restartOnConfigChange`** — restart the dev server when `warlock.config.ts` or any `.env*` changes (default `true`). Set `false` to get a warning instead and restart by hand. Neither file can be hot-reloaded, so without a restart the running services keep the old values.
3677
+ - **`timings`** — print a one-line, per-phase breakdown next to the `hmr update` line on every hot reload: `watcher`, `debounce`, `graph`, `reimport`, `connectors`. `watcher` is the raw-fs-notification-to-stabilised-event gap (chokidar's `awaitWriteFinish` window); `debounce` is the handler's own adaptive wait; the rest are self-explanatory. Opt-in, off by default — a disabled flag costs one boolean check per reload, since the watcher-settle bookkeeping only runs when this is on. Use it to see which phase a slow reload is actually spending time in.
3676
3678
 
3677
3679
  ## `warlock build` — production bundle
3678
3680
 
@@ -3778,7 +3780,7 @@ Means `docker stop` / `kubectl delete pod` works as expected: SIGTERM reaches th
3778
3780
 
3779
3781
  ### pnpm needs esbuild's install script allowed
3780
3782
 
3781
- pnpm 10+ will not run a dependency's install script unless the app names it. esbuild's script links its platform-native binary, and `warlock build` shells out to that binary — so the app installs cleanly and then cannot build:
3783
+ pnpm 10+ will not run a dependency's install script unless the app names it. esbuild's script links its platform-native binary, and both `warlock build` and `warlock dev` shell out to that binary — so the app installs cleanly and then cannot build or start dev:
3782
3784
 
3783
3785
  ```
3784
3786
  [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.27.7
@@ -3793,6 +3795,8 @@ allowBuilds:
3793
3795
 
3794
3796
  Note pnpm reads this from `pnpm-workspace.yaml`, **not** from `package.json`'s `pnpm` field — pnpm 11 warns that the field is ignored and then carries on, so settings left there fail silently.
3795
3797
 
3798
+ `warlock dev` checks for esbuild's native binary before starting (as `warlock build` already did) and fails fast with `EsbuildBinaryMissingError`, naming the same fix, instead of surfacing an opaque low-level error later from inside the bundler.
3799
+
3796
3800
  Nothing else is needed for pnpm. Warlock never requires an app to declare a package it does not import: generated code is checked at build time against the app's own `dependencies`, so `warlock build` failing over an unfamiliar package name is a framework bug, not a missing dependency.
3797
3801
 
3798
3802
  ### Output streams — what a supervisor may trust
@@ -6572,7 +6576,7 @@ import { groupedTranslations } from "@mongez/localization";
6572
6576
 
6573
6577
  groupedTranslations("products", {
6574
6578
  notFound: { en: "Product not found", ar: "المنتج غير موجود" },
6575
- created: { en: "Product created", ar: "تم إنشاء المنتج" },
6579
+ created: { en: "Product created", ar: "تم إنشاء المنتج" },
6576
6580
  });
6577
6581
 
6578
6582
  // 2. Look up in a controller / service
@@ -6597,11 +6601,11 @@ Every module owns its translation namespace under `src/app/<module>/utils/locale
6597
6601
  import { groupedTranslations } from "@mongez/localization";
6598
6602
 
6599
6603
  groupedTranslations("products", {
6600
- notFound: { en: "Product not found", ar: "المنتج غير موجود" },
6601
- outOfStock: { en: "Product out of stock", ar: "المنتج غير متوفر" },
6602
- created: { en: "Product created", ar: "تم إنشاء المنتج" },
6603
- updated: { en: "Product updated", ar: "تم تحديث المنتج" },
6604
- deleted: { en: "Product deleted", ar: "تم حذف المنتج" },
6604
+ notFound: { en: "Product not found", ar: "المنتج غير موجود" },
6605
+ outOfStock: { en: "Product out of stock", ar: "المنتج غير متوفر" },
6606
+ created: { en: "Product created", ar: "تم إنشاء المنتج" },
6607
+ updated: { en: "Product updated", ar: "تم تحديث المنتج" },
6608
+ deleted: { en: "Product deleted", ar: "تم حذف المنتج" },
6605
6609
  });
6606
6610
  ```
6607
6611
 
@@ -6648,6 +6652,10 @@ request.trans("products.notFound");
6648
6652
 
6649
6653
  All three lookups go through `@mongez/localization`'s `trans()` under the hood, with the locale pulled from the request context (or the global default).
6650
6654
 
6655
+ ### Web `useTrans()` key checking
6656
+
6657
+ When an app uses `@warlock.js/web`, `warlock dev` writes `.warlock/typings/translations.d.ts` from literal `groupedTranslations("group", { key: ... })` registrations. It augments web's `TranslationKeyRegistry`, so `useTrans()("products.notFound")` is checked against registered keys and a typo fails TypeScript. Before the generated file exists, `useTrans()` accepts `string` for a non-breaking first boot. Dynamic groups/keys and placeholders are not inferred.
6658
+
6651
6659
  ### Locale on a specific lookup
6652
6660
 
6653
6661
  ```ts
@@ -6675,7 +6683,7 @@ Configure the default:
6675
6683
 
6676
6684
  ```ts title="src/config/app.ts"
6677
6685
  export default {
6678
- localeCode: "en", // app-wide default
6686
+ localeCode: "en", // app-wide default
6679
6687
  // ...
6680
6688
  };
6681
6689
  ```
@@ -6694,20 +6702,19 @@ When a column stores per-locale values as an array:
6694
6702
 
6695
6703
  ```ts
6696
6704
  // Schema (Seal):
6697
- name_translations: v.array(
6705
+ name_translations: (v.array(
6698
6706
  v.object({
6699
- localeCode: v.string(), // "en", "ar", ...
6707
+ localeCode: v.string(), // "en", "ar", ...
6700
6708
  value: v.string(),
6701
- })
6709
+ }),
6702
6710
  ),
6703
-
6704
- // Stored row (DB):
6705
- {
6706
- name_translations: [
6707
- { localeCode: "en", value: "Hello World" },
6708
- { localeCode: "ar", value: "مرحبا" },
6709
- ],
6710
- }
6711
+ // Stored row (DB):
6712
+ {
6713
+ name_translations: [
6714
+ { localeCode: "en", value: "Hello World" },
6715
+ { localeCode: "ar", value: "مرحبا" },
6716
+ ],
6717
+ });
6711
6718
  ```
6712
6719
 
6713
6720
  Pick the right one for the current request:
@@ -6731,12 +6738,12 @@ getLocalized(
6731
6738
  ```
6732
6739
 
6733
6740
  - **`values`** — the localized-array column.
6734
- - **`localeCode`** *(optional)* — pin to a specific locale. Defaults to the current request's locale (reads via `useRequestStore()`).
6735
- - **`key`** *(default `"value"`)* — which property of the matched entry to return. Use a different key if your localized objects store the value under a different name.
6741
+ - **`localeCode`** _(optional)_ — pin to a specific locale. Defaults to the current request's locale (reads via `useRequestStore()`).
6742
+ - **`key`** _(default `"value"`)_ — which property of the matched entry to return. Use a different key if your localized objects store the value under a different name.
6736
6743
 
6737
6744
  ```ts
6738
6745
  const slug = getLocalized(product.get("slug_translations"), undefined, "value");
6739
- const tagline = getLocalized(product.get("name_translations"), "fr"); // force French
6746
+ const tagline = getLocalized(product.get("name_translations"), "fr"); // force French
6740
6747
  ```
6741
6748
 
6742
6749
  ### Use inside a resource for clean per-locale responses
@@ -7030,6 +7037,30 @@ router.get("/analytics/summary", summaryController, {
7030
7037
 
7031
7038
  `cacheKey` can be a string OR a function `(request) => string | Promise<string>` for per-request keys. Excludes failures and omits `["user", "settings"]` from the cached body by default.
7032
7039
 
7040
+ ### Tag-based invalidation
7041
+
7042
+ Give it `tags` — a static list, or a function of the request — to evict the entry early with `cache.tags([...]).invalidate()` (`@warlock.js/cache`), instead of waiting out `ttl`. Mirrors `route.cache.tags` on `@warlock.js/web`'s page cache, so an API response and a page can share the same tag and be invalidated together:
7043
+
7044
+ ```ts
7045
+ import { middleware } from "@warlock.js/core";
7046
+ import { cache } from "@warlock.js/cache";
7047
+
7048
+ router.get("/orders/:id", getOrderController, {
7049
+ middleware: [
7050
+ middleware.cache({
7051
+ cacheKey: (request) => `orders.${request.params.id}`,
7052
+ ttl: 300,
7053
+ tags: (request) => [`order.${request.params.id}`],
7054
+ }),
7055
+ ],
7056
+ });
7057
+
7058
+ // Elsewhere, after the order changes:
7059
+ await cache.tags([`order.${orderId}`]).invalidate();
7060
+ ```
7061
+
7062
+ Untagged entries (no `tags` given) behave exactly as before — they only expire via `ttl`.
7063
+
7033
7064
  ## Composed example
7034
7065
 
7035
7066
  Tight cap on logins, concurrency + idempotency on AI calls:
@@ -8944,7 +8975,7 @@ CLI commands, scheduled jobs, queue workers — anything running outside an HTTP
8944
8975
 
8945
8976
  ---
8946
8977
  name: write-cli-command
8947
- description: 'Author a custom `warlock <my-cmd>` command via the `command()` factory — name, description, action, options, preload, then register in `warlock.config.ts > cli.commands` or drop in `src/app/<module>/commands/`. Also covers built-in `warlock add` feature scaffolding, including the Web starter and `index.register.ts`. Triggers: `command`, `CLICommand`, `CLICommandPreload`, `CLICommandOption`, `preload`, `preAction`, `persistent`, `colors`, `warlock add`, `index.register.ts`; "write a custom warlock command", "one-off maintenance task", "ship a CLI from a package", "framework built-in commands"; typical import `import { command } from "@warlock.js/core"`. Skip: framework dev/build/start — `@warlock.js/core/run-app/SKILL.md`; warlock.config.ts wiring — `@warlock.js/core/configure-app/SKILL.md`; competing libs `commander`, `yargs`, `oclif`.'
8978
+ description: 'Author a custom `warlock <my-cmd>` command via the `command()` factory — name, description, action, options, preload, then register in `warlock.config.ts > cli.commands` or drop in `src/app/<module>/commands/`. Also covers built-in `warlock add` feature scaffolding, including the Web starter and `index.register.ts`. Triggers: `command`, `CLICommand`, `CLICommandPreload`, `CLICommandOption`, `preload`, `preAction`, `persistent`, `colors`, `warlock add`, `warlock add bull-board`, `index.register.ts`; "write a custom warlock command", "one-off maintenance task", "ship a CLI from a package", "framework built-in commands"; typical import `import { command } from "@warlock.js/core"`. Skip: framework dev/build/start — `@warlock.js/core/run-app/SKILL.md`; warlock.config.ts wiring — `@warlock.js/core/configure-app/SKILL.md`; competing libs `commander`, `yargs`, `oclif`.'
8948
8979
  ---
8949
8980
 
8950
8981
  # Warlock — write a CLI command
@@ -9151,6 +9182,7 @@ warlock generate (alias: g) — interactive picker
9151
9182
  | `react-email` | `react-email` + `@react-email/components` + `@react-email/render` + `@react-email/tailwind`; drops a `welcome-email.tsx` sample; patches `tsconfig.json` |
9152
9183
  | `web` | Warlock Web + React stack; scaffolds the application page, localization, contact endpoint, and Web configuration |
9153
9184
  | `react` | `react` + `react-dom` + types |
9185
+ | `bull-board` | `@bull-board/api` + `@bull-board/fastify`; writes a `dashboard` block to `src/config/queue.ts`. `requires: ["queue"]` — adds the `queue` feature first automatically when it's missing |
9154
9186
  | `image` | `sharp` (for the `Image` class) |
9155
9187
  | `mail` | `nodemailer` + types |
9156
9188
  | `ses` | `@aws-sdk/client-sesv2` |