@tatara/cli 0.9.2 → 0.11.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 (100) hide show
  1. package/dist/bin.mjs +1436 -319
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/apps/admin/app.ts +1 -1
  4. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +2 -2
  5. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +11 -4
  6. package/templates/features/admin/files/src/modules/staff/db/seed.ts +27 -0
  7. package/templates/features/admin/files/src/modules/staff/services/auth.ts +46 -0
  8. package/templates/features/admin/files/tests/helpers/staff.ts +8 -0
  9. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/db/staff.schema.ts +58 -0
  10. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/services/adapter.ts +13 -0
  11. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/db/staff.schema.ts +57 -0
  12. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/services/adapter.ts +13 -0
  13. package/templates/features/admin/files+prisma/prisma/models/staff.prisma +61 -0
  14. package/templates/features/admin/files+prisma+mysql/src/modules/staff/services/adapter.ts +5 -0
  15. package/templates/features/admin/files+prisma+postgres/src/modules/staff/services/adapter.ts +5 -0
  16. package/templates/features/auth-keycloak/files/keycloak/realm.json +70 -9
  17. package/templates/features/auth-keycloak/files/src/{core/auth → modules/users}/commands/keycloak-token.ts +3 -1
  18. package/templates/features/auth-keycloak/files/{src/core/auth/tests/keycloak-tokens.test.ts → tests/tatara/auth-keycloak-tokens.test.ts} +2 -2
  19. package/templates/features/auth-keycloak/files+auth-orgs/{src/core/auth/tests/keycloak-organizations.test.ts → tests/tatara/auth-keycloak-organizations.test.ts} +1 -1
  20. package/templates/features/auth-keycloak/files+authz/{src/core/auth/tests/keycloak-authz.test.ts → tests/tatara/auth-keycloak-authz.test.ts} +3 -2
  21. package/templates/features/auth-orgs/files+drizzle+mysql/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  22. package/templates/features/auth-orgs/files+drizzle+postgres/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  23. package/templates/features/authz/files/src/core/authz/system-roles.ts +1 -0
  24. package/templates/features/authz/files/src/{core/authz → modules/users}/routes/authorization.routes.ts +4 -1
  25. package/templates/features/authz/files/{src/core/authz/tests → tests/tatara}/authz.test.ts +4 -2
  26. package/templates/features/authz/files+drizzle/src/core/authz/index.ts +1 -0
  27. package/templates/features/authz/files+prisma/src/core/authz/index.ts +1 -0
  28. package/templates/features/base/files/AGENTS.md +17 -0
  29. package/templates/features/base/files/biome.template.json +1 -1
  30. package/templates/features/base/files/src/apps/api/app.ts +1 -1
  31. package/templates/features/base/files/src/apps/api/server.ts +7 -1
  32. package/templates/features/base/files/src/apps/cli/index.ts +4 -1
  33. package/templates/features/base/files/src/{core/http/app.ts → apps/http.ts} +6 -0
  34. package/templates/features/base/files/src/core/http/context.ts +2 -0
  35. package/templates/features/base/files/src/core/logging/logger.ts +5 -3
  36. package/templates/features/better-auth/files/src/{core/auth → modules/users}/db/seed.ts +1 -1
  37. package/templates/features/better-auth/files/src/modules/users/services/auth.ts +37 -0
  38. package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/delivery.ts +1 -1
  39. package/templates/features/better-auth/files/tests/helpers/auth.ts +4 -3
  40. package/templates/features/better-auth/files+admin/tests/tatara/auth-openapi-admin.test.ts +10 -0
  41. package/templates/features/better-auth/files+auth-orgs/{src/core/auth/tests/principal.test.ts → tests/tatara/auth-principal.test.ts} +1 -1
  42. package/templates/features/better-auth/files+auth-orgs+authz/{src/core/auth/tests/principal-authz.test.ts → tests/tatara/auth-principal-authz.test.ts} +1 -1
  43. package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  44. package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  45. package/templates/features/cache/files/src/core/cache/index.ts +23 -0
  46. package/templates/features/cache/files/src/core/http/rate-limit.ts +15 -0
  47. package/templates/features/cache/files/tests/tatara/cache.test.ts +22 -0
  48. package/templates/features/cache/files/tests/tatara/rate-limit.test.ts +27 -0
  49. package/templates/features/cache/files+postgres/src/core/cache/index.ts +36 -0
  50. package/templates/features/db/files+mysql+drizzle/src/core/commands/db/command-runs.schema.ts +10 -0
  51. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +6 -1
  52. package/templates/features/db/files+mysql+prisma/prisma/models/command-runs.prisma +9 -0
  53. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +1 -0
  54. package/templates/features/db/files+postgres/src/core/db/admin.ts +6 -1
  55. package/templates/features/email/files/src/core/email/send.ts +22 -2
  56. package/templates/features/email/files/src/modules/example/emails/welcome.ts +11 -0
  57. package/templates/features/jobs/files/src/apps/cron/index.ts +2 -3
  58. package/templates/features/jobs/files/src/apps/cron/runner.ts +11 -0
  59. package/templates/features/jobs/files/src/apps/worker/index.ts +2 -9
  60. package/templates/features/jobs/files/src/core/jobs/index.ts +37 -6
  61. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +7 -10
  62. package/templates/features/jobs/files+drizzle+postgres/src/core/jobs/outbox.ts +23 -0
  63. package/templates/features/jobs/files+drizzle+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
  64. package/templates/features/jobs/files+postgres/src/core/jobs/index.ts +47 -0
  65. package/templates/features/jobs/files+prisma+postgres/src/core/jobs/outbox.ts +17 -0
  66. package/templates/features/jobs/files+prisma+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
  67. package/templates/features/notifications/files/src/modules/example/notifications/welcome.ts +14 -0
  68. package/templates/features/notifications/files/src/modules/notifications/consumers/deliver.ts +6 -0
  69. package/templates/features/notifications/files/src/modules/notifications/consumers/fanout.ts +6 -0
  70. package/templates/features/notifications/files/src/modules/notifications/routes/notifications.routes.ts +168 -0
  71. package/templates/features/notifications/files/src/modules/notifications/services/notifier.ts +59 -0
  72. package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts +72 -0
  73. package/templates/features/notifications/files+drizzle/src/modules/notifications/services/store.ts +5 -0
  74. package/templates/features/notifications/files+drizzle+mysql/src/modules/notifications/db/notifications.schema.ts +40 -0
  75. package/templates/features/notifications/files+drizzle+postgres/src/modules/notifications/db/notifications.schema.ts +41 -0
  76. package/templates/features/notifications/files+prisma/prisma/models/notifications.prisma +34 -0
  77. package/templates/features/notifications/files+prisma/src/modules/notifications/services/store.ts +4 -0
  78. package/templates/features/observability/files/src/apps/instrument.ts +15 -0
  79. package/templates/features/realtime/files/src/core/realtime/index.ts +29 -0
  80. package/templates/features/realtime/files/src/modules/realtime/routes/realtime.routes.ts +38 -0
  81. package/templates/features/realtime/files/tests/tatara/realtime.test.ts +37 -0
  82. package/templates/versions.json +28 -8
  83. package/templates/features/better-auth/files/src/core/auth/auth.ts +0 -26
  84. package/templates/features/better-auth/files/src/core/auth/principal.ts +0 -10
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +0 -8
  86. /package/templates/features/auth/files/src/{core/auth → modules/users}/routes/me.routes.ts +0 -0
  87. /package/templates/features/auth/files/{src/core/auth/tests/me.test.ts → tests/tatara/auth-me.test.ts} +0 -0
  88. /package/templates/features/auth-keycloak/files/src/{core/auth → modules/users/services}/keycloak.ts +0 -0
  89. /package/templates/features/authz/files/{src/core/authz/tests/error-messages.test.ts → tests/tatara/authz-error-messages.test.ts} +0 -0
  90. /package/templates/features/authz/files/{src/core/authz/tests/request-body.test.ts → tests/tatara/authz-request-body.test.ts} +0 -0
  91. /package/templates/features/base/files/{src/core/config/tests/env.test.ts → tests/tatara/config-env.test.ts} +0 -0
  92. /package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/sms.ts +0 -0
  93. /package/templates/features/better-auth/files/{src/core/auth/tests/openapi.test.ts → tests/tatara/auth-openapi.test.ts} +0 -0
  94. /package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  95. /package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  96. /package/templates/features/better-auth/files+prisma+mysql/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  97. /package/templates/features/better-auth/files+prisma+postgres/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  98. /package/templates/features/email/files/{src/core/email/tests → tests/tatara}/email.test.ts +0 -0
  99. /package/templates/features/i18n/files/{src/core/i18n/tests → tests/tatara}/i18n.test.ts +0 -0
  100. /package/templates/features/storage/files/{src/core/storage/tests → tests/tatara}/storage.test.ts +0 -0
@@ -2,5 +2,11 @@ import { startServer } from "@tatara/http";
2
2
  import { app } from "#apps/api/app";
3
3
  import { env } from "#core/config/env";
4
4
  import { logger } from "#core/logging/logger";
5
+ // tatara:slot imports
5
6
 
6
- startServer(app, { port: env.PORT, name: "api", logger });
7
+ startServer(app, {
8
+ port: env.PORT,
9
+ name: "api",
10
+ logger,
11
+ // tatara:slot server-options
12
+ });
@@ -1,6 +1,9 @@
1
1
  import { runCommand, shutdown } from "@tatara/core";
2
2
  import { logger } from "#core/logging/logger";
3
3
  import { commands } from "#generated/commands";
4
+ // tatara:slot imports
4
5
 
5
- const code = await runCommand(commands, process.argv.slice(2), logger);
6
+ const code = await runCommand(commands, process.argv.slice(2), logger, {
7
+ // tatara:slot options
8
+ });
6
9
  await shutdown(code, logger);
@@ -20,6 +20,12 @@ export function createHttpApp(name: AppName, options: { title: string }) {
20
20
  ],
21
21
  },
22
22
  bodyLimitBytes: env.BODY_LIMIT_BYTES,
23
+ timeouts: {
24
+ defaultMs: env.REQUEST_TIMEOUT_MS,
25
+ paths: {
26
+ // tatara:slot timeout-paths
27
+ },
28
+ },
23
29
  defaults: { principal: null, language: "en" },
24
30
  });
25
31
  // tatara:slot middleware
@@ -3,6 +3,8 @@ import type { Logger } from "#core/logging/logger";
3
3
  export interface Principal {
4
4
  userId: string;
5
5
  orgId: string | null;
6
+ /** the caller's role in orgId, set by requireOrg() */
7
+ orgRole?: string | null;
6
8
  roles: string[];
7
9
  kind: "user" | "service";
8
10
  claims: Record<string, unknown>;
@@ -1,9 +1,11 @@
1
1
  import { loggerOptions } from "@tatara/core";
2
2
  import { pino } from "pino";
3
3
  import { env } from "#core/config/env";
4
+ // tatara:slot imports
4
5
 
5
- export const logger = pino(
6
- loggerOptions({ env: env.NODE_ENV, level: env.LOG_LEVEL, service: "__APP_NAME__" }),
7
- );
6
+ export const logger = pino({
7
+ ...loggerOptions({ env: env.NODE_ENV, level: env.LOG_LEVEL, service: "__APP_NAME__" }),
8
+ // tatara:slot logger-options
9
+ });
8
10
 
9
11
  export type Logger = typeof logger;
@@ -1,4 +1,4 @@
1
- import { auth } from "#core/auth/auth";
1
+ import { auth } from "#modules/users/services/auth";
2
2
  import { env } from "#core/config/env";
3
3
  import { defineSeed } from "@tatara/db";
4
4
 
@@ -0,0 +1,37 @@
1
+ import { createAuthInstance } from "@tatara/auth";
2
+ import { newId } from "@tatara/db";
3
+ import { bearer, openAPI } from "better-auth/plugins";
4
+ import { env } from "#core/config/env";
5
+ import { database } from "#modules/users/services/adapter";
6
+ // tatara:slot imports
7
+
8
+ /**
9
+ * The app's better-auth for its users. The options are the app's own; tatara only adds the
10
+ * principal middleware and the org check that come back with the instance.
11
+ */
12
+ export const { auth, attachPrincipal, requireOrg } = createAuthInstance(
13
+ {
14
+ appName: "__APP_NAME__",
15
+ baseURL: env.APP_URL,
16
+ basePath: "/api/auth",
17
+ secret: env.BETTER_AUTH_SECRET,
18
+ trustedOrigins: env.CORS_ORIGINS,
19
+ database,
20
+ advanced: { database: { generateId: () => newId() } },
21
+ // tatara:slot options
22
+ plugins: [
23
+ // Authorization: Bearer <session token> for mobile and API clients
24
+ bearer(),
25
+ // reference for the auth endpoints at /api/auth/reference
26
+ openAPI(),
27
+ // tatara:slot plugins
28
+ ],
29
+ },
30
+ {
31
+ resolvers: [
32
+ // tatara:slot resolvers
33
+ ],
34
+ },
35
+ );
36
+
37
+ export type Session = typeof auth.$Infer.Session;
@@ -1,5 +1,5 @@
1
1
  import { createOutbox } from "@tatara/auth";
2
- import { smsSender } from "#core/auth/sms";
2
+ import { smsSender } from "#modules/users/services/sms";
3
3
  import { env } from "#core/config/env";
4
4
  // tatara:slot imports
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { createTestUser as createUser, sessionHeaders } from "@tatara/auth/testing";
2
- import { auth } from "#core/auth/auth";
2
+ import { auth } from "#modules/users/services/auth";
3
3
  // tatara:slot imports
4
4
 
5
5
  export interface AsUserOptions {
@@ -24,10 +24,11 @@ export async function createTestUser(options: AsUserOptions = {}): Promise<strin
24
24
  /** A new user with a real session; the token is sent as a bearer token. */
25
25
  export async function authenticate(
26
26
  options: AsUserOptions = {},
27
- ): Promise<{ userId: string; headers: Record<string, string> }> {
27
+ ): Promise<{ userId: string; orgId?: string; headers: Record<string, string> }> {
28
28
  // tatara:slot authenticate
29
29
  const userId = await createTestUser(options);
30
- return { userId, headers: await sessionHeaders(auth, userId) };
30
+ const headers = await sessionHeaders(auth, userId);
31
+ // tatara:slot authenticated
31
32
  }
32
33
 
33
34
  /** Headers that authenticate as a new user. */
@@ -0,0 +1,10 @@
1
+ import { expect, it } from "vitest";
2
+ import { adminApi } from "#tests/helpers/api";
3
+
4
+ it("documents the staff auth on the admin app, not the users' sign-up", async () => {
5
+ const res = await adminApi.get<{ paths: Record<string, unknown> }>("/openapi.json");
6
+ expect(res.status).toBe(200);
7
+ const paths = Object.keys(res.body.paths);
8
+ expect(paths).toContain("/api/auth/sign-in/email");
9
+ expect(paths.some((path) => path.startsWith("/api/auth/sign-in/anonymous"))).toBe(false);
10
+ });
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { auth } from "#core/auth/auth";
2
+ import { auth } from "#modules/users/services/auth";
3
3
  import { api } from "#tests/helpers/api";
4
4
  import { authenticate } from "#tests/helpers/auth";
5
5
  import { resetDatabase } from "#tests/helpers/db";
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, expect, it } from "vitest";
2
- import { auth } from "#core/auth/auth";
2
+ import { auth } from "#modules/users/services/auth";
3
3
  import { api } from "#tests/helpers/api";
4
4
  import { authenticate } from "#tests/helpers/auth";
5
5
  import { applyTestAccess } from "#tests/helpers/authz";
@@ -1,5 +1,5 @@
1
1
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
2
- import * as authSchema from "#core/auth/db/auth.schema";
2
+ import * as authSchema from "#modules/users/db/auth.schema";
3
3
  import { db } from "#core/db/client";
4
4
  // tatara:slot imports
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
2
- import * as authSchema from "#core/auth/db/auth.schema";
2
+ import * as authSchema from "#modules/users/db/auth.schema";
3
3
  import { db } from "#core/db/client";
4
4
  // tatara:slot imports
5
5
 
@@ -0,0 +1,23 @@
1
+ import { onShutdown } from "@tatara/core";
2
+ import { type Cache, createCache, type CacheStore, memoryStore } from "@tatara/cache";
3
+ import { redisStore } from "@tatara/cache/redis";
4
+ import { Redis } from "ioredis";
5
+ import { env } from "#core/config/env";
6
+ import { logger } from "#core/logging/logger";
7
+
8
+ export type { Cache } from "@tatara/cache";
9
+
10
+ function redis(): CacheStore {
11
+ // connects on first command, so importing this file opens nothing
12
+ const client = new Redis(env.REDIS_URL, { lazyConnect: true, maxRetriesPerRequest: 3 });
13
+ onShutdown("cache", async () => {
14
+ await client.quit();
15
+ });
16
+ return redisStore(client);
17
+ }
18
+
19
+ /** The app's cache; pick the store with CACHE_DRIVER. */
20
+ export const cache: Cache = createCache(env.CACHE_DRIVER === "redis" ? redis() : memoryStore(), {
21
+ prefix: "__APP_NAME__",
22
+ onError: (error, key) => logger.warn({ err: error, key }, "cache unavailable, computing instead"),
23
+ });
@@ -0,0 +1,15 @@
1
+ import { clientIp, type RateLimitOptions, rateLimit } from "@tatara/http";
2
+ import { cache } from "#core/cache/index";
3
+ import { env } from "#core/config/env";
4
+
5
+ /**
6
+ * `middleware: [limitRequests({ name: "contact", windowMs: 60_000, max: 5 })]` on a route: per
7
+ * caller IP, or per whatever `key` returns (e.g. the signed-in user).
8
+ */
9
+ export function limitRequests(options: Omit<RateLimitOptions, "counter" | "key"> & Partial<Pick<RateLimitOptions, "key">>) {
10
+ return rateLimit({
11
+ counter: cache,
12
+ key: (c) => clientIp(c, { trustedHops: env.TRUSTED_PROXY_HOPS }),
13
+ ...options,
14
+ });
15
+ }
@@ -0,0 +1,22 @@
1
+ import { orgKey } from "@tatara/cache";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { cache } from "#core/cache/index";
4
+
5
+ describe("cache", () => {
6
+ it("wraps a computation and drops an org's keys together", async () => {
7
+ const orgId = crypto.randomUUID();
8
+ const load = vi.fn(async () => ({ total: 3 }));
9
+ expect(await cache.wrap(orgKey(orgId, "dashboard"), 60_000, load)).toEqual({ total: 3 });
10
+ expect(await cache.wrap(orgKey(orgId, "dashboard"), 60_000, load)).toEqual({ total: 3 });
11
+ expect(load).toHaveBeenCalledTimes(1);
12
+ expect(await cache.delPrefix(orgKey(orgId))).toBe(1);
13
+ expect(await cache.get(orgKey(orgId, "dashboard"))).toBeNull();
14
+ });
15
+
16
+ it("lets one holder run under a lock", async () => {
17
+ const key = crypto.randomUUID();
18
+ const first = cache.lock(key, 10_000, () => new Promise((r) => setTimeout(() => r("ran"), 50)));
19
+ expect(await cache.lock(key, 10_000, async () => "second")).toEqual({ acquired: false });
20
+ expect(await first).toEqual({ acquired: true, value: "ran" });
21
+ });
22
+ });
@@ -0,0 +1,27 @@
1
+ import { createRoute } from "@hono/zod-openapi";
2
+ import { createRouter, handleErrors } from "@tatara/http";
3
+ import { describe, expect, it } from "vitest";
4
+ import { limitRequests } from "#core/http/rate-limit";
5
+
6
+ describe("limitRequests", () => {
7
+ it("answers 429 once a caller passes the limit", async () => {
8
+ const name = crypto.randomUUID();
9
+ const app = createRouter();
10
+ app.openapi(
11
+ createRoute({
12
+ method: "post",
13
+ path: "/contact",
14
+ middleware: [limitRequests({ name, windowMs: 60_000, max: 2, key: (c) => c.req.header("x-who") })],
15
+ responses: { 204: { description: "Sent" } },
16
+ }),
17
+ (c) => c.body(null, 204),
18
+ );
19
+ handleErrors(app);
20
+ const send = () => app.request("/contact", { method: "POST", headers: { "x-who": "a" } });
21
+ expect((await send()).status).toBe(204);
22
+ expect((await send()).status).toBe(204);
23
+ const limited = await send();
24
+ expect(limited.status).toBe(429);
25
+ expect(limited.headers.get("retry-after")).toBe("60");
26
+ });
27
+ });
@@ -0,0 +1,36 @@
1
+ import { onShutdown } from "@tatara/core";
2
+ import { type Cache, createCache, type CacheStore, memoryStore } from "@tatara/cache";
3
+ import { postgresStore } from "@tatara/cache/postgres";
4
+ import { redisStore } from "@tatara/cache/redis";
5
+ import { Redis } from "ioredis";
6
+ import pg from "pg";
7
+ import { env } from "#core/config/env";
8
+ import { logger } from "#core/logging/logger";
9
+
10
+ export type { Cache } from "@tatara/cache";
11
+
12
+ function redis(): CacheStore {
13
+ // connects on first command, so importing this file opens nothing
14
+ const client = new Redis(env.REDIS_URL, { lazyConnect: true, maxRetriesPerRequest: 3 });
15
+ onShutdown("cache", async () => {
16
+ await client.quit();
17
+ });
18
+ return redisStore(client);
19
+ }
20
+
21
+ function postgres(): CacheStore {
22
+ // an unlogged table in the "tatara" schema, created on first use
23
+ const pool = new pg.Pool({ connectionString: env.DATABASE_URL, max: 5 });
24
+ onShutdown("cache", async () => {
25
+ await pool.end();
26
+ });
27
+ return postgresStore(pool);
28
+ }
29
+
30
+ const stores = { redis, postgres, memory: memoryStore };
31
+
32
+ /** The app's cache; pick the store with CACHE_DRIVER. */
33
+ export const cache: Cache = createCache(stores[env.CACHE_DRIVER](), {
34
+ prefix: "__APP_NAME__",
35
+ onError: (error, key) => logger.warn({ err: error, key }, "cache unavailable, computing instead"),
36
+ });
@@ -0,0 +1,10 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { datetime, int, mysqlTable, varchar } from "drizzle-orm/mysql-core";
3
+
4
+ /** Which "once" commands this database has run (written by `deploy`). */
5
+ export const tataraCommandRuns = mysqlTable("tatara_command_runs", {
6
+ name: varchar({ length: 255 }).primaryKey(),
7
+ phase: varchar({ length: 16 }).notNull(),
8
+ ranAt: datetime({ fsp: 3 }).notNull().default(sql`current_timestamp(3)`),
9
+ durationMs: int().notNull(),
10
+ });
@@ -1,2 +1,7 @@
1
1
  // This app uses MySQL through mysql2.
2
- export { dropAllTables, ensureDatabase, truncateAllTables } from "@tatara/db/mysql2";
2
+ export {
3
+ commandLedger,
4
+ dropAllTables,
5
+ ensureDatabase,
6
+ truncateAllTables,
7
+ } from "@tatara/db/mysql2";
@@ -0,0 +1,9 @@
1
+ // Which "once" commands this database has run (written by `deploy`).
2
+ model TataraCommandRun {
3
+ name String @id @db.VarChar(255)
4
+ phase String @db.VarChar(16)
5
+ ranAt DateTime @default(now()) @map("ran_at") @db.DateTime(3)
6
+ durationMs Int @map("duration_ms")
7
+
8
+ @@map("tatara_command_runs")
9
+ }
@@ -1,5 +1,6 @@
1
1
  // This app uses MySQL through the mariadb driver (Prisma adapter).
2
2
  export {
3
+ commandLedger,
3
4
  connectionConfig,
4
5
  dropAllTables,
5
6
  ensureDatabase,
@@ -1,2 +1,7 @@
1
1
  // This app uses PostgreSQL through node-postgres.
2
- export { dropAllTables, ensureDatabase, truncateAllTables } from "@tatara/db/postgres";
2
+ export {
3
+ commandLedger,
4
+ dropAllTables,
5
+ ensureDatabase,
6
+ truncateAllTables,
7
+ } from "@tatara/db/postgres";
@@ -1,13 +1,33 @@
1
- import { createMailer } from "@tatara/email";
1
+ import { createMailer, renderEmail } from "@tatara/email";
2
2
  import { env } from "#core/config/env";
3
3
  import { logger } from "#core/logging/logger";
4
+ import type { emails } from "#generated/emails";
4
5
 
5
6
  export type { EmailMessage } from "@tatara/email";
6
7
 
7
- /** Sends through SMTP (Mailpit locally) or logs it when EMAIL_DRIVER=console. */
8
+ /** Sends through SMTP (Mailpit locally) or Mailgun, or logs it when EMAIL_DRIVER=console. */
8
9
  export const { send: sendEmail } = createMailer({
9
10
  driver: env.EMAIL_DRIVER,
10
11
  smtpUrl: env.SMTP_URL,
12
+ ...(env.MAILGUN_API_KEY && env.MAILGUN_DOMAIN
13
+ ? { mailgun: { apiKey: env.MAILGUN_API_KEY, domain: env.MAILGUN_DOMAIN, apiUrl: env.MAILGUN_API_URL } }
14
+ : {}),
11
15
  from: env.EMAIL_FROM,
12
16
  logger,
13
17
  });
18
+
19
+ type Templates = { [E in (typeof emails)[number] as E["name"]]: Parameters<E["render"]>[0] };
20
+
21
+ /** Renders an `emails/*.ts` template in `language` and sends it. */
22
+ export async function sendTemplate<N extends keyof Templates & string>(
23
+ name: N,
24
+ to: string,
25
+ data: Templates[N],
26
+ language = "en",
27
+ ): Promise<void> {
28
+ // loaded on first use: core doesn't import module code until it's needed
29
+ const { emails: all } = await import("#generated/emails");
30
+ const template = all.find((e) => e.name === name);
31
+ if (!template) throw new Error(`No email template named "${name}"`);
32
+ await sendEmail({ to, ...renderEmail(template, data, language) });
33
+ }
@@ -0,0 +1,11 @@
1
+ import { defineEmail } from "@tatara/email";
2
+ import { z } from "zod";
3
+
4
+ export default defineEmail({
5
+ name: "example.welcome",
6
+ schema: z.object({ name: z.string().min(1) }),
7
+ render: ({ name }, language) =>
8
+ language === "ar"
9
+ ? { subject: "أهلاً بك", text: `أهلاً ${name}` }
10
+ : { subject: "Welcome", text: `Hello ${name}, welcome aboard.` },
11
+ });
@@ -1,8 +1,7 @@
1
1
  import { handleSignals } from "@tatara/core";
2
2
  import { startCron } from "@tatara/jobs";
3
- import { redis } from "#core/jobs/index";
4
3
  import { logger } from "#core/logging/logger";
5
- import { schedules } from "#generated/schedules";
4
+ import { scheduleRunner } from "#apps/cron/runner";
6
5
 
7
- startCron({ schedules, redis, logger });
6
+ startCron({ ...scheduleRunner, logger });
8
7
  handleSignals(logger);
@@ -0,0 +1,11 @@
1
+ import { cache } from "#core/cache/index";
2
+ import { schedules } from "#generated/schedules";
3
+ // tatara:slot imports
4
+
5
+ /** What runs a schedule, wherever it's triggered from: the cron app, `schedule:run`, or HTTP. */
6
+ export const scheduleRunner = {
7
+ schedules,
8
+ // one run per tick across every process and trigger
9
+ lock: cache,
10
+ // tatara:slot options
11
+ };
@@ -1,16 +1,9 @@
1
1
  import { handleSignals } from "@tatara/core";
2
2
  import { startWorker } from "@tatara/jobs";
3
3
  import { env } from "#core/config/env";
4
- import { QUEUE_NAME, redis } from "#core/jobs/index";
4
+ import { jobs } from "#core/jobs/index";
5
5
  import { logger } from "#core/logging/logger";
6
6
  import { consumers } from "#generated/consumers";
7
7
 
8
- startWorker({
9
- queue: QUEUE_NAME,
10
- prefix: env.JOBS_PREFIX,
11
- redis: redis(),
12
- concurrency: env.WORKER_CONCURRENCY,
13
- consumers,
14
- logger,
15
- });
8
+ await startWorker({ driver: jobs.driver, consumers, concurrency: env.WORKER_CONCURRENCY, logger });
16
9
  handleSignals(logger);
@@ -1,10 +1,41 @@
1
- import { createQueue, lazyRedis } from "@tatara/jobs";
1
+ import { createJobs, inlineDriver, type JobDriver, lazyRedis, type TopicsOf } from "@tatara/jobs";
2
+ import { bullmqDriver } from "@tatara/jobs/bullmq";
3
+ import { kafkaDriver } from "@tatara/jobs/kafka";
4
+ import { Kafka } from "kafkajs";
2
5
  import { env } from "#core/config/env";
6
+ import { logger } from "#core/logging/logger";
7
+ import type { consumers } from "#generated/consumers";
3
8
 
4
- export const QUEUE_NAME = "__APP_NAME__-jobs";
5
-
6
- /** The shared Redis connection, opened on first use. */
9
+ /** The shared Redis connection for BullMQ, opened on first use. */
7
10
  export const redis = lazyRedis(env.REDIS_URL);
8
11
 
9
- /** `enqueue(topic, data)`: the consumer with that topic validates the payload. */
10
- export const { enqueue } = createQueue({ name: QUEUE_NAME, prefix: env.JOBS_PREFIX, redis });
12
+ function kafka(): JobDriver {
13
+ if (!env.KAFKA_BROKERS) throw new Error("JOBS_DRIVER=kafka needs KAFKA_BROKERS");
14
+ const client = new Kafka({
15
+ clientId: env.KAFKA_CLIENT_ID,
16
+ brokers: env.KAFKA_BROKERS.split(","),
17
+ ssl: env.KAFKA_SSL,
18
+ ...(env.KAFKA_SASL_USERNAME && env.KAFKA_SASL_PASSWORD
19
+ ? {
20
+ sasl: {
21
+ mechanism: "scram-sha-512",
22
+ username: env.KAFKA_SASL_USERNAME,
23
+ password: env.KAFKA_SASL_PASSWORD,
24
+ },
25
+ }
26
+ : {}),
27
+ });
28
+ return kafkaDriver({ kafka: client, topic: "__APP_NAME__.jobs", groupId: env.KAFKA_CLIENT_ID });
29
+ }
30
+
31
+ const drivers: Record<typeof env.JOBS_DRIVER, () => JobDriver> = {
32
+ bullmq: () => bullmqDriver({ name: "__APP_NAME__-jobs", prefix: env.JOBS_PREFIX, redis }),
33
+ kafka,
34
+ // jobs run where they're sent, with no worker: tests and quick local work
35
+ inline: () =>
36
+ inlineDriver({ consumers: async () => (await import("#generated/consumers")).consumers, logger }),
37
+ };
38
+
39
+ /** `jobs.enqueue(topic, data, { orgId })`, typed by the app's consumers; pick the driver with JOBS_DRIVER. */
40
+ export const jobs = createJobs<TopicsOf<typeof consumers>>({ driver: drivers[env.JOBS_DRIVER]() });
41
+ export const { enqueue } = jobs;
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { processJob, withLock } from "@tatara/jobs";
3
- import { redis } from "#core/jobs/index";
2
+ import { type InlineDriver, processJob } from "@tatara/jobs";
3
+ import { enqueue, jobs } from "#core/jobs/index";
4
4
  import { logger } from "#core/logging/logger";
5
5
  import { consumers } from "#generated/consumers";
6
6
 
@@ -17,13 +17,10 @@ describe("jobs", () => {
17
17
  );
18
18
  });
19
19
 
20
- it("lets only one holder run a locked section", async () => {
21
- const key = `test-${Date.now()}-${Math.random()}`;
22
- let inner: boolean | undefined;
23
- const outer = await withLock(redis(), key, 5000, async () => {
24
- inner = await withLock(redis(), key, 5000, async () => {});
25
- });
26
- expect(outer).toBe(true);
27
- expect(inner).toBe(false);
20
+ it("runs an enqueued job in tests (JOBS_DRIVER=inline)", async () => {
21
+ await enqueue("example.send-welcome", { name: "Ada" });
22
+ const driver = jobs.driver as InlineDriver;
23
+ await driver.drain();
24
+ expect(driver.dead).toEqual([]);
28
25
  });
29
26
  });
@@ -0,0 +1,23 @@
1
+ import { type SQL, sql } from "drizzle-orm";
2
+ import type { Queryable } from "@tatara/jobs/postgres";
3
+ import { type Executor, pool } from "#core/db/client";
4
+
5
+ /** The app's own pool: the Postgres driver needs no connection of its own. */
6
+ export const outboxPool: Queryable = pool;
7
+
8
+ /**
9
+ * `enqueue(topic, data, { executor: inTransaction(tx) })`: with JOBS_DRIVER=postgres the job is
10
+ * written in `tx`, so it exists exactly when the transaction commits.
11
+ */
12
+ export function inTransaction(executor: Executor): Queryable {
13
+ return {
14
+ query(text, values = []) {
15
+ // $1, $2… become drizzle parameters
16
+ const parts = text.split(/\$(\d+)/);
17
+ let query: SQL = sql.raw(parts[0] ?? "");
18
+ for (let i = 1; i < parts.length; i += 2)
19
+ query = sql`${query}${values[Number(parts[i]) - 1]}${sql.raw(parts[i + 1] ?? "")}`;
20
+ return executor.execute(query);
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,34 @@
1
+ import { createJobs, type TopicsOf } from "@tatara/jobs";
2
+ import { postgresDriver } from "@tatara/jobs/postgres";
3
+ import { describe, expect, it } from "vitest";
4
+ import { db } from "#core/db/client";
5
+ import { inTransaction, outboxPool } from "#core/jobs/outbox";
6
+ import type { consumers } from "#generated/consumers";
7
+
8
+ const jobs = createJobs<TopicsOf<typeof consumers>>({ driver: postgresDriver({ db: outboxPool }) });
9
+
10
+ async function stored(id: string): Promise<number> {
11
+ const result = (await outboxPool.query("select count(*)::int as n from tatara.jobs where id = $1", [id])) as {
12
+ rows: { n: number }[];
13
+ };
14
+ return result.rows[0]?.n ?? 0;
15
+ }
16
+
17
+ describe("the Postgres job outbox", () => {
18
+ it("stores a job with the caller's transaction, and drops it on rollback", async () => {
19
+ let rolledBack = "";
20
+ await expect(
21
+ db.transaction(async (tx) => {
22
+ rolledBack = await jobs.enqueue("example.send-welcome", { name: "Ada" }, { executor: inTransaction(tx) });
23
+ throw new Error("rollback");
24
+ }),
25
+ ).rejects.toThrow("rollback");
26
+ expect(await stored(rolledBack)).toBe(0);
27
+
28
+ const committed = await db.transaction(async (tx) =>
29
+ jobs.enqueue("example.send-welcome", { name: "Ada" }, { executor: inTransaction(tx) }),
30
+ );
31
+ expect(await stored(committed)).toBe(1);
32
+ await outboxPool.query("delete from tatara.jobs where id = $1", [committed]);
33
+ });
34
+ });
@@ -0,0 +1,47 @@
1
+ import { createJobs, inlineDriver, type JobDriver, lazyRedis, type TopicsOf } from "@tatara/jobs";
2
+ import { bullmqDriver } from "@tatara/jobs/bullmq";
3
+ import { kafkaDriver } from "@tatara/jobs/kafka";
4
+ import { postgresDriver } from "@tatara/jobs/postgres";
5
+ import { Kafka } from "kafkajs";
6
+ import { env } from "#core/config/env";
7
+ import { outboxPool } from "#core/jobs/outbox";
8
+ import { logger } from "#core/logging/logger";
9
+ import type { consumers } from "#generated/consumers";
10
+
11
+ export { inTransaction } from "#core/jobs/outbox";
12
+
13
+ /** The shared Redis connection for BullMQ, opened on first use. */
14
+ export const redis = lazyRedis(env.REDIS_URL);
15
+
16
+ function kafka(): JobDriver {
17
+ if (!env.KAFKA_BROKERS) throw new Error("JOBS_DRIVER=kafka needs KAFKA_BROKERS");
18
+ const client = new Kafka({
19
+ clientId: env.KAFKA_CLIENT_ID,
20
+ brokers: env.KAFKA_BROKERS.split(","),
21
+ ssl: env.KAFKA_SSL,
22
+ ...(env.KAFKA_SASL_USERNAME && env.KAFKA_SASL_PASSWORD
23
+ ? {
24
+ sasl: {
25
+ mechanism: "scram-sha-512",
26
+ username: env.KAFKA_SASL_USERNAME,
27
+ password: env.KAFKA_SASL_PASSWORD,
28
+ },
29
+ }
30
+ : {}),
31
+ });
32
+ return kafkaDriver({ kafka: client, topic: "__APP_NAME__.jobs", groupId: env.KAFKA_CLIENT_ID });
33
+ }
34
+
35
+ const drivers: Record<typeof env.JOBS_DRIVER, () => JobDriver> = {
36
+ bullmq: () => bullmqDriver({ name: "__APP_NAME__-jobs", prefix: env.JOBS_PREFIX, redis }),
37
+ // a "tatara.jobs" table, claimed with SKIP LOCKED; enqueue with { executor: inTransaction(tx) }
38
+ postgres: () => postgresDriver({ db: outboxPool }),
39
+ kafka,
40
+ // jobs run where they're sent, with no worker: tests and quick local work
41
+ inline: () =>
42
+ inlineDriver({ consumers: async () => (await import("#generated/consumers")).consumers, logger }),
43
+ };
44
+
45
+ /** `jobs.enqueue(topic, data, { orgId })`, typed by the app's consumers; pick the driver with JOBS_DRIVER. */
46
+ export const jobs = createJobs<TopicsOf<typeof consumers>>({ driver: drivers[env.JOBS_DRIVER]() });
47
+ export const { enqueue } = jobs;