@tatara/cli 0.5.0 → 0.7.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/dist/bin.mjs +370 -30
  2. package/package.json +1 -1
  3. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
  4. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
  5. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
  6. package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
  7. package/templates/features/authz/files/src/core/authz/index.ts +9 -0
  8. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
  9. package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
  10. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
  11. package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
  12. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
  13. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
  14. package/templates/features/email/files/src/core/email/send.ts +8 -17
  15. package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
  16. package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
  17. package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
  18. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
  19. package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
  20. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
  21. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
  22. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
  23. package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
  24. package/templates/features/storage/files/src/core/storage/index.ts +19 -5
  25. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
  26. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
  27. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
  28. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  29. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  30. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  31. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
  32. package/templates/versions.json +6 -1
  33. package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
  34. package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
  35. package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
  36. package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
  37. package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
  38. package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
  39. package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
  40. package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
  41. package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
  42. package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
  43. package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
  44. package/templates/features/authz/files/src/core/authz/types.ts +0 -87
  45. package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
  46. package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
  47. package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
  48. package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
  49. package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
  50. package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
  51. package/templates/features/storage/files/src/core/storage/local.ts +0 -34
  52. package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
  53. package/templates/features/storage/files/src/core/storage/storage.ts +0 -21
@@ -1,281 +1,10 @@
1
- import { createRoute, z } from "@hono/zod-openapi";
2
- import { currentPrincipal, requireAuth } from "@tatara/auth";
3
- import { requirePermission } from "#core/authz/middleware";
4
- import { effectivePermissions, orgOf } from "#core/authz/resolver";
5
- import { listAudit } from "#core/authz/services/audit";
6
- import { addOverlay, assignRole, getMember, removeOverlay, revokeRole } from "#core/authz/services/members";
7
- import { createRole, deleteRole, listRoles, updateRole } from "#core/authz/services/roles";
8
- import type { OverlayRow } from "#core/authz/types";
9
- import {
10
- auditEntrySchema,
11
- createRoleSchema,
12
- idParams,
13
- memberSchema,
14
- myPermissionsSchema,
15
- overlayInputSchema,
16
- roleSchema,
17
- updateRoleSchema,
18
- userOverlayParams,
19
- userParams,
20
- userRoleParams,
21
- } from "#core/authz/validators";
22
- import { errorResponses, jsonBody, jsonContent, success, successSchema } from "@tatara/http";
23
- import { createRouter, defineRoutes } from "#core/http/routes";
24
- import { catalog } from "#generated/permissions";
25
-
26
- const router = createRouter();
27
- const tags = ["Authorization"];
28
- const toOverlay = (o: OverlayRow) => ({
29
- id: o.id,
30
- resource: o.resource,
31
- action: o.action,
32
- scope: o.scope,
33
- effect: o.effect,
34
- reason: o.reason,
35
- expiresAt: o.expiresAt ? o.expiresAt.toISOString() : null,
1
+ import { authorizationRoutes } from "@tatara/authz";
2
+ import { authz } from "#core/authz/index";
3
+ import type { AppEnv } from "#core/http/context";
4
+ import { defineRoutes } from "#core/http/routes";
5
+
6
+ export default defineRoutes({
7
+ app: "api",
8
+ basePath: "/authorization",
9
+ router: authorizationRoutes<AppEnv>(authz),
36
10
  });
37
-
38
- router.openapi(
39
- createRoute({
40
- method: "get",
41
- path: "/catalog",
42
- tags,
43
- summary: "Every permission the API knows",
44
- middleware: [requireAuth()] as const,
45
- security: [{ bearerAuth: [] }],
46
- responses: {
47
- 200: jsonContent(
48
- successSchema(z.object({ keys: z.array(z.string()), resources: z.record(z.string(), z.unknown()) })),
49
- "Permission catalog",
50
- ),
51
- ...errorResponses(401),
52
- },
53
- }),
54
- (c) => c.json(success({ keys: catalog.keys, resources: catalog.resources }), 200),
55
- );
56
-
57
- router.openapi(
58
- createRoute({
59
- method: "get",
60
- path: "/me",
61
- tags,
62
- summary: "The caller's effective permissions in their org",
63
- middleware: [requireAuth()] as const,
64
- security: [{ bearerAuth: [] }],
65
- responses: { 200: jsonContent(successSchema(myPermissionsSchema), "Effective permissions"), ...errorResponses(401) },
66
- }),
67
- async (c) => {
68
- const principal = currentPrincipal(c);
69
- const permissions = await effectivePermissions(principal);
70
- return c.json(
71
- success({
72
- orgId: orgOf(principal),
73
- roles: permissions.roles,
74
- grants: permissions.grants,
75
- denies: [...permissions.denies],
76
- }),
77
- 200,
78
- );
79
- },
80
- );
81
-
82
- router.openapi(
83
- createRoute({
84
- method: "get",
85
- path: "/roles",
86
- tags,
87
- summary: "Built-in roles and the org's custom roles",
88
- middleware: [requirePermission("role:read")] as const,
89
- security: [{ bearerAuth: ["role:read"] }],
90
- responses: { 200: jsonContent(successSchema(z.array(roleSchema)), "Roles"), ...errorResponses(401, 403) },
91
- }),
92
- async (c) => c.json(success(await listRoles(orgOf(currentPrincipal(c)))), 200),
93
- );
94
-
95
- router.openapi(
96
- createRoute({
97
- method: "post",
98
- path: "/roles",
99
- tags,
100
- summary: "Create a custom role",
101
- middleware: [requirePermission("role:create")] as const,
102
- security: [{ bearerAuth: ["role:create"] }],
103
- request: { body: jsonBody(createRoleSchema, "The role") },
104
- responses: {
105
- 201: jsonContent(successSchema(roleSchema), "Created"),
106
- ...errorResponses(400, 409),
107
- ...errorResponses(401, 403),
108
- },
109
- }),
110
- async (c) => {
111
- const principal = currentPrincipal(c);
112
- const role = await createRole(principal.userId, orgOf(principal), c.req.valid("json"));
113
- return c.json(success(role), 201);
114
- },
115
- );
116
-
117
- router.openapi(
118
- createRoute({
119
- method: "patch",
120
- path: "/roles/{id}",
121
- tags,
122
- summary: "Update a custom role",
123
- middleware: [requirePermission("role:update")] as const,
124
- security: [{ bearerAuth: ["role:update"] }],
125
- request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
126
- responses: {
127
- 200: jsonContent(successSchema(roleSchema), "Updated"),
128
- ...errorResponses(400, 404),
129
- ...errorResponses(401, 403),
130
- },
131
- }),
132
- async (c) => {
133
- const principal = currentPrincipal(c);
134
- const role = await updateRole(principal.userId, orgOf(principal), c.req.valid("param").id, c.req.valid("json"));
135
- return c.json(success(role), 200);
136
- },
137
- );
138
-
139
- router.openapi(
140
- createRoute({
141
- method: "delete",
142
- path: "/roles/{id}",
143
- tags,
144
- summary: "Delete a custom role that no member holds",
145
- middleware: [requirePermission("role:delete")] as const,
146
- security: [{ bearerAuth: ["role:delete"] }],
147
- request: { params: idParams },
148
- responses: { 204: { description: "Deleted" }, ...errorResponses(400, 404, 409), ...errorResponses(401, 403) },
149
- }),
150
- async (c) => {
151
- const principal = currentPrincipal(c);
152
- await deleteRole(principal.userId, orgOf(principal), c.req.valid("param").id);
153
- return c.body(null, 204);
154
- },
155
- );
156
-
157
- router.openapi(
158
- createRoute({
159
- method: "get",
160
- path: "/members/{userId}",
161
- tags,
162
- summary: "A member's roles and overlays",
163
- middleware: [requirePermission("member:read")] as const,
164
- security: [{ bearerAuth: ["member:read"] }],
165
- request: { params: userParams },
166
- responses: { 200: jsonContent(successSchema(memberSchema), "Member"), ...errorResponses(401, 403) },
167
- }),
168
- async (c) => {
169
- const member = await getMember(orgOf(currentPrincipal(c)), c.req.valid("param").userId);
170
- return c.json(success({ ...member, overlays: member.overlays.map(toOverlay) }), 200);
171
- },
172
- );
173
-
174
- router.openapi(
175
- createRoute({
176
- method: "post",
177
- path: "/members/{userId}/roles",
178
- tags,
179
- summary: "Assign a role to a member",
180
- middleware: [requirePermission("member:update")] as const,
181
- security: [{ bearerAuth: ["member:update"] }],
182
- request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
183
- responses: {
184
- 200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
185
- ...errorResponses(404),
186
- ...errorResponses(401, 403),
187
- },
188
- }),
189
- async (c) => {
190
- const principal = currentPrincipal(c);
191
- const assigned = await assignRole(
192
- principal.userId,
193
- orgOf(principal),
194
- c.req.valid("param").userId,
195
- c.req.valid("json").roleId,
196
- );
197
- return c.json(success({ assigned }), 200);
198
- },
199
- );
200
-
201
- router.openapi(
202
- createRoute({
203
- method: "delete",
204
- path: "/members/{userId}/roles/{roleId}",
205
- tags,
206
- summary: "Revoke a role from a member",
207
- middleware: [requirePermission("member:update")] as const,
208
- security: [{ bearerAuth: ["member:update"] }],
209
- request: { params: userRoleParams },
210
- responses: { 204: { description: "Revoked" }, ...errorResponses(404), ...errorResponses(401, 403) },
211
- }),
212
- async (c) => {
213
- const principal = currentPrincipal(c);
214
- const { userId, roleId } = c.req.valid("param");
215
- await revokeRole(principal.userId, orgOf(principal), userId, roleId);
216
- return c.body(null, 204);
217
- },
218
- );
219
-
220
- router.openapi(
221
- createRoute({
222
- method: "post",
223
- path: "/members/{userId}/overlays",
224
- tags,
225
- summary: "Grant or deny one permission to a member",
226
- middleware: [requirePermission("member:update")] as const,
227
- security: [{ bearerAuth: ["member:update"] }],
228
- request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
229
- responses: {
230
- 201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
231
- ...errorResponses(400),
232
- ...errorResponses(401, 403),
233
- },
234
- }),
235
- async (c) => {
236
- const principal = currentPrincipal(c);
237
- const body = c.req.valid("json");
238
- const id = await addOverlay(principal.userId, orgOf(principal), c.req.valid("param").userId, {
239
- ...body,
240
- expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
241
- });
242
- return c.json(success({ id }), 201);
243
- },
244
- );
245
-
246
- router.openapi(
247
- createRoute({
248
- method: "delete",
249
- path: "/members/{userId}/overlays/{overlayId}",
250
- tags,
251
- summary: "Remove an overlay",
252
- middleware: [requirePermission("member:update")] as const,
253
- security: [{ bearerAuth: ["member:update"] }],
254
- request: { params: userOverlayParams },
255
- responses: { 204: { description: "Removed" }, ...errorResponses(404), ...errorResponses(401, 403) },
256
- }),
257
- async (c) => {
258
- const principal = currentPrincipal(c);
259
- const { userId, overlayId } = c.req.valid("param");
260
- await removeOverlay(principal.userId, orgOf(principal), userId, overlayId);
261
- return c.body(null, 204);
262
- },
263
- );
264
-
265
- router.openapi(
266
- createRoute({
267
- method: "get",
268
- path: "/audit",
269
- tags,
270
- summary: "Recent authorization changes in the org",
271
- middleware: [requirePermission("audit_log:read")] as const,
272
- security: [{ bearerAuth: ["audit_log:read"] }],
273
- responses: { 200: jsonContent(successSchema(z.array(auditEntrySchema)), "Audit log"), ...errorResponses(401, 403) },
274
- }),
275
- async (c) => {
276
- const rows = await listAudit(orgOf(currentPrincipal(c)));
277
- return c.json(success(rows.map((r) => ({ ...r, at: r.at.toISOString() }))), 200);
278
- },
279
- );
280
-
281
- export default defineRoutes({ app: "api", basePath: "/authorization", router });
@@ -5,5 +5,3 @@ export const SYSTEM_ROLES = [
5
5
  ] as const;
6
6
 
7
7
  export type SystemRoleKey = (typeof SYSTEM_ROLES)[number]["key"];
8
-
9
- export const SYSTEM_ROLE_KEYS: ReadonlySet<string> = new Set(SYSTEM_ROLES.map((r) => r.key));
@@ -1,6 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { allows } from "#core/authz/resolver";
3
- import type { EffectivePermissions } from "#core/authz/types";
2
+ import { allows, type EffectivePermissions } from "@tatara/authz";
4
3
  import { api } from "#tests/helpers/api";
5
4
  import { asUser, authenticate } from "#tests/helpers/auth";
6
5
  import { applyTestAccess } from "#tests/helpers/authz";
@@ -1,7 +1,5 @@
1
- import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "#core/authz/constants";
2
- import { store } from "#core/authz/db/store";
3
- import { addOverlay, assignRole } from "#core/authz/services/members";
4
- import { syncSystemRoles } from "#core/authz/services/sync";
1
+ import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
2
+ import { authz } from "#core/authz/index";
5
3
  import type { SystemRoleKey } from "#core/authz/system-roles";
6
4
  import type { PermissionKey } from "#generated/permissions";
7
5
 
@@ -17,14 +15,14 @@ export async function applyTestAccess(userId: string, access: TestAccess): Promi
17
15
  const orgId = access.orgId ?? DEFAULT_ORG_ID;
18
16
  for (const key of access.permissions ?? []) {
19
17
  const [resource = "", action = ""] = key.split(":");
20
- await addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
18
+ await authz.addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
21
19
  }
22
20
  if (access.systemRoles?.length) {
23
- await syncSystemRoles();
21
+ await authz.syncSystemRoles();
24
22
  for (const roleKey of access.systemRoles) {
25
- const role = await store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
23
+ const role = await authz.store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
26
24
  if (!role) throw new Error(`Unknown built-in role ${roleKey}`);
27
- await assignRole(null, orgId, userId, role.id);
25
+ await authz.assignRole(null, orgId, userId, role.id);
28
26
  }
29
27
  }
30
28
  }
@@ -1,6 +1,6 @@
1
1
  import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
2
- import type { OverlayEffect, Scope } from "#core/authz/constants";
3
- import { SYSTEM_ORG_ID } from "#core/authz/constants";
2
+ import type { OverlayEffect, Scope } from "@tatara/authz";
3
+ import { SYSTEM_ORG_ID } from "@tatara/authz";
4
4
  import {
5
5
  authzAuditLog,
6
6
  authzMemberRoles,
@@ -8,7 +8,7 @@ import {
8
8
  authzRolePermissions,
9
9
  authzRoles,
10
10
  } from "#core/authz/db/authz.schema";
11
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
11
+ import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
12
12
  import { db } from "#core/db/client";
13
13
  import { newId } from "@tatara/db";
14
14
 
@@ -1,6 +1,6 @@
1
- import type { OverlayEffect, Scope } from "#core/authz/constants";
2
- import { SYSTEM_ORG_ID } from "#core/authz/constants";
3
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
1
+ import type { OverlayEffect, Scope } from "@tatara/authz";
2
+ import { SYSTEM_ORG_ID } from "@tatara/authz";
3
+ import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
4
4
  import { db } from "#core/db/client";
5
5
  import { newId } from "@tatara/db";
6
6
 
@@ -1,22 +1,13 @@
1
- import { createTransport, type Transporter } from "nodemailer";
1
+ import { createMailer } from "@tatara/email";
2
2
  import { env } from "#core/config/env";
3
3
  import { logger } from "#core/logging/logger";
4
4
 
5
- export interface EmailMessage {
6
- to: string;
7
- subject: string;
8
- text: string;
9
- html?: string;
10
- }
11
-
12
- let transport: Transporter | undefined;
5
+ export type { EmailMessage } from "@tatara/email";
13
6
 
14
7
  /** Sends through SMTP (Mailpit locally) or logs it when EMAIL_DRIVER=console. */
15
- export async function sendEmail(message: EmailMessage): Promise<void> {
16
- if (env.EMAIL_DRIVER === "console") {
17
- logger.info({ to: message.to, subject: message.subject }, "email (console driver)");
18
- return;
19
- }
20
- transport ??= createTransport(env.SMTP_URL);
21
- await transport.sendMail({ from: env.EMAIL_FROM, ...message });
22
- }
8
+ export const { send: sendEmail } = createMailer({
9
+ driver: env.EMAIL_DRIVER,
10
+ smtpUrl: env.SMTP_URL,
11
+ from: env.EMAIL_FROM,
12
+ logger,
13
+ });
@@ -1,41 +1,11 @@
1
- import { createMiddleware } from "hono/factory";
1
+ import { createI18n } from "@tatara/i18n";
2
2
  import { env } from "#core/config/env";
3
- import type { AppEnv } from "#core/http/context";
4
3
  import ar from "#locales/ar" with { type: "json" };
5
4
  import en from "#locales/en" with { type: "json" };
6
5
 
7
- type Dictionary = { [key: string]: string | Dictionary };
6
+ const i18n = createI18n({ dictionaries: { en, ar }, defaultLanguage: env.DEFAULT_LANGUAGE });
8
7
 
9
- const dictionaries: Record<string, Dictionary> = { en, ar };
8
+ export const SUPPORTED_LANGUAGES = i18n.languages;
10
9
 
11
- export const SUPPORTED_LANGUAGES = Object.keys(dictionaries);
12
-
13
- function lookup(dict: Dictionary | undefined, key: string): string | undefined {
14
- let node: string | Dictionary | undefined = dict;
15
- for (const part of key.split(".")) {
16
- if (!node || typeof node === "string") return undefined;
17
- node = node[part];
18
- }
19
- return typeof node === "string" ? node : undefined;
20
- }
21
-
22
- /** Translates `key` ("errors.NOT_FOUND"), falling back to the default language, then `fallback`. */
23
- export function t(language: string, key: string, fallback = key, vars: Record<string, string> = {}): string {
24
- const text = lookup(dictionaries[language], key) ?? lookup(dictionaries[env.DEFAULT_LANGUAGE], key) ?? fallback;
25
- return text.replace(/\{(\w+)\}/g, (_, name: string) => vars[name] ?? `{${name}}`);
26
- }
27
-
28
- /** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language`. */
29
- export const detectLanguage = createMiddleware<AppEnv>(async (c, next) => {
30
- const candidates = [
31
- c.req.query("lang"),
32
- /(?:^|;\s*)lang=([^;]+)/.exec(c.req.header("cookie") ?? "")?.[1],
33
- ...(c.req.header("accept-language") ?? "")
34
- .split(",")
35
- .map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]),
36
- ];
37
- const language = candidates.find((l) => l && SUPPORTED_LANGUAGES.includes(l)) ?? env.DEFAULT_LANGUAGE;
38
- c.set("language", language);
39
- c.header("content-language", language);
40
- await next();
41
- });
10
+ /** `t(language, "errors.NOT_FOUND")`, and the middleware that sets `c.var.language`. */
11
+ export const { t, detectLanguage } = i18n;
@@ -1,25 +1,8 @@
1
- import { Cron } from "croner";
2
- import { withLock } from "#core/jobs/lock";
1
+ import { handleSignals } from "@tatara/core";
2
+ import { startCron } from "@tatara/jobs";
3
+ import { redis } from "#core/jobs/index";
3
4
  import { logger } from "#core/logging/logger";
4
- import { handleSignals, onShutdown } from "@tatara/core";
5
5
  import { schedules } from "#generated/schedules";
6
6
 
7
- const jobs = schedules.map((schedule) => {
8
- const log = logger.child({ schedule: schedule.name });
9
- return new Cron(schedule.cron, { timezone: "UTC", protect: true, name: schedule.name }, async (self) => {
10
- const tick = self.currentRun()?.toISOString() ?? new Date().toISOString();
11
- const ran = await withLock(`schedule:${schedule.name}:${tick}`, schedule.lockTtlMs ?? 60_000, () =>
12
- schedule.run({ logger: log }),
13
- ).catch((error: unknown) => {
14
- log.error({ err: error }, "schedule failed");
15
- return true;
16
- });
17
- if (!ran) log.debug("skipped: another process holds this tick");
18
- });
19
- });
20
-
21
- onShutdown("cron", () => {
22
- for (const job of jobs) job.stop();
23
- });
7
+ startCron({ schedules, redis, logger });
24
8
  handleSignals(logger);
25
- logger.info({ schedules: schedules.map((s) => `${s.name} (${s.cron})`) }, "cron started");
@@ -1,24 +1,16 @@
1
- import { Worker } from "bullmq";
1
+ import { handleSignals } from "@tatara/core";
2
+ import { startWorker } from "@tatara/jobs";
2
3
  import { env } from "#core/config/env";
3
- import { processJob } from "#core/jobs/process";
4
- import { QUEUE_NAME } from "#core/jobs/queue";
5
- import { redis } from "#core/jobs/redis";
4
+ import { QUEUE_NAME, redis } from "#core/jobs/index";
6
5
  import { logger } from "#core/logging/logger";
7
- import { handleSignals, onShutdown } from "@tatara/core";
8
6
  import { consumers } from "#generated/consumers";
9
7
 
10
- const worker = new Worker(
11
- QUEUE_NAME,
12
- (job) =>
13
- processJob(consumers, job.name, job.data, {
14
- logger: logger.child({ jobId: job.id, topic: job.name }),
15
- jobId: job.id ?? "",
16
- attempt: job.attemptsMade + 1,
17
- }),
18
- { connection: redis(), prefix: env.JOBS_PREFIX, concurrency: env.WORKER_CONCURRENCY },
19
- );
20
-
21
- worker.on("failed", (job, err) => logger.error({ err, jobId: job?.id, topic: job?.name }, "job failed"));
22
- onShutdown("worker", () => worker.close());
8
+ startWorker({
9
+ queue: QUEUE_NAME,
10
+ prefix: env.JOBS_PREFIX,
11
+ redis: redis(),
12
+ concurrency: env.WORKER_CONCURRENCY,
13
+ consumers,
14
+ logger,
15
+ });
23
16
  handleSignals(logger);
24
- logger.info({ topics: consumers.map((c) => c.topic) }, "worker started");
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { defineCommand } from "@tatara/core";
3
- import { enqueue } from "#core/jobs/queue";
3
+ import { enqueue } from "#core/jobs/index";
4
4
 
5
5
  export default defineCommand({
6
6
  name: "jobs:enqueue",
@@ -0,0 +1,10 @@
1
+ import { createQueue, lazyRedis } from "@tatara/jobs";
2
+ import { env } from "#core/config/env";
3
+
4
+ export const QUEUE_NAME = "__APP_NAME__-jobs";
5
+
6
+ /** The shared Redis connection, opened on first use. */
7
+ export const redis = lazyRedis(env.REDIS_URL);
8
+
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 });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineConsumer } from "#core/jobs/define";
2
+ import { defineConsumer } from "@tatara/jobs";
3
3
 
4
4
  export default defineConsumer({
5
5
  topic: "example.send-welcome",
@@ -1,4 +1,4 @@
1
- import { defineSchedule } from "#core/jobs/define";
1
+ import { defineSchedule } from "@tatara/jobs";
2
2
 
3
3
  export default defineSchedule({
4
4
  name: "example.heartbeat",
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { withLock } from "#core/jobs/lock";
3
- import { processJob } from "#core/jobs/process";
2
+ import { processJob, withLock } from "@tatara/jobs";
3
+ import { redis } from "#core/jobs/index";
4
4
  import { logger } from "#core/logging/logger";
5
5
  import { consumers } from "#generated/consumers";
6
6
 
@@ -20,8 +20,8 @@ describe("jobs", () => {
20
20
  it("lets only one holder run a locked section", async () => {
21
21
  const key = `test-${Date.now()}-${Math.random()}`;
22
22
  let inner: boolean | undefined;
23
- const outer = await withLock(key, 5000, async () => {
24
- inner = await withLock(key, 5000, async () => {});
23
+ const outer = await withLock(redis(), key, 5000, async () => {
24
+ inner = await withLock(redis(), key, 5000, async () => {});
25
25
  });
26
26
  expect(outer).toBe(true);
27
27
  expect(inner).toBe(false);
@@ -1,13 +1,13 @@
1
1
  import { defineCommand } from "@tatara/core";
2
2
  import { env } from "#core/config/env";
3
- import { storage } from "#core/storage/index";
4
- import { ensureBucket } from "#core/storage/s3";
3
+ import { ensureBucket } from "@tatara/storage/s3";
4
+ import { s3, storage } from "#core/storage/index";
5
5
 
6
6
  export default defineCommand({
7
7
  name: "storage:check",
8
8
  description: "Create the bucket if needed, then write, read and delete a probe object",
9
9
  async run(_args, { logger }) {
10
- if (env.STORAGE_DRIVER === "s3" && (await ensureBucket())) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
10
+ if (env.STORAGE_DRIVER === "s3" && (await ensureBucket(s3, env.S3_BUCKET))) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
11
11
  const key = `tatara-check/${Date.now()}.txt`;
12
12
  await storage.put(key, "ok", "text/plain");
13
13
  const body = await storage.get(key);
@@ -1,8 +1,22 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import { localStorage, type Storage } from "@tatara/storage";
3
+ import { s3Storage } from "@tatara/storage/s3";
1
4
  import { env } from "#core/config/env";
2
- import { localStorage } from "#core/storage/local";
3
- import { s3Storage } from "#core/storage/s3";
4
- import type { Storage } from "#core/storage/storage";
5
5
 
6
- export type { Storage } from "#core/storage/storage";
6
+ export type { Storage } from "@tatara/storage";
7
7
 
8
- export const storage: Storage = env.STORAGE_DRIVER === "s3" ? s3Storage() : localStorage();
8
+ export const s3 = new S3Client({
9
+ region: env.S3_REGION,
10
+ ...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
11
+ forcePathStyle: env.S3_FORCE_PATH_STYLE,
12
+ ...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
13
+ ? { credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY } }
14
+ : {}),
15
+ });
16
+
17
+ /** Object storage for modules; pick the driver with STORAGE_DRIVER. */
18
+ export const storage: Storage =
19
+ env.STORAGE_DRIVER === "s3"
20
+ ? // local development: the bucket is created on first write
21
+ s3Storage({ client: s3, bucket: env.S3_BUCKET, createBucket: env.NODE_ENV === "development" })
22
+ : localStorage(env.STORAGE_LOCAL_DIR);
@@ -2,7 +2,7 @@ import { mkdtemp } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
- import { localStorage } from "#core/storage/local";
5
+ import { localStorage } from "@tatara/storage";
6
6
 
7
7
  describe("local storage", () => {
8
8
  it("round-trips an object", async () => {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineConsumer } from "#core/jobs/define";
2
+ import { defineConsumer } from "@tatara/jobs";
3
3
 
4
4
  export const __nameCamel__Payload = z.object({ id: z.string().min(1) });
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { processJob } from "#core/jobs/process";
2
+ import { processJob } from "@tatara/jobs";
3
3
  import { logger } from "#core/logging/logger";
4
4
  import { consumers } from "#generated/consumers";
5
5
 
@@ -1,4 +1,4 @@
1
- import { definePermissions } from "#core/authz/catalog";
1
+ import { definePermissions } from "@tatara/authz";
2
2
 
3
3
  export default definePermissions({
4
4
  resources: {
@@ -1,4 +1,4 @@
1
- import { definePermissions } from "#core/authz/catalog";
1
+ import { definePermissions } from "@tatara/authz";
2
2
 
3
3
  export default definePermissions({
4
4
  resources: {