@tatara/cli 0.6.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 (26) hide show
  1. package/dist/bin.mjs +171 -7
  2. package/package.json +1 -1
  3. package/templates/features/email/files/src/core/email/send.ts +8 -17
  4. package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
  5. package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
  6. package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
  7. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
  8. package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
  9. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
  10. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
  11. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
  12. package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
  13. package/templates/features/storage/files/src/core/storage/index.ts +19 -5
  14. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
  15. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
  16. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
  17. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
  18. package/templates/versions.json +5 -1
  19. package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
  20. package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
  21. package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
  22. package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
  23. package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
  24. package/templates/features/storage/files/src/core/storage/local.ts +0 -34
  25. package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
  26. package/templates/features/storage/files/src/core/storage/storage.ts +0 -21
package/dist/bin.mjs CHANGED
@@ -257,14 +257,14 @@ async function sync(project = loadProject()) {
257
257
  const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
258
258
  await writeIfChanged(join(out, "consumers.ts"), listModule(consumers, {
259
259
  prefix: "consumer",
260
- typeImport: "import type { ConsumerDefinition } from \"#core/jobs/define\";",
260
+ typeImport: "import type { ConsumerDefinition } from \"@tatara/jobs\";",
261
261
  typeName: "ConsumerDefinition",
262
262
  exportName: "consumers"
263
263
  }), written);
264
264
  const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
265
265
  await writeIfChanged(join(out, "schedules.ts"), listModule(schedules, {
266
266
  prefix: "schedule",
267
- typeImport: "import type { ScheduleDefinition } from \"#core/jobs/define\";",
267
+ typeImport: "import type { ScheduleDefinition } from \"@tatara/jobs\";",
268
268
  typeName: "ScheduleDefinition",
269
269
  exportName: "schedules"
270
270
  }), written);
@@ -731,6 +731,7 @@ const jobs = {
731
731
  kind: "addon",
732
732
  label: "Jobs + cron (BullMQ)",
733
733
  dependencies: [
734
+ "@tatara/jobs",
734
735
  "bullmq",
735
736
  "ioredis",
736
737
  "croner"
@@ -803,8 +804,8 @@ const jobs = {
803
804
  slot: "sections",
804
805
  content: `## Jobs and schedules
805
806
 
806
- - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/queue\`; a module handles it in
807
- \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\`). Payloads are validated by the
807
+ - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/index\`; a module handles it in
808
+ \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\` from \`@tatara/jobs\`). Payloads are validated by the
808
809
  consumer's zod schema. Handlers must be idempotent: jobs retry.
809
810
  - Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
810
811
  each tick run once across every cron process.
@@ -871,7 +872,11 @@ const storage = {
871
872
  id: "storage",
872
873
  kind: "addon",
873
874
  label: "File storage",
874
- dependencies: ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
875
+ dependencies: [
876
+ "@tatara/storage",
877
+ "@aws-sdk/client-s3",
878
+ "@aws-sdk/s3-request-presigner"
879
+ ],
875
880
  env: [
876
881
  {
877
882
  key: "STORAGE_DRIVER",
@@ -973,7 +978,7 @@ const email = {
973
978
  id: "email",
974
979
  kind: "addon",
975
980
  label: "Email",
976
- dependencies: ["nodemailer"],
981
+ dependencies: ["@tatara/email", "nodemailer"],
977
982
  devDependencies: ["@types/nodemailer"],
978
983
  env: [
979
984
  {
@@ -1033,6 +1038,7 @@ const i18n = {
1033
1038
  id: "i18n",
1034
1039
  kind: "addon",
1035
1040
  label: "i18n",
1041
+ dependencies: ["@tatara/i18n"],
1036
1042
  env: [{
1037
1043
  key: "DEFAULT_LANGUAGE",
1038
1044
  schema: "z.enum([\"en\", \"ar\"]).default(\"en\")",
@@ -3216,6 +3222,163 @@ function importsOf(text, specifier) {
3216
3222
  return found ? names : null;
3217
3223
  }
3218
3224
  //#endregion
3225
+ //#region src/upgrade/codemods/groups.ts
3226
+ async function read$4(ctx, path) {
3227
+ return ctx.read(path).catch(() => void 0);
3228
+ }
3229
+ function sourceFiles(ctx) {
3230
+ return glob(["src/**/*.ts", "tests/**/*.ts"], {
3231
+ cwd: ctx.project.root,
3232
+ ignore: ["src/generated/**"]
3233
+ });
3234
+ }
3235
+ /** Why the group can't move ([] when it can). Changes nothing. */
3236
+ async function blockersOf$1(ctx, group, sources) {
3237
+ const blockers = [];
3238
+ for (const path of [...group.regenerate, ...group.remove]) {
3239
+ const text = await read$4(ctx, path);
3240
+ if (text === void 0) continue;
3241
+ if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3242
+ }
3243
+ const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
3244
+ for (const path of sources) {
3245
+ if (own.has(path)) continue;
3246
+ const text = await ctx.read(path);
3247
+ for (const rewrite of group.rewrites) {
3248
+ const names = importsOf(text, rewrite.from);
3249
+ if (!names) continue;
3250
+ const allowed = rewrite.names;
3251
+ const missing = names.filter((n) => n === "*" || allowed !== void 0 && !allowed.includes(n));
3252
+ if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${rewrite.from}`);
3253
+ }
3254
+ }
3255
+ return blockers;
3256
+ }
3257
+ async function applyGroup(ctx, group) {
3258
+ for (const path of group.regenerate) {
3259
+ const next = await ctx.renderCurrent(path);
3260
+ if (next !== void 0) await ctx.write(path, next);
3261
+ }
3262
+ for (const path of group.remove) await ctx.removeIfUnchanged(path);
3263
+ for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
3264
+ }
3265
+ //#endregion
3266
+ //#region src/upgrade/codemods/addon-packages.ts
3267
+ const groups$1 = {
3268
+ storage: {
3269
+ name: "storage",
3270
+ regenerate: [
3271
+ "src/core/storage/index.ts",
3272
+ "src/core/storage/commands/check.ts",
3273
+ "src/core/storage/tests/storage.test.ts"
3274
+ ],
3275
+ remove: [
3276
+ "storage.ts",
3277
+ "local.ts",
3278
+ "s3.ts"
3279
+ ].map((f) => `src/core/storage/${f}`),
3280
+ rewrites: [
3281
+ {
3282
+ from: "#core/storage/storage",
3283
+ to: "@tatara/storage"
3284
+ },
3285
+ {
3286
+ from: "#core/storage/local",
3287
+ to: "@tatara/storage",
3288
+ names: []
3289
+ },
3290
+ {
3291
+ from: "#core/storage/s3",
3292
+ to: "#core/storage/index",
3293
+ names: ["s3"]
3294
+ }
3295
+ ]
3296
+ },
3297
+ email: {
3298
+ name: "email",
3299
+ regenerate: ["src/core/email/send.ts"],
3300
+ remove: [],
3301
+ rewrites: []
3302
+ },
3303
+ i18n: {
3304
+ name: "i18n",
3305
+ regenerate: ["src/core/i18n/index.ts"],
3306
+ remove: [],
3307
+ rewrites: []
3308
+ },
3309
+ jobs: {
3310
+ name: "jobs",
3311
+ regenerate: [
3312
+ "src/core/jobs/index.ts",
3313
+ "src/apps/worker/index.ts",
3314
+ "src/apps/cron/index.ts",
3315
+ "src/core/jobs/commands/enqueue.ts",
3316
+ "src/modules/example/tests/jobs.test.ts"
3317
+ ],
3318
+ remove: [
3319
+ "define.ts",
3320
+ "process.ts",
3321
+ "lock.ts",
3322
+ "redis.ts",
3323
+ "queue.ts"
3324
+ ].map((f) => `src/core/jobs/${f}`),
3325
+ rewrites: [
3326
+ {
3327
+ from: "#core/jobs/define",
3328
+ to: "@tatara/jobs"
3329
+ },
3330
+ {
3331
+ from: "#core/jobs/process",
3332
+ to: "@tatara/jobs"
3333
+ },
3334
+ {
3335
+ from: "#core/jobs/queue",
3336
+ to: "#core/jobs/index",
3337
+ names: ["enqueue", "QUEUE_NAME"]
3338
+ },
3339
+ {
3340
+ from: "#core/jobs/redis",
3341
+ to: "#core/jobs/index",
3342
+ names: ["redis"]
3343
+ },
3344
+ {
3345
+ from: "#core/jobs/lock",
3346
+ to: "@tatara/jobs",
3347
+ names: []
3348
+ }
3349
+ ]
3350
+ }
3351
+ };
3352
+ /** Written even though the app has no copy yet; other files are only replaced when present. */
3353
+ const NEW = /* @__PURE__ */ new Set(["src/core/jobs/index.ts"]);
3354
+ const addonPackages = {
3355
+ version: "0.7.0",
3356
+ id: "addon-packages",
3357
+ description: "Use @tatara/storage, @tatara/email, @tatara/i18n and @tatara/jobs",
3358
+ async run(ctx) {
3359
+ const addons = ctx.project.config.addons;
3360
+ const sources = await sourceFiles(ctx);
3361
+ const ready = [];
3362
+ for (const [addon, group] of Object.entries(groups$1)) {
3363
+ if (!addons.includes(addon)) continue;
3364
+ await ctx.addDependency(`@tatara/${addon}`, versionOf(`@tatara/${addon}`));
3365
+ const blockers = await blockersOf$1(ctx, group, sources);
3366
+ if (blockers.length > 0) {
3367
+ ctx.report.notes.push(`${addon} was not moved onto @tatara/${addon}: ${blockers.join("; ")}. Compare with a newly generated app and move it by hand.`);
3368
+ if (!ctx.report.blocked.includes("addon-packages")) ctx.report.blocked.push("addon-packages");
3369
+ continue;
3370
+ }
3371
+ const present = [];
3372
+ for (const path of group.regenerate) if (await ctx.read(path).then(() => true, () => false) || NEW.has(path)) present.push(path);
3373
+ ready.push({
3374
+ ...group,
3375
+ regenerate: present
3376
+ });
3377
+ }
3378
+ for (const group of ready) await applyGroup(ctx, group);
3379
+ }
3380
+ };
3381
+ //#endregion
3219
3382
  //#region src/upgrade/codemods/auth-package.ts
3220
3383
  const AUTH = "@tatara/auth";
3221
3384
  const KEYCLOAK = "@tatara/keycloak";
@@ -3772,7 +3935,8 @@ const CODEMODS = [
3772
3935
  },
3773
3936
  dbTesting,
3774
3937
  authPackage,
3775
- authzPackage
3938
+ authzPackage,
3939
+ addonPackages
3776
3940
  ];
3777
3941
  //#endregion
3778
3942
  //#region src/upgrade/context.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 { defineSchedule } from "#core/jobs/define";
1
+ import { defineSchedule } from "@tatara/jobs";
2
2
 
3
3
  export default defineSchedule({
4
4
  name: "__moduleKebab__.__nameKebab__",
@@ -37,5 +37,9 @@
37
37
  "@tatara/testing": "0.1.0",
38
38
  "@tatara/auth": "0.1.0",
39
39
  "@tatara/keycloak": "0.1.0",
40
- "@tatara/authz": "0.1.0"
40
+ "@tatara/authz": "0.1.0",
41
+ "@tatara/storage": "0.1.0",
42
+ "@tatara/email": "0.1.0",
43
+ "@tatara/i18n": "0.1.0",
44
+ "@tatara/jobs": "0.1.0"
41
45
  }
@@ -1,37 +0,0 @@
1
- import type { z } from "zod";
2
- import type { Logger } from "#core/logging/logger";
3
-
4
- export interface JobContext {
5
- logger: Logger;
6
- jobId: string;
7
- attempt: number;
8
- }
9
-
10
- export interface ConsumerDefinition<S extends z.ZodType = z.ZodType> {
11
- /** job name, e.g. "billing.invoice-paid" */
12
- topic: string;
13
- description: string;
14
- schema: S;
15
- /** retries before the job is marked failed (default 3) */
16
- attempts?: number;
17
- handle(data: z.infer<S>, ctx: JobContext): Promise<void>;
18
- }
19
-
20
- /** Default export of every `consumers/*.ts`; run by the worker app. */
21
- export function defineConsumer<S extends z.ZodType>(definition: ConsumerDefinition<S>) {
22
- return definition;
23
- }
24
-
25
- export interface ScheduleDefinition {
26
- name: string;
27
- /** cron expression (seconds optional), evaluated in UTC */
28
- cron: string;
29
- /** how long the run holds its lock; one run per tick across all cron processes */
30
- lockTtlMs?: number;
31
- run(ctx: { logger: Logger }): Promise<void>;
32
- }
33
-
34
- /** Default export of every `schedules/*.ts`; run by the cron app. */
35
- export function defineSchedule(definition: ScheduleDefinition): ScheduleDefinition {
36
- return definition;
37
- }
@@ -1,17 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { redis } from "#core/jobs/redis";
3
-
4
- const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
5
-
6
- /** Runs `fn` only if this process wins the lock; returns false when another holder has it. */
7
- export async function withLock(key: string, ttlMs: number, fn: () => Promise<void>): Promise<boolean> {
8
- const token = randomUUID();
9
- const acquired = await redis().set(`lock:${key}`, token, "PX", ttlMs, "NX");
10
- if (acquired !== "OK") return false;
11
- try {
12
- await fn();
13
- } finally {
14
- await redis().eval(RELEASE, 1, `lock:${key}`, token);
15
- }
16
- return true;
17
- }
@@ -1,19 +0,0 @@
1
- import type { ConsumerDefinition, JobContext } from "#core/jobs/define";
2
-
3
- export class UnknownTopicError extends Error {}
4
-
5
- /** Validates and runs one job; shared by the worker and by tests. */
6
- export async function processJob(
7
- consumers: ConsumerDefinition[],
8
- topic: string,
9
- data: unknown,
10
- ctx: JobContext,
11
- ): Promise<void> {
12
- const consumer = consumers.find((c) => c.topic === topic);
13
- if (!consumer) throw new UnknownTopicError(`No consumer for topic "${topic}"`);
14
- const parsed = consumer.schema.safeParse(data);
15
- if (!parsed.success) {
16
- throw new Error(`Invalid payload for ${topic}: ${parsed.error.message}`);
17
- }
18
- await consumer.handle(parsed.data, ctx);
19
- }
@@ -1,29 +0,0 @@
1
- import { type JobsOptions, Queue } from "bullmq";
2
- import { env } from "#core/config/env";
3
- import { redis } from "#core/jobs/redis";
4
- import { onShutdown } from "@tatara/core";
5
-
6
- export const QUEUE_NAME = "__APP_NAME__-jobs";
7
-
8
- let queue: Queue | undefined;
9
-
10
- function getQueue(): Queue {
11
- if (!queue) {
12
- const q = new Queue(QUEUE_NAME, { connection: redis(), prefix: env.JOBS_PREFIX });
13
- queue = q;
14
- onShutdown("queue", () => q.close());
15
- }
16
- return queue;
17
- }
18
-
19
- /** Enqueues a job for the consumer with this topic. The consumer validates the payload. */
20
- export async function enqueue(topic: string, data: unknown, options: JobsOptions = {}): Promise<string> {
21
- const job = await getQueue().add(topic, data, {
22
- attempts: 3,
23
- backoff: { type: "exponential", delay: 1000 },
24
- removeOnComplete: 1000,
25
- removeOnFail: 5000,
26
- ...options,
27
- });
28
- return job.id ?? "";
29
- }
@@ -1,17 +0,0 @@
1
- import { Redis } from "ioredis";
2
- import { env } from "#core/config/env";
3
- import { onShutdown } from "@tatara/core";
4
-
5
- let client: Redis | undefined;
6
-
7
- /** Shared connection (BullMQ requires maxRetriesPerRequest: null). */
8
- export function redis(): Redis {
9
- if (!client) {
10
- const connection = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
11
- client = connection;
12
- onShutdown("redis", async () => {
13
- await connection.quit();
14
- });
15
- }
16
- return client;
17
- }
@@ -1,34 +0,0 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { dirname, resolve, sep } from "node:path";
3
- import { env } from "#core/config/env";
4
- import { assertKey, type Storage } from "#core/storage/storage";
5
-
6
- /** Files on disk; for development and tests. */
7
- export function localStorage(root = env.STORAGE_LOCAL_DIR): Storage {
8
- const base = resolve(root);
9
- const path = (key: string) => {
10
- const full = resolve(base, assertKey(key));
11
- if (!full.startsWith(base + sep)) throw new Error(`Invalid storage key "${key}"`);
12
- return full;
13
- };
14
- return {
15
- async put(key, body) {
16
- await mkdir(dirname(path(key)), { recursive: true });
17
- await writeFile(path(key), body);
18
- },
19
- async get(key) {
20
- try {
21
- return new Uint8Array(await readFile(path(key)));
22
- } catch (error) {
23
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
24
- throw error;
25
- }
26
- },
27
- async delete(key) {
28
- await rm(path(key), { force: true });
29
- },
30
- async url(key) {
31
- return `file://${path(key)}`;
32
- },
33
- };
34
- }
@@ -1,66 +0,0 @@
1
- import {
2
- CreateBucketCommand,
3
- DeleteObjectCommand,
4
- GetObjectCommand,
5
- HeadBucketCommand,
6
- NoSuchKey,
7
- PutObjectCommand,
8
- S3Client,
9
- } from "@aws-sdk/client-s3";
10
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
11
- import { env } from "#core/config/env";
12
- import { assertKey, type Storage } from "#core/storage/storage";
13
-
14
- export const s3 = new S3Client({
15
- region: env.S3_REGION,
16
- ...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
17
- forcePathStyle: env.S3_FORCE_PATH_STYLE,
18
- ...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
19
- ? { credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY } }
20
- : {}),
21
- });
22
-
23
- /** Any S3-compatible store (AWS S3, R2, RustFS locally). */
24
- export function s3Storage(bucket = env.S3_BUCKET): Storage {
25
- let bucketReady: Promise<boolean> | undefined;
26
- return {
27
- async put(key, body, contentType) {
28
- // local development: create the bucket on first write
29
- if (env.NODE_ENV === "development") {
30
- bucketReady ??= ensureBucket(bucket);
31
- await bucketReady;
32
- }
33
- await s3.send(
34
- new PutObjectCommand({ Bucket: bucket, Key: assertKey(key), Body: body, ContentType: contentType }),
35
- );
36
- },
37
- async get(key) {
38
- try {
39
- const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: assertKey(key) }));
40
- return res.Body ? await res.Body.transformToByteArray() : null;
41
- } catch (error) {
42
- if (error instanceof NoSuchKey) return null;
43
- throw error;
44
- }
45
- },
46
- async delete(key) {
47
- await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: assertKey(key) }));
48
- },
49
- async url(key, expiresInSeconds = 900) {
50
- return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: assertKey(key) }), {
51
- expiresIn: expiresInSeconds,
52
- });
53
- },
54
- };
55
- }
56
-
57
- /** Creates the bucket when it does not exist (local development). */
58
- export async function ensureBucket(bucket = env.S3_BUCKET): Promise<boolean> {
59
- try {
60
- await s3.send(new HeadBucketCommand({ Bucket: bucket }));
61
- return false;
62
- } catch {
63
- await s3.send(new CreateBucketCommand({ Bucket: bucket }));
64
- return true;
65
- }
66
- }
@@ -1,21 +0,0 @@
1
- /** Object storage used by modules; pick the driver with STORAGE_DRIVER. */
2
- export interface Storage {
3
- put(key: string, body: Uint8Array | string, contentType?: string): Promise<void>;
4
- get(key: string): Promise<Uint8Array | null>;
5
- delete(key: string): Promise<void>;
6
- /** a URL clients can fetch the object from (signed for S3) */
7
- url(key: string, expiresInSeconds?: number): Promise<string>;
8
- }
9
-
10
- /** Keys are relative, forward-slash paths without "..", backslashes, NUL bytes or drive prefixes. */
11
- export function assertKey(key: string): string {
12
- const invalid =
13
- !key ||
14
- key.startsWith("/") ||
15
- key.includes("\\") ||
16
- key.includes("\0") ||
17
- /^[A-Za-z]:/.test(key) ||
18
- key.split("/").some((segment) => segment === ".." || segment === ".");
19
- if (invalid) throw new Error(`Invalid storage key "${key}"`);
20
- return key;
21
- }