@tatara/cli 0.6.0 → 0.8.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 +328 -17
- 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 +11 -7
- 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
|
@@ -190,6 +190,18 @@ function ignored(v, text) {
|
|
|
190
190
|
return new RegExp(`tatara-check-ignore\\s+${v.rule}:\\s*\\S`).test(previous);
|
|
191
191
|
}
|
|
192
192
|
//#endregion
|
|
193
|
+
//#region src/project/ejected.ts
|
|
194
|
+
/** Packages the app ejected (`tatara eject`); their code lives in src/ejected/<name>. */
|
|
195
|
+
function ejectedPackages(config) {
|
|
196
|
+
return Object.keys(config.ejected ?? {});
|
|
197
|
+
}
|
|
198
|
+
/** Points `@tatara/<name>` and `@tatara/<name>/<entry>` imports at the app's ejected copy. */
|
|
199
|
+
function pointAtEjected(text, names) {
|
|
200
|
+
if (names.length === 0) return text;
|
|
201
|
+
const pattern = new RegExp(`(["'])@tatara/(${names.join("|")})(?:/([\\w-]+))?\\1`, "g");
|
|
202
|
+
return text.replace(pattern, (_match, quote, name, entry) => `${quote}#ejected/${name}/${entry ?? "index"}${quote}`);
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
193
205
|
//#region src/commands/sync.ts
|
|
194
206
|
var sync_exports = /* @__PURE__ */ __exportAll({
|
|
195
207
|
specifier: () => specifier,
|
|
@@ -235,19 +247,21 @@ async function writeIfChanged(path, content, written) {
|
|
|
235
247
|
}
|
|
236
248
|
async function sync(project = loadProject()) {
|
|
237
249
|
const { root } = project;
|
|
250
|
+
const ejected = ejectedPackages(project.config);
|
|
251
|
+
const write = (path, content, out) => writeIfChanged(path, pointAtEjected(content, ejected), out);
|
|
238
252
|
const out = join(root, "src/generated");
|
|
239
253
|
await mkdir(out, { recursive: true });
|
|
240
254
|
const written = [];
|
|
241
255
|
const dirs = ["src/core/*", "src/modules/*"];
|
|
242
256
|
const routes = await find(root, dirs.map((d) => `${d}/routes/*.routes.ts`));
|
|
243
|
-
await
|
|
257
|
+
await write(join(out, "routes.ts"), listModule(routes, {
|
|
244
258
|
prefix: "routes",
|
|
245
259
|
typeImport: "import type { RouteDefinition } from \"#core/http/routes\";",
|
|
246
260
|
typeName: "RouteDefinition",
|
|
247
261
|
exportName: "routes"
|
|
248
262
|
}), written);
|
|
249
263
|
const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
|
|
250
|
-
await
|
|
264
|
+
await write(join(out, "commands.ts"), listModule(commands, {
|
|
251
265
|
prefix: "command",
|
|
252
266
|
typeImport: "import type { CommandDefinition } from \"@tatara/core\";",
|
|
253
267
|
typeName: "CommandDefinition",
|
|
@@ -255,16 +269,16 @@ async function sync(project = loadProject()) {
|
|
|
255
269
|
}), written);
|
|
256
270
|
if (project.has("jobs")) {
|
|
257
271
|
const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
|
|
258
|
-
await
|
|
272
|
+
await write(join(out, "consumers.ts"), listModule(consumers, {
|
|
259
273
|
prefix: "consumer",
|
|
260
|
-
typeImport: "import type { ConsumerDefinition } from \"
|
|
274
|
+
typeImport: "import type { ConsumerDefinition } from \"@tatara/jobs\";",
|
|
261
275
|
typeName: "ConsumerDefinition",
|
|
262
276
|
exportName: "consumers"
|
|
263
277
|
}), written);
|
|
264
278
|
const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
|
|
265
|
-
await
|
|
279
|
+
await write(join(out, "schedules.ts"), listModule(schedules, {
|
|
266
280
|
prefix: "schedule",
|
|
267
|
-
typeImport: "import type { ScheduleDefinition } from \"
|
|
281
|
+
typeImport: "import type { ScheduleDefinition } from \"@tatara/jobs\";",
|
|
268
282
|
typeName: "ScheduleDefinition",
|
|
269
283
|
exportName: "schedules"
|
|
270
284
|
}), written);
|
|
@@ -273,7 +287,7 @@ async function sync(project = loadProject()) {
|
|
|
273
287
|
const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
|
|
274
288
|
const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
|
|
275
289
|
const names = permissions.map((f) => identifier(f, "permissions"));
|
|
276
|
-
await
|
|
290
|
+
await write(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
|
|
277
291
|
${imports.join("\n")}${imports.length ? "\n" : ""}
|
|
278
292
|
export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
|
|
279
293
|
|
|
@@ -283,7 +297,7 @@ export type PermissionKey =
|
|
|
283
297
|
}
|
|
284
298
|
if (project.config.database !== "none") {
|
|
285
299
|
const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
|
|
286
|
-
await
|
|
300
|
+
await write(join(out, "seeds.ts"), listModule(seeds, {
|
|
287
301
|
prefix: "seed",
|
|
288
302
|
typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
|
|
289
303
|
typeName: "SeedDefinition",
|
|
@@ -292,7 +306,7 @@ export type PermissionKey =
|
|
|
292
306
|
}
|
|
293
307
|
if (project.config.orm === "drizzle") {
|
|
294
308
|
const schemas = await find(root, dirs.map((d) => `${d}/db/*.schema.ts`));
|
|
295
|
-
await
|
|
309
|
+
await write(join(out, "schema.ts"), `${HEADER}${schemas.map((f) => `export * from "${specifier(f)}";`).join("\n")}\nexport {};\n`, written);
|
|
296
310
|
}
|
|
297
311
|
if (project.config.orm === "prisma") {
|
|
298
312
|
const bin = join(root, "node_modules/.bin/prisma");
|
|
@@ -650,6 +664,121 @@ function printDoctor(checks) {
|
|
|
650
664
|
}
|
|
651
665
|
}
|
|
652
666
|
//#endregion
|
|
667
|
+
//#region src/commands/eject.ts
|
|
668
|
+
var EjectError = class extends Error {};
|
|
669
|
+
const short = (name) => name.replace(/^@tatara\//, "");
|
|
670
|
+
const full = (name) => `@tatara/${short(name)}`;
|
|
671
|
+
const NOT_LIBRARIES = /* @__PURE__ */ new Set(["cli", "create-tatara"]);
|
|
672
|
+
async function readJson(path) {
|
|
673
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Copies a package's source into src/ejected/<name>, points the app's imports at it and records
|
|
677
|
+
* it in tatara.json. From then on the app owns that code and `tatara upgrade` leaves it alone.
|
|
678
|
+
*/
|
|
679
|
+
async function eject(project, name, opts = {}) {
|
|
680
|
+
const target = short(name);
|
|
681
|
+
if (NOT_LIBRARIES.has(target)) throw new EjectError(`${full(target)} is a tool, not a library`);
|
|
682
|
+
const appPkgPath = join(project.root, "package.json");
|
|
683
|
+
const appPkg = await readJson(appPkgPath);
|
|
684
|
+
const installed = Object.keys({
|
|
685
|
+
...appPkg.dependencies,
|
|
686
|
+
...appPkg.devDependencies
|
|
687
|
+
}).filter((dep) => dep.startsWith("@tatara/") && !NOT_LIBRARIES.has(short(dep))).map(short);
|
|
688
|
+
const already = ejectedPackages(project.config);
|
|
689
|
+
if (already.includes(target)) throw new EjectError(`${full(target)} is already ejected`);
|
|
690
|
+
if (!installed.includes(target)) throw new EjectError(`${full(target)} is not a dependency of this app`);
|
|
691
|
+
const manifests = /* @__PURE__ */ new Map();
|
|
692
|
+
for (const dep of installed) {
|
|
693
|
+
const path = join(project.root, "node_modules", full(dep), "package.json");
|
|
694
|
+
if (existsSync(path)) manifests.set(dep, await readJson(path));
|
|
695
|
+
}
|
|
696
|
+
const chain = [target];
|
|
697
|
+
for (let i = 0; i < chain.length; i++) for (const [dep, manifest] of manifests) {
|
|
698
|
+
if (chain.includes(dep)) continue;
|
|
699
|
+
if (full(chain[i]) in (manifest.peerDependencies ?? {})) chain.push(dep);
|
|
700
|
+
}
|
|
701
|
+
const dependents = chain.slice(1);
|
|
702
|
+
if (dependents.length > 0 && !opts.withDependents) throw new EjectError(`${dependents.map(full).join(", ")} depend on ${full(target)}. Eject them too with --with-dependents.`);
|
|
703
|
+
const report = {
|
|
704
|
+
ejected: chain,
|
|
705
|
+
copied: [],
|
|
706
|
+
rewritten: [],
|
|
707
|
+
added: []
|
|
708
|
+
};
|
|
709
|
+
const versions = {};
|
|
710
|
+
for (const dep of chain) {
|
|
711
|
+
const manifest = manifests.get(dep);
|
|
712
|
+
const source = join(project.root, "node_modules", full(dep), "src");
|
|
713
|
+
if (!manifest || !existsSync(source)) throw new EjectError(`${full(dep)} ships no source to eject; update it to a version that does, then run this again`);
|
|
714
|
+
versions[dep] = manifest.version;
|
|
715
|
+
const files = await glob(["**/*.ts"], {
|
|
716
|
+
cwd: source,
|
|
717
|
+
ignore: ["**/*.test.ts"]
|
|
718
|
+
});
|
|
719
|
+
for (const file of files) {
|
|
720
|
+
const to = `src/ejected/${dep}/${file}`;
|
|
721
|
+
await mkdir(dirname(join(project.root, to)), { recursive: true });
|
|
722
|
+
await cp(join(source, file), join(project.root, to));
|
|
723
|
+
report.copied.push(to);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const ejected = [...already, ...chain];
|
|
727
|
+
for (const file of await glob(["src/**/*.ts", "tests/**/*.ts"], { cwd: project.root })) {
|
|
728
|
+
const path = join(project.root, file);
|
|
729
|
+
const text = await readFile(path, "utf8");
|
|
730
|
+
const next = pointAtEjected(text, ejected);
|
|
731
|
+
if (next !== text) {
|
|
732
|
+
await writeFile(path, next);
|
|
733
|
+
if (!report.copied.includes(file)) report.rewritten.push(file);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const deps = { ...appPkg.dependencies };
|
|
737
|
+
const listed = new Set(Object.keys({
|
|
738
|
+
...appPkg.dependencies,
|
|
739
|
+
...appPkg.devDependencies
|
|
740
|
+
}));
|
|
741
|
+
for (const dep of chain) {
|
|
742
|
+
const manifest = manifests.get(dep);
|
|
743
|
+
for (const [peer, range] of Object.entries(manifest.peerDependencies ?? {})) {
|
|
744
|
+
if (listed.has(peer) || manifest.peerDependenciesMeta?.[peer]?.optional) continue;
|
|
745
|
+
if (peer.startsWith("@tatara/") && ejected.includes(short(peer))) continue;
|
|
746
|
+
deps[peer] = range;
|
|
747
|
+
listed.add(peer);
|
|
748
|
+
report.added.push(peer);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
for (const dep of chain) delete deps[full(dep)];
|
|
752
|
+
const devDeps = { ...appPkg.devDependencies };
|
|
753
|
+
for (const dep of chain) delete devDeps[full(dep)];
|
|
754
|
+
const nextPkg = {
|
|
755
|
+
...appPkg,
|
|
756
|
+
imports: {
|
|
757
|
+
...appPkg.imports,
|
|
758
|
+
"#ejected/*": "./src/ejected/*.ts"
|
|
759
|
+
},
|
|
760
|
+
dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
|
|
761
|
+
devDependencies: devDeps
|
|
762
|
+
};
|
|
763
|
+
await writeFile(appPkgPath, `${JSON.stringify(nextPkg, null, " ")}\n`);
|
|
764
|
+
const configPath = join(project.root, "tatara.json");
|
|
765
|
+
const config = await readJson(configPath);
|
|
766
|
+
await writeFile(configPath, `${JSON.stringify({
|
|
767
|
+
...config,
|
|
768
|
+
ejected: {
|
|
769
|
+
...config.ejected,
|
|
770
|
+
...versions
|
|
771
|
+
}
|
|
772
|
+
}, null, " ")}\n`);
|
|
773
|
+
if (opts.formatFile) for (const file of [
|
|
774
|
+
...report.copied,
|
|
775
|
+
...report.rewritten,
|
|
776
|
+
"package.json",
|
|
777
|
+
"tatara.json"
|
|
778
|
+
]) await opts.formatFile(file, project);
|
|
779
|
+
return report;
|
|
780
|
+
}
|
|
781
|
+
//#endregion
|
|
653
782
|
//#region src/paths.ts
|
|
654
783
|
/** Package root: one level above both src/ (dev) and dist/ (published, bundled). */
|
|
655
784
|
const PACKAGE_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -731,6 +860,7 @@ const jobs = {
|
|
|
731
860
|
kind: "addon",
|
|
732
861
|
label: "Jobs + cron (BullMQ)",
|
|
733
862
|
dependencies: [
|
|
863
|
+
"@tatara/jobs",
|
|
734
864
|
"bullmq",
|
|
735
865
|
"ioredis",
|
|
736
866
|
"croner"
|
|
@@ -803,8 +933,8 @@ const jobs = {
|
|
|
803
933
|
slot: "sections",
|
|
804
934
|
content: `## Jobs and schedules
|
|
805
935
|
|
|
806
|
-
- Background work: \`enqueue(topic, payload)\` from \`#core/jobs/
|
|
807
|
-
\`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\`). Payloads are validated by the
|
|
936
|
+
- Background work: \`enqueue(topic, payload)\` from \`#core/jobs/index\`; a module handles it in
|
|
937
|
+
\`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\` from \`@tatara/jobs\`). Payloads are validated by the
|
|
808
938
|
consumer's zod schema. Handlers must be idempotent: jobs retry.
|
|
809
939
|
- Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
|
|
810
940
|
each tick run once across every cron process.
|
|
@@ -871,7 +1001,11 @@ const storage = {
|
|
|
871
1001
|
id: "storage",
|
|
872
1002
|
kind: "addon",
|
|
873
1003
|
label: "File storage",
|
|
874
|
-
dependencies: [
|
|
1004
|
+
dependencies: [
|
|
1005
|
+
"@tatara/storage",
|
|
1006
|
+
"@aws-sdk/client-s3",
|
|
1007
|
+
"@aws-sdk/s3-request-presigner"
|
|
1008
|
+
],
|
|
875
1009
|
env: [
|
|
876
1010
|
{
|
|
877
1011
|
key: "STORAGE_DRIVER",
|
|
@@ -973,7 +1107,7 @@ const email = {
|
|
|
973
1107
|
id: "email",
|
|
974
1108
|
kind: "addon",
|
|
975
1109
|
label: "Email",
|
|
976
|
-
dependencies: ["nodemailer"],
|
|
1110
|
+
dependencies: ["@tatara/email", "nodemailer"],
|
|
977
1111
|
devDependencies: ["@types/nodemailer"],
|
|
978
1112
|
env: [
|
|
979
1113
|
{
|
|
@@ -1033,6 +1167,7 @@ const i18n = {
|
|
|
1033
1167
|
id: "i18n",
|
|
1034
1168
|
kind: "addon",
|
|
1035
1169
|
label: "i18n",
|
|
1170
|
+
dependencies: ["@tatara/i18n"],
|
|
1036
1171
|
env: [{
|
|
1037
1172
|
key: "DEFAULT_LANGUAGE",
|
|
1038
1173
|
schema: "z.enum([\"en\", \"ar\"]).default(\"en\")",
|
|
@@ -2574,7 +2709,7 @@ async function generate(project, request) {
|
|
|
2574
2709
|
for (const [rel, text] of rendered) {
|
|
2575
2710
|
const full = join(project.root, rel);
|
|
2576
2711
|
await mkdir(dirname(full), { recursive: true });
|
|
2577
|
-
await writeFile(full, text);
|
|
2712
|
+
await writeFile(full, pointAtEjected(text, ejectedPackages(project.config)));
|
|
2578
2713
|
}
|
|
2579
2714
|
if (!request.skipPostProcess) await postProcess(project, files);
|
|
2580
2715
|
return {
|
|
@@ -3216,6 +3351,163 @@ function importsOf(text, specifier) {
|
|
|
3216
3351
|
return found ? names : null;
|
|
3217
3352
|
}
|
|
3218
3353
|
//#endregion
|
|
3354
|
+
//#region src/upgrade/codemods/groups.ts
|
|
3355
|
+
async function read$4(ctx, path) {
|
|
3356
|
+
return ctx.read(path).catch(() => void 0);
|
|
3357
|
+
}
|
|
3358
|
+
function sourceFiles(ctx) {
|
|
3359
|
+
return glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3360
|
+
cwd: ctx.project.root,
|
|
3361
|
+
ignore: ["src/generated/**"]
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
/** Why the group can't move ([] when it can). Changes nothing. */
|
|
3365
|
+
async function blockersOf$1(ctx, group, sources) {
|
|
3366
|
+
const blockers = [];
|
|
3367
|
+
for (const path of [...group.regenerate, ...group.remove]) {
|
|
3368
|
+
const text = await read$4(ctx, path);
|
|
3369
|
+
if (text === void 0) continue;
|
|
3370
|
+
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3371
|
+
}
|
|
3372
|
+
const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
|
|
3373
|
+
for (const path of sources) {
|
|
3374
|
+
if (own.has(path)) continue;
|
|
3375
|
+
const text = await ctx.read(path);
|
|
3376
|
+
for (const rewrite of group.rewrites) {
|
|
3377
|
+
const names = importsOf(text, rewrite.from);
|
|
3378
|
+
if (!names) continue;
|
|
3379
|
+
const allowed = rewrite.names;
|
|
3380
|
+
const missing = names.filter((n) => n === "*" || allowed !== void 0 && !allowed.includes(n));
|
|
3381
|
+
if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${rewrite.from}`);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
return blockers;
|
|
3385
|
+
}
|
|
3386
|
+
async function applyGroup(ctx, group) {
|
|
3387
|
+
for (const path of group.regenerate) {
|
|
3388
|
+
const next = await ctx.renderCurrent(path);
|
|
3389
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3390
|
+
}
|
|
3391
|
+
for (const path of group.remove) await ctx.removeIfUnchanged(path);
|
|
3392
|
+
for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
|
|
3393
|
+
}
|
|
3394
|
+
//#endregion
|
|
3395
|
+
//#region src/upgrade/codemods/addon-packages.ts
|
|
3396
|
+
const groups$1 = {
|
|
3397
|
+
storage: {
|
|
3398
|
+
name: "storage",
|
|
3399
|
+
regenerate: [
|
|
3400
|
+
"src/core/storage/index.ts",
|
|
3401
|
+
"src/core/storage/commands/check.ts",
|
|
3402
|
+
"src/core/storage/tests/storage.test.ts"
|
|
3403
|
+
],
|
|
3404
|
+
remove: [
|
|
3405
|
+
"storage.ts",
|
|
3406
|
+
"local.ts",
|
|
3407
|
+
"s3.ts"
|
|
3408
|
+
].map((f) => `src/core/storage/${f}`),
|
|
3409
|
+
rewrites: [
|
|
3410
|
+
{
|
|
3411
|
+
from: "#core/storage/storage",
|
|
3412
|
+
to: "@tatara/storage"
|
|
3413
|
+
},
|
|
3414
|
+
{
|
|
3415
|
+
from: "#core/storage/local",
|
|
3416
|
+
to: "@tatara/storage",
|
|
3417
|
+
names: []
|
|
3418
|
+
},
|
|
3419
|
+
{
|
|
3420
|
+
from: "#core/storage/s3",
|
|
3421
|
+
to: "#core/storage/index",
|
|
3422
|
+
names: ["s3"]
|
|
3423
|
+
}
|
|
3424
|
+
]
|
|
3425
|
+
},
|
|
3426
|
+
email: {
|
|
3427
|
+
name: "email",
|
|
3428
|
+
regenerate: ["src/core/email/send.ts"],
|
|
3429
|
+
remove: [],
|
|
3430
|
+
rewrites: []
|
|
3431
|
+
},
|
|
3432
|
+
i18n: {
|
|
3433
|
+
name: "i18n",
|
|
3434
|
+
regenerate: ["src/core/i18n/index.ts"],
|
|
3435
|
+
remove: [],
|
|
3436
|
+
rewrites: []
|
|
3437
|
+
},
|
|
3438
|
+
jobs: {
|
|
3439
|
+
name: "jobs",
|
|
3440
|
+
regenerate: [
|
|
3441
|
+
"src/core/jobs/index.ts",
|
|
3442
|
+
"src/apps/worker/index.ts",
|
|
3443
|
+
"src/apps/cron/index.ts",
|
|
3444
|
+
"src/core/jobs/commands/enqueue.ts",
|
|
3445
|
+
"src/modules/example/tests/jobs.test.ts"
|
|
3446
|
+
],
|
|
3447
|
+
remove: [
|
|
3448
|
+
"define.ts",
|
|
3449
|
+
"process.ts",
|
|
3450
|
+
"lock.ts",
|
|
3451
|
+
"redis.ts",
|
|
3452
|
+
"queue.ts"
|
|
3453
|
+
].map((f) => `src/core/jobs/${f}`),
|
|
3454
|
+
rewrites: [
|
|
3455
|
+
{
|
|
3456
|
+
from: "#core/jobs/define",
|
|
3457
|
+
to: "@tatara/jobs"
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
from: "#core/jobs/process",
|
|
3461
|
+
to: "@tatara/jobs"
|
|
3462
|
+
},
|
|
3463
|
+
{
|
|
3464
|
+
from: "#core/jobs/queue",
|
|
3465
|
+
to: "#core/jobs/index",
|
|
3466
|
+
names: ["enqueue", "QUEUE_NAME"]
|
|
3467
|
+
},
|
|
3468
|
+
{
|
|
3469
|
+
from: "#core/jobs/redis",
|
|
3470
|
+
to: "#core/jobs/index",
|
|
3471
|
+
names: ["redis"]
|
|
3472
|
+
},
|
|
3473
|
+
{
|
|
3474
|
+
from: "#core/jobs/lock",
|
|
3475
|
+
to: "@tatara/jobs",
|
|
3476
|
+
names: []
|
|
3477
|
+
}
|
|
3478
|
+
]
|
|
3479
|
+
}
|
|
3480
|
+
};
|
|
3481
|
+
/** Written even though the app has no copy yet; other files are only replaced when present. */
|
|
3482
|
+
const NEW = /* @__PURE__ */ new Set(["src/core/jobs/index.ts"]);
|
|
3483
|
+
const addonPackages = {
|
|
3484
|
+
version: "0.7.0",
|
|
3485
|
+
id: "addon-packages",
|
|
3486
|
+
description: "Use @tatara/storage, @tatara/email, @tatara/i18n and @tatara/jobs",
|
|
3487
|
+
async run(ctx) {
|
|
3488
|
+
const addons = ctx.project.config.addons;
|
|
3489
|
+
const sources = await sourceFiles(ctx);
|
|
3490
|
+
const ready = [];
|
|
3491
|
+
for (const [addon, group] of Object.entries(groups$1)) {
|
|
3492
|
+
if (!addons.includes(addon)) continue;
|
|
3493
|
+
await ctx.addDependency(`@tatara/${addon}`, versionOf(`@tatara/${addon}`));
|
|
3494
|
+
const blockers = await blockersOf$1(ctx, group, sources);
|
|
3495
|
+
if (blockers.length > 0) {
|
|
3496
|
+
ctx.report.notes.push(`${addon} was not moved onto @tatara/${addon}: ${blockers.join("; ")}. Compare with a newly generated app and move it by hand.`);
|
|
3497
|
+
if (!ctx.report.blocked.includes("addon-packages")) ctx.report.blocked.push("addon-packages");
|
|
3498
|
+
continue;
|
|
3499
|
+
}
|
|
3500
|
+
const present = [];
|
|
3501
|
+
for (const path of group.regenerate) if (await ctx.read(path).then(() => true, () => false) || NEW.has(path)) present.push(path);
|
|
3502
|
+
ready.push({
|
|
3503
|
+
...group,
|
|
3504
|
+
regenerate: present
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
for (const group of ready) await applyGroup(ctx, group);
|
|
3508
|
+
}
|
|
3509
|
+
};
|
|
3510
|
+
//#endregion
|
|
3219
3511
|
//#region src/upgrade/codemods/auth-package.ts
|
|
3220
3512
|
const AUTH = "@tatara/auth";
|
|
3221
3513
|
const KEYCLOAK = "@tatara/keycloak";
|
|
@@ -3772,7 +4064,8 @@ const CODEMODS = [
|
|
|
3772
4064
|
},
|
|
3773
4065
|
dbTesting,
|
|
3774
4066
|
authPackage,
|
|
3775
|
-
authzPackage
|
|
4067
|
+
authzPackage,
|
|
4068
|
+
addonPackages
|
|
3776
4069
|
];
|
|
3777
4070
|
//#endregion
|
|
3778
4071
|
//#region src/upgrade/context.ts
|
|
@@ -3789,7 +4082,9 @@ function createContext(project, baseline, opts = {}) {
|
|
|
3789
4082
|
};
|
|
3790
4083
|
const abs = (path) => join(project.root, path);
|
|
3791
4084
|
let rendered;
|
|
3792
|
-
const
|
|
4085
|
+
const ejected = ejectedPackages(project.config);
|
|
4086
|
+
const write = async (path, written) => {
|
|
4087
|
+
const text = path.endsWith(".ts") ? pointAtEjected(written, ejected) : written;
|
|
3793
4088
|
if (!report.written.includes(path)) {
|
|
3794
4089
|
report.written.push(path);
|
|
3795
4090
|
const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
|
|
@@ -3850,6 +4145,11 @@ function createContext(project, baseline, opts = {}) {
|
|
|
3850
4145
|
return (await rendered).get(path);
|
|
3851
4146
|
},
|
|
3852
4147
|
async addDependency(name, range, { dev = false } = {}) {
|
|
4148
|
+
if (ejected.includes(name.replace(/^@tatara\//, ""))) {
|
|
4149
|
+
const note = `${name} is ejected; it was not added (its code is in src/ejected)`;
|
|
4150
|
+
if (!report.notes.includes(note)) report.notes.push(note);
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
3853
4153
|
const pkg = JSON.parse(await readFile(abs("package.json"), "utf8"));
|
|
3854
4154
|
const key = dev ? "devDependencies" : "dependencies";
|
|
3855
4155
|
pkg[key] = {
|
|
@@ -4018,6 +4318,17 @@ program.command("upgrade").description("Run this CLI version's codemods on the a
|
|
|
4018
4318
|
printUpgrade(await upgrade(project, { dryRun: flags.dryRun }), from, cliVersion());
|
|
4019
4319
|
if (!flags.dryRun && flags.verify !== false && !await verify(loadProject())) process.exitCode = 1;
|
|
4020
4320
|
});
|
|
4321
|
+
program.command("eject <package>").description("Copy a @tatara/* package's source into src/ejected; the app owns it from then on").option("--with-dependents", "also eject installed @tatara/* packages that depend on it").action(async (name, flags) => {
|
|
4322
|
+
const report = await eject(loadProject(), name, {
|
|
4323
|
+
withDependents: flags.withDependents,
|
|
4324
|
+
formatFile: biomeFormat
|
|
4325
|
+
});
|
|
4326
|
+
const names = report.ejected.map((n) => `@tatara/${n}`).join(", ");
|
|
4327
|
+
console.log(pc.green(`ejected ${names} into src/ejected (${report.copied.length} files)`));
|
|
4328
|
+
if (report.rewritten.length > 0) console.log(` ${report.rewritten.length} files now import the ejected copy`);
|
|
4329
|
+
if (report.added.length > 0) console.log(` added dependencies: ${report.added.join(", ")}`);
|
|
4330
|
+
console.log(` run your package manager's install, then verify; tatara upgrade skips ${names} from now on`);
|
|
4331
|
+
});
|
|
4021
4332
|
program.command("doctor").description("Check the toolchain, services, env and layout").option("--json", "machine-readable output").action(async (flags) => {
|
|
4022
4333
|
const checks = await doctor(loadProject());
|
|
4023
4334
|
if (flags.json) console.log(JSON.stringify(checks, null, 2));
|
|
@@ -4025,7 +4336,7 @@ program.command("doctor").description("Check the toolchain, services, env and la
|
|
|
4025
4336
|
if (checks.some((c) => !c.ok)) process.exitCode = 1;
|
|
4026
4337
|
});
|
|
4027
4338
|
program.parseAsync().catch((error) => {
|
|
4028
|
-
if (error instanceof OptionsError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
|
|
4339
|
+
if (error instanceof OptionsError || error instanceof EjectError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
|
|
4029
4340
|
else console.error(error);
|
|
4030
4341
|
process.exit(1);
|
|
4031
4342
|
});
|
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
|
@@ -31,11 +31,15 @@
|
|
|
31
31
|
"@aws-sdk/s3-request-presigner": "3.1152.0",
|
|
32
32
|
"nodemailer": "10.1.1",
|
|
33
33
|
"@types/nodemailer": "8.0.2",
|
|
34
|
-
"@tatara/core": "0.1.
|
|
35
|
-
"@tatara/http": "0.1.
|
|
36
|
-
"@tatara/db": "0.1.
|
|
37
|
-
"@tatara/testing": "0.1.
|
|
38
|
-
"@tatara/auth": "0.1.
|
|
39
|
-
"@tatara/keycloak": "0.1.
|
|
40
|
-
"@tatara/authz": "0.1.0"
|
|
34
|
+
"@tatara/core": "0.1.1",
|
|
35
|
+
"@tatara/http": "0.1.1",
|
|
36
|
+
"@tatara/db": "0.1.1",
|
|
37
|
+
"@tatara/testing": "0.1.1",
|
|
38
|
+
"@tatara/auth": "0.1.1",
|
|
39
|
+
"@tatara/keycloak": "0.1.1",
|
|
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
|
-
}
|