@tatara/cli 0.0.0-stage → 0.1.1

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.mjs +3001 -0
  3. package/package.json +32 -3
  4. package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
  5. package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
  6. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
  7. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
  8. package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
  9. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
  10. package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
  11. package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
  12. package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
  13. package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
  14. package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
  15. package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
  16. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
  17. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
  18. package/templates/features/authz/files/src/core/authz/constants.ts +32 -0
  19. package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
  20. package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
  21. package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
  22. package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
  23. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
  24. package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
  25. package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
  26. package/templates/features/authz/files/src/core/authz/services/members.ts +84 -0
  27. package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
  28. package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
  29. package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
  30. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
  31. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  32. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  33. package/templates/features/authz/files/src/core/authz/types.ts +87 -0
  34. package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
  35. package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
  36. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +275 -0
  37. package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
  38. package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
  39. package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
  40. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +236 -0
  41. package/templates/features/base/files/AGENTS.md +71 -0
  42. package/templates/features/base/files/CLAUDE.md +1 -0
  43. package/templates/features/base/files/Dockerfile +16 -0
  44. package/templates/features/base/files/README.md +31 -0
  45. package/templates/features/base/files/_github/workflows/ci.yml +26 -0
  46. package/templates/features/base/files/biome.template.json +20 -0
  47. package/templates/features/base/files/dockerignore +8 -0
  48. package/templates/features/base/files/gitignore +9 -0
  49. package/templates/features/base/files/src/apps/api/app.ts +3 -0
  50. package/templates/features/base/files/src/apps/api/server.ts +5 -0
  51. package/templates/features/base/files/src/apps/cli/index.ts +6 -0
  52. package/templates/features/base/files/src/core/commands/define.ts +33 -0
  53. package/templates/features/base/files/src/core/commands/runner.ts +54 -0
  54. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  55. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  56. package/templates/features/base/files/src/core/http/app.ts +57 -0
  57. package/templates/features/base/files/src/core/http/context.ts +18 -0
  58. package/templates/features/base/files/src/core/http/errors.ts +79 -0
  59. package/templates/features/base/files/src/core/http/health.ts +25 -0
  60. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
  61. package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
  62. package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
  63. package/templates/features/base/files/src/core/http/response.ts +72 -0
  64. package/templates/features/base/files/src/core/http/routes.ts +28 -0
  65. package/templates/features/base/files/src/core/http/server.ts +15 -0
  66. package/templates/features/base/files/src/core/logging/logger.ts +17 -0
  67. package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
  68. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
  69. package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
  70. package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
  71. package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
  72. package/templates/features/base/files/tests/helpers/api.ts +54 -0
  73. package/templates/features/base/files/tests/setup.ts +2 -0
  74. package/templates/features/base/files/tsconfig.json +20 -0
  75. package/templates/features/base/files/tsdown.config.ts +15 -0
  76. package/templates/features/base/files/vitest.config.ts +16 -0
  77. package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
  78. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
  79. package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
  80. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  81. package/templates/features/better-auth/files/src/core/auth/principal.ts +40 -0
  82. package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
  83. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  84. package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  86. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  87. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  88. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
  89. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
  90. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
  91. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
  92. package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
  93. package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
  94. package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
  95. package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
  96. package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
  97. package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
  98. package/templates/features/db/files/src/core/db/id.ts +13 -0
  99. package/templates/features/db/files/src/core/db/seed.ts +14 -0
  100. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  101. package/templates/features/db/files/tests/global-setup.ts +27 -0
  102. package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
  103. package/templates/features/db/files/tests/helpers/db.ts +7 -0
  104. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
  105. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
  106. package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
  107. package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
  108. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
  109. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
  110. package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
  111. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
  112. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
  113. package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
  114. package/templates/features/email/files/src/core/email/send.ts +22 -0
  115. package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
  116. package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
  117. package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
  118. package/templates/features/i18n/files/src/locales/ar.json +10 -0
  119. package/templates/features/i18n/files/src/locales/en.json +10 -0
  120. package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
  121. package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
  122. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
  123. package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
  124. package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
  125. package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
  126. package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
  127. package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
  128. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
  129. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
  130. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
  131. package/templates/features/keycloak/files/keycloak/realm.json +83 -0
  132. package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
  133. package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
  134. package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
  135. package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
  136. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
  137. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
  138. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
  139. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
  140. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
  141. package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
  142. package/templates/features/prisma/files/prisma.config.ts +10 -0
  143. package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
  144. package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
  145. package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
  146. package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
  147. package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
  148. package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
  149. package/templates/features/storage/files/src/core/storage/index.ts +8 -0
  150. package/templates/features/storage/files/src/core/storage/local.ts +34 -0
  151. package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
  152. package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
  153. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
  154. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
  155. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
  156. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
  157. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
  158. package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
  159. package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
  160. package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
  161. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
  162. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +127 -0
  163. package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
  164. package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
  165. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
  166. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
  167. package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  168. package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  169. package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  170. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
  171. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
  172. package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
  173. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
  174. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
  175. package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  176. package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  177. package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  178. package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
  179. package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
  180. package/templates/versions.json +34 -0
  181. package/README.md +0 -3
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,66 @@
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
+ }
@@ -0,0 +1,21 @@
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
+ }
@@ -0,0 +1,22 @@
1
+ import { mkdtemp } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { localStorage } from "#core/storage/local";
6
+
7
+ describe("local storage", () => {
8
+ it("round-trips an object", async () => {
9
+ const store = localStorage(await mkdtemp(join(tmpdir(), "storage-")));
10
+ await store.put("a/b.txt", "hello");
11
+ expect(new TextDecoder().decode((await store.get("a/b.txt")) ?? new Uint8Array())).toBe("hello");
12
+ await store.delete("a/b.txt");
13
+ expect(await store.get("a/b.txt")).toBeNull();
14
+ });
15
+
16
+ it("rejects keys that escape the root", async () => {
17
+ const store = localStorage(await mkdtemp(join(tmpdir(), "storage-")));
18
+ for (const key of ["../x", "a/../../x", "a\\..\\x", "/etc/passwd", "C:/x", "a/\0b"]) {
19
+ await expect(store.put(key, "no")).rejects.toThrow(/Invalid storage key/);
20
+ }
21
+ });
22
+ });
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ import { defineCommand, flag } from "#core/commands/define";
3
+
4
+ export default defineCommand({
5
+ name: "__moduleKebab__:__nameKebab__",
6
+ description: "__nameTitle__",
7
+ args: z.object({ dryRun: flag() }),
8
+ async run({ dryRun }, { logger }) {
9
+ logger.info({ dryRun }, "__moduleKebab__:__nameKebab__ ran");
10
+ },
11
+ });
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { defineConsumer } from "#core/jobs/define";
3
+
4
+ export const __nameCamel__Payload = z.object({ id: z.string().min(1) });
5
+
6
+ export default defineConsumer({
7
+ topic: "__moduleKebab__.__nameKebab__",
8
+ description: "__nameTitle__",
9
+ schema: __nameCamel__Payload,
10
+ async handle({ id }, { logger }) {
11
+ // must be idempotent: a job can run more than once
12
+ logger.info({ id }, "__moduleKebab__.__nameKebab__ handled");
13
+ },
14
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { processJob } from "#core/jobs/process";
3
+ import { logger } from "#core/logging/logger";
4
+ import { consumers } from "#generated/consumers";
5
+
6
+ describe("__moduleKebab__.__nameKebab__", () => {
7
+ it("handles a valid payload", async () => {
8
+ await expect(
9
+ processJob(consumers, "__moduleKebab__.__nameKebab__", { id: "1" }, { logger, jobId: "t", attempt: 1 }),
10
+ ).resolves.toBeUndefined();
11
+ });
12
+ });
@@ -0,0 +1,20 @@
1
+ import { createRoute } from "@hono/zod-openapi";
2
+ import { jsonContent, success, successSchema } from "#core/http/response";
3
+ import { createRouter, defineRoutes } from "#core/http/routes";
4
+ import { __moduleCamel__Status } from "#modules/__moduleKebab__/services/__moduleKebab__.service";
5
+ import { __moduleCamel__StatusSchema } from "#modules/__moduleKebab__/validators/__moduleKebab__.validators";
6
+
7
+ const router = createRouter();
8
+
9
+ router.openapi(
10
+ createRoute({
11
+ method: "get",
12
+ path: "/status",
13
+ tags: ["__modulePascal__"],
14
+ summary: "__moduleTitle__ module status",
15
+ responses: { 200: jsonContent(successSchema(__moduleCamel__StatusSchema), "Status") },
16
+ }),
17
+ (c) => c.json(success(__moduleCamel__Status()), 200),
18
+ );
19
+
20
+ export default defineRoutes({ app: "api", basePath: "/__moduleKebab__", router });
@@ -0,0 +1,4 @@
1
+ /** Business logic for the __moduleKebab__ module: plain functions, no HTTP. */
2
+ export function __moduleCamel__Status(): { module: string; status: "ok" } {
3
+ return { module: "__moduleKebab__", status: "ok" };
4
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+
4
+ describe("__moduleKebab__", () => {
5
+ it("reports status", async () => {
6
+ const res = await api.get("/api/__moduleKebab__/status");
7
+ expect(res.status).toBe(200);
8
+ expect(res.body).toEqual({ success: true, data: { module: "__moduleKebab__", status: "ok" } });
9
+ });
10
+ });
@@ -0,0 +1,5 @@
1
+ import { z } from "@hono/zod-openapi";
2
+
3
+ export const __moduleCamel__StatusSchema = z
4
+ .object({ module: z.string(), status: z.literal("ok") })
5
+ .openapi("__modulePascal__Status");
@@ -0,0 +1,18 @@
1
+ import { definePermissions } from "#core/authz/catalog";
2
+
3
+ export default definePermissions({
4
+ resources: {
5
+ "__nameKebab__": {
6
+ // scoped actions are checked at own | team | org; use resolveScope() in the service
7
+ read: { scoped: true },
8
+ manage: { scoped: false },
9
+ },
10
+ },
11
+ roleGrants: {
12
+ admin: [
13
+ ["__nameKebab__", "read", "org"],
14
+ ["__nameKebab__", "manage"],
15
+ ],
16
+ member: [["__nameKebab__", "read", "own"]],
17
+ },
18
+ });
@@ -0,0 +1,127 @@
1
+ import { createRoute } from "@hono/zod-openapi";
2
+ import {
3
+ errorResponses,
4
+ jsonBody,
5
+ jsonContent,
6
+ paginated,
7
+ paginatedSchema,
8
+ paginationQuerySchema,
9
+ success,
10
+ successSchema,
11
+ } from "#core/http/response";
12
+ import { createRouter, defineRoutes } from "#core/http/routes";
13
+ // tatara:slot imports
14
+ import {
15
+ create__namePascal__,
16
+ get__namePascal__,
17
+ list__namePluralPascal__,
18
+ remove__namePascal__,
19
+ update__namePascal__,
20
+ } from "#modules/__moduleKebab__/services/__nameKebab__.service";
21
+ import {
22
+ __nameCamel__ParamsSchema,
23
+ __nameCamel__Schema,
24
+ create__namePascal__Schema,
25
+ update__namePascal__Schema,
26
+ } from "#modules/__moduleKebab__/validators/__nameKebab__.validators";
27
+
28
+ const router = createRouter();
29
+ const tags = ["__namePluralPascal__"];
30
+
31
+ router.openapi(
32
+ createRoute({
33
+ method: "get",
34
+ path: "/",
35
+ tags,
36
+ summary: "List __namePluralKebab__",
37
+ // tatara:slot guard-list
38
+ request: { query: paginationQuerySchema },
39
+ responses: {
40
+ 200: jsonContent(paginatedSchema(__nameCamel__Schema), "A page of __namePluralKebab__"),
41
+ ...errorResponses(400),
42
+ // tatara:slot auth-errors
43
+ },
44
+ }),
45
+ async (c) => {
46
+ const query = c.req.valid("query");
47
+ const { items, total } = await list__namePluralPascal__(query);
48
+ return c.json(paginated(items, query.page, query.pageSize, total), 200);
49
+ },
50
+ );
51
+
52
+ router.openapi(
53
+ createRoute({
54
+ method: "get",
55
+ path: "/{id}",
56
+ tags,
57
+ summary: "Get a __nameKebab__",
58
+ // tatara:slot guard-read
59
+ request: { params: __nameCamel__ParamsSchema },
60
+ responses: {
61
+ 200: jsonContent(successSchema(__nameCamel__Schema), "The __nameKebab__"),
62
+ ...errorResponses(400, 404),
63
+ // tatara:slot auth-errors
64
+ },
65
+ }),
66
+ async (c) => c.json(success(await get__namePascal__(c.req.valid("param").id)), 200),
67
+ );
68
+
69
+ router.openapi(
70
+ createRoute({
71
+ method: "post",
72
+ path: "/",
73
+ tags,
74
+ summary: "Create a __nameKebab__",
75
+ // tatara:slot guard-create
76
+ request: { body: jsonBody(create__namePascal__Schema, "The new __nameKebab__") },
77
+ responses: {
78
+ 201: jsonContent(successSchema(__nameCamel__Schema), "Created"),
79
+ ...errorResponses(400),
80
+ // tatara:slot auth-errors
81
+ },
82
+ }),
83
+ async (c) => c.json(success(await create__namePascal__(c.req.valid("json"))), 201),
84
+ );
85
+
86
+ router.openapi(
87
+ createRoute({
88
+ method: "patch",
89
+ path: "/{id}",
90
+ tags,
91
+ summary: "Update a __nameKebab__",
92
+ // tatara:slot guard-update
93
+ request: {
94
+ params: __nameCamel__ParamsSchema,
95
+ body: jsonBody(update__namePascal__Schema, "Fields to change"),
96
+ },
97
+ responses: {
98
+ 200: jsonContent(successSchema(__nameCamel__Schema), "Updated"),
99
+ ...errorResponses(400, 404),
100
+ // tatara:slot auth-errors
101
+ },
102
+ }),
103
+ async (c) =>
104
+ c.json(success(await update__namePascal__(c.req.valid("param").id, c.req.valid("json"))), 200),
105
+ );
106
+
107
+ router.openapi(
108
+ createRoute({
109
+ method: "delete",
110
+ path: "/{id}",
111
+ tags,
112
+ summary: "Delete a __nameKebab__",
113
+ // tatara:slot guard-delete
114
+ request: { params: __nameCamel__ParamsSchema },
115
+ responses: {
116
+ 204: { description: "Deleted" },
117
+ ...errorResponses(400, 404),
118
+ // tatara:slot auth-errors
119
+ },
120
+ }),
121
+ async (c) => {
122
+ await remove__namePascal__(c.req.valid("param").id);
123
+ return c.body(null, 204);
124
+ },
125
+ );
126
+
127
+ export default defineRoutes({ app: "api", basePath: "/__namePluralKebab__", router });
@@ -0,0 +1,59 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+ import { resetDatabase } from "#tests/helpers/db";
4
+ // tatara:slot test-imports
5
+
6
+ const path = "/api/__namePluralKebab__";
7
+ const headers: Record<string, string> = {};
8
+
9
+ interface __namePascal__Body {
10
+ data: { id: string; name: string; description: string | null };
11
+ }
12
+
13
+ beforeEach(async () => {
14
+ await resetDatabase();
15
+ // tatara:slot test-auth
16
+ });
17
+
18
+ describe("__namePluralKebab__", () => {
19
+ it("creates, reads, updates, lists and deletes", async () => {
20
+ const created = await api.post<__namePascal__Body>(path, { name: "First" }, { headers });
21
+ expect(created.status).toBe(201);
22
+ const { id } = created.body.data;
23
+ expect(created.body.data).toMatchObject({ name: "First", description: null });
24
+
25
+ const read = await api.get<__namePascal__Body>(`${path}/${id}`, { headers });
26
+ expect(read.status).toBe(200);
27
+ expect(read.body.data.id).toBe(id);
28
+
29
+ const updated = await api.patch<__namePascal__Body>(
30
+ `${path}/${id}`,
31
+ { description: "Changed" },
32
+ { headers },
33
+ );
34
+ expect(updated.status).toBe(200);
35
+ expect(updated.body.data).toMatchObject({ name: "First", description: "Changed" });
36
+
37
+ await api.post(path, { name: "Second" }, { headers });
38
+ const list = await api.get<{ data: { name: string }[]; meta: { total: number } }>(
39
+ `${path}?pageSize=10`,
40
+ { headers },
41
+ );
42
+ expect(list.status).toBe(200);
43
+ expect(list.body.meta.total).toBe(2);
44
+ expect(list.body.data.map((item) => item.name)).toEqual(["Second", "First"]);
45
+
46
+ const removed = await api.delete(`${path}/${id}`, { headers });
47
+ expect(removed.status).toBe(204);
48
+ const gone = await api.get(`${path}/${id}`, { headers });
49
+ expect(gone.status).toBe(404);
50
+ });
51
+
52
+ it("rejects invalid input", async () => {
53
+ const res = await api.post(path, { name: "" }, { headers });
54
+ expect(res.status).toBe(400);
55
+ expect(res.body).toMatchObject({ success: false, code: "VALIDATION_ERROR" });
56
+ });
57
+
58
+ // tatara:slot test-cases
59
+ });
@@ -0,0 +1,30 @@
1
+ import { z } from "@hono/zod-openapi";
2
+
3
+ export const __nameCamel__Schema = z
4
+ .object({
5
+ id: z.string().openapi({ example: "01920000-0000-7000-8000-000000000000" }),
6
+ name: z.string().openapi({ example: "My __nameTitle__" }),
7
+ description: z.string().nullable(),
8
+ createdAt: z.string().openapi({ format: "date-time" }),
9
+ updatedAt: z.string().openapi({ format: "date-time" }),
10
+ })
11
+ .openapi("__namePascal__");
12
+
13
+ export const create__namePascal__Schema = z
14
+ .object({
15
+ name: z.string().trim().min(1).max(200),
16
+ description: z.string().max(5000).nullable().optional(),
17
+ })
18
+ .openapi("Create__namePascal__");
19
+
20
+ export const update__namePascal__Schema = create__namePascal__Schema
21
+ .partial()
22
+ .openapi("Update__namePascal__");
23
+
24
+ export const __nameCamel__ParamsSchema = z.object({
25
+ id: z.string().min(1).openapi({ param: { name: "id", in: "path" } }),
26
+ });
27
+
28
+ export type __namePascal__ = z.infer<typeof __nameCamel__Schema>;
29
+ export type Create__namePascal__Input = z.infer<typeof create__namePascal__Schema>;
30
+ export type Update__namePascal__Input = z.infer<typeof update__namePascal__Schema>;
@@ -0,0 +1,21 @@
1
+ import { definePermissions } from "#core/authz/catalog";
2
+
3
+ export default definePermissions({
4
+ resources: {
5
+ "__nameKebab__": {
6
+ read: { scoped: false },
7
+ create: { scoped: false },
8
+ update: { scoped: false },
9
+ delete: { scoped: false },
10
+ },
11
+ },
12
+ roleGrants: {
13
+ admin: [
14
+ ["__nameKebab__", "read"],
15
+ ["__nameKebab__", "create"],
16
+ ["__nameKebab__", "update"],
17
+ ["__nameKebab__", "delete"],
18
+ ],
19
+ member: [["__nameKebab__", "read"]],
20
+ },
21
+ });
@@ -0,0 +1,78 @@
1
+ import { count, desc, eq } from "drizzle-orm";
2
+ import { db, type Executor } from "#core/db/client";
3
+ import { newId } from "#core/db/id";
4
+ import { NotFoundError } from "#core/http/errors";
5
+ import type { PaginationQuery } from "#core/http/response";
6
+ import { __namePluralCamel__ } from "#modules/__moduleKebab__/db/__nameKebab__.schema";
7
+ import type {
8
+ Create__namePascal__Input,
9
+ __namePascal__,
10
+ Update__namePascal__Input,
11
+ } from "#modules/__moduleKebab__/validators/__nameKebab__.validators";
12
+
13
+ type Row = typeof __namePluralCamel__.$inferSelect;
14
+
15
+ function to__namePascal__(row: Row): __namePascal__ {
16
+ return {
17
+ id: row.id,
18
+ name: row.name,
19
+ description: row.description,
20
+ createdAt: row.createdAt.toISOString(),
21
+ updatedAt: row.updatedAt.toISOString(),
22
+ };
23
+ }
24
+
25
+ export async function list__namePluralPascal__(
26
+ { page, pageSize }: PaginationQuery,
27
+ executor: Executor = db,
28
+ ) {
29
+ const [rows, totals] = await Promise.all([
30
+ executor
31
+ .select()
32
+ .from(__namePluralCamel__)
33
+ .orderBy(desc(__namePluralCamel__.id))
34
+ .limit(pageSize)
35
+ .offset((page - 1) * pageSize),
36
+ executor.select({ total: count() }).from(__namePluralCamel__),
37
+ ]);
38
+ return { items: rows.map(to__namePascal__), total: totals[0]?.total ?? 0 };
39
+ }
40
+
41
+ export async function get__namePascal__(id: string, executor: Executor = db): Promise<__namePascal__> {
42
+ const [row] = await executor
43
+ .select()
44
+ .from(__namePluralCamel__)
45
+ .where(eq(__namePluralCamel__.id, id))
46
+ .limit(1);
47
+ if (!row) throw new NotFoundError("__nameTitle__");
48
+ return to__namePascal__(row);
49
+ }
50
+
51
+ export async function create__namePascal__(
52
+ input: Create__namePascal__Input,
53
+ executor: Executor = db,
54
+ ): Promise<__namePascal__> {
55
+ const id = newId();
56
+ await executor
57
+ .insert(__namePluralCamel__)
58
+ .values({ id, name: input.name, description: input.description ?? null });
59
+ return get__namePascal__(id, executor);
60
+ }
61
+
62
+ export async function update__namePascal__(
63
+ id: string,
64
+ input: Update__namePascal__Input,
65
+ executor: Executor = db,
66
+ ): Promise<__namePascal__> {
67
+ await get__namePascal__(id, executor);
68
+ await executor
69
+ .update(__namePluralCamel__)
70
+ .set({ ...input, updatedAt: new Date() })
71
+ .where(eq(__namePluralCamel__.id, id));
72
+ return get__namePascal__(id, executor);
73
+ }
74
+
75
+ export async function remove__namePascal__(id: string, executor: Executor = db): Promise<void> {
76
+ await get__namePascal__(id, executor);
77
+ await executor.delete(__namePluralCamel__).where(eq(__namePluralCamel__.id, id));
78
+ }
@@ -0,0 +1,9 @@
1
+ import { mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
2
+
3
+ export const __namePluralCamel__ = mysqlTable("__namePluralSnake__", {
4
+ id: varchar({ length: 36 }).primaryKey(),
5
+ name: varchar({ length: 200 }).notNull(),
6
+ description: text(),
7
+ createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
8
+ updatedAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
9
+ });
@@ -0,0 +1,9 @@
1
+ import { pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
2
+
3
+ export const __namePluralCamel__ = pgTable("__namePluralSnake__", {
4
+ id: uuid().primaryKey(),
5
+ name: varchar({ length: 200 }).notNull(),
6
+ description: text(),
7
+ createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
8
+ updatedAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
9
+ });
@@ -0,0 +1,9 @@
1
+ model __namePascal__ {
2
+ id String @id @db.VarChar(36)
3
+ name String @db.VarChar(200)
4
+ description String? @db.Text
5
+ createdAt DateTime @default(now()) @map("created_at")
6
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
7
+
8
+ @@map("__namePluralSnake__")
9
+ }
@@ -0,0 +1,73 @@
1
+ import { db, type Executor } from "#core/db/client";
2
+ import { newId } from "#core/db/id";
3
+ import { NotFoundError } from "#core/http/errors";
4
+ import type { PaginationQuery } from "#core/http/response";
5
+ import type {
6
+ Create__namePascal__Input,
7
+ __namePascal__,
8
+ Update__namePascal__Input,
9
+ } from "#modules/__moduleKebab__/validators/__nameKebab__.validators";
10
+
11
+ interface Row {
12
+ id: string;
13
+ name: string;
14
+ description: string | null;
15
+ createdAt: Date;
16
+ updatedAt: Date;
17
+ }
18
+
19
+ function to__namePascal__(row: Row): __namePascal__ {
20
+ return {
21
+ id: row.id,
22
+ name: row.name,
23
+ description: row.description,
24
+ createdAt: row.createdAt.toISOString(),
25
+ updatedAt: row.updatedAt.toISOString(),
26
+ };
27
+ }
28
+
29
+ export async function list__namePluralPascal__(
30
+ { page, pageSize }: PaginationQuery,
31
+ executor: Executor = db,
32
+ ) {
33
+ const [rows, total] = await Promise.all([
34
+ executor.__nameCamel__.findMany({
35
+ orderBy: { id: "desc" },
36
+ skip: (page - 1) * pageSize,
37
+ take: pageSize,
38
+ }),
39
+ executor.__nameCamel__.count(),
40
+ ]);
41
+ return { items: rows.map(to__namePascal__), total };
42
+ }
43
+
44
+ export async function get__namePascal__(id: string, executor: Executor = db): Promise<__namePascal__> {
45
+ const row = await executor.__nameCamel__.findUnique({ where: { id } });
46
+ if (!row) throw new NotFoundError("__nameTitle__");
47
+ return to__namePascal__(row);
48
+ }
49
+
50
+ export async function create__namePascal__(
51
+ input: Create__namePascal__Input,
52
+ executor: Executor = db,
53
+ ): Promise<__namePascal__> {
54
+ const row = await executor.__nameCamel__.create({
55
+ data: { id: newId(), name: input.name, description: input.description ?? null },
56
+ });
57
+ return to__namePascal__(row);
58
+ }
59
+
60
+ export async function update__namePascal__(
61
+ id: string,
62
+ input: Update__namePascal__Input,
63
+ executor: Executor = db,
64
+ ): Promise<__namePascal__> {
65
+ await get__namePascal__(id, executor);
66
+ const row = await executor.__nameCamel__.update({ where: { id }, data: input });
67
+ return to__namePascal__(row);
68
+ }
69
+
70
+ export async function remove__namePascal__(id: string, executor: Executor = db): Promise<void> {
71
+ await get__namePascal__(id, executor);
72
+ await executor.__nameCamel__.delete({ where: { id } });
73
+ }