@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.
- package/dist/bin.mjs +171 -7
- package/package.json +1 -1
- package/templates/features/email/files/src/core/email/send.ts +8 -17
- package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
- package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
- package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
- package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
- package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
- package/templates/features/storage/files/src/core/storage/index.ts +19 -5
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
- package/templates/versions.json +5 -1
- package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
- package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
- package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
- package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
- package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
- package/templates/features/storage/files/src/core/storage/local.ts +0 -34
- package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
- 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 \"
|
|
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 \"
|
|
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/
|
|
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: [
|
|
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,22 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
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
|
-
|
|
6
|
+
const i18n = createI18n({ dictionaries: { en, ar }, defaultLanguage: env.DEFAULT_LANGUAGE });
|
|
8
7
|
|
|
9
|
-
const
|
|
8
|
+
export const SUPPORTED_LANGUAGES = i18n.languages;
|
|
10
9
|
|
|
11
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import { handleSignals } from "@tatara/core";
|
|
2
|
+
import { startWorker } from "@tatara/jobs";
|
|
2
3
|
import { env } from "#core/config/env";
|
|
3
|
-
import {
|
|
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
|
-
|
|
11
|
-
QUEUE_NAME,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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");
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { withLock } from "
|
|
3
|
-
import {
|
|
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 {
|
|
4
|
-
import {
|
|
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 "
|
|
6
|
+
export type { Storage } from "@tatara/storage";
|
|
7
7
|
|
|
8
|
-
export const
|
|
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 "
|
|
5
|
+
import { localStorage } from "@tatara/storage";
|
|
6
6
|
|
|
7
7
|
describe("local storage", () => {
|
|
8
8
|
it("round-trips an object", async () => {
|
package/templates/versions.json
CHANGED
|
@@ -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
|
-
}
|