create-nexwire 4.2.2 → 4.2.4

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 (90) hide show
  1. package/express/package.json +48 -23
  2. package/express/src/database/migrations/sqlite/0000_init.sql +47 -60
  3. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
  4. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  5. package/express/src/database/schema.ts +2 -3
  6. package/express/src/framework/database/app-schema.ts +37 -0
  7. package/express/src/framework/database/connection.ts +198 -196
  8. package/express/src/framework/database/seed.ts +35 -22
  9. package/express/src/framework/facade.ts +35 -5
  10. package/express/src/framework/kernel.ts +54 -52
  11. package/express/src/framework/maker-cli/db/core.mjs +291 -288
  12. package/express/src/framework/maker-cli/index.mjs +2 -0
  13. package/express/src/framework/maker-cli/module/core.mjs +155 -87
  14. package/express/src/framework/maker-cli/module/index.mjs +13 -4
  15. package/express/src/framework/maker-cli/provider/core.mjs +49 -0
  16. package/express/src/framework/maker-cli/provider/index.mjs +12 -0
  17. package/express/src/framework/maker-cli/stubs/notification/controller.ts.stub +76 -77
  18. package/express/src/framework/maker-cli/stubs/notification/job.ts.stub +11 -13
  19. package/express/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
  20. package/express/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
  21. package/express/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
  22. package/express/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
  23. package/express/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
  24. package/express/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
  25. package/express/src/framework/maker-cli/stubs/provider/module-barrel.ts.stub +19 -0
  26. package/express/src/framework/maker-cli/stubs/provider/module.ts.stub +21 -0
  27. package/express/src/framework/maker-cli/stubs/provider/name.ts.stub +21 -0
  28. package/express/src/framework/maker-cli/utils/help.mjs +6 -1
  29. package/express/src/framework/notification/index.ts +65 -47
  30. package/express/src/framework/providers/adapter.ts +139 -0
  31. package/express/src/framework/providers/contracts.ts +126 -0
  32. package/express/src/framework/providers/loader.ts +105 -0
  33. package/express/src/framework/providers/registry.ts +88 -0
  34. package/express/src/middlewares/auth-middleware.ts +65 -38
  35. package/express/src/providers/README.md +107 -0
  36. package/express/src/resources/src/main.ts +1 -1
  37. package/express/src/resources/src/pages/auth/forgetPassword.vue +1 -1
  38. package/express/src/resources/src/pages/auth/login.vue +1 -1
  39. package/express/src/resources/src/pages/auth/register.vue +1 -1
  40. package/express/src/resources/src/pages/auth/resetPassword.vue +1 -1
  41. package/express/src/resources/src/pages/auth/verifyEmail.vue +1 -1
  42. package/express/src/resources/src/pages/dashboard/index.vue +66 -66
  43. package/express/src/storage/database/nexwire.sqlite +0 -0
  44. package/hono/package.json +44 -19
  45. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -60
  46. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -89
  47. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  48. package/hono/src/database/schema.ts +2 -3
  49. package/hono/src/framework/database/app-schema.ts +37 -0
  50. package/hono/src/framework/database/connection.ts +198 -196
  51. package/hono/src/framework/database/seed.ts +35 -22
  52. package/hono/src/framework/facade.ts +59 -29
  53. package/hono/src/framework/kernel.ts +50 -48
  54. package/hono/src/framework/maker-cli/db/core.mjs +291 -288
  55. package/hono/src/framework/maker-cli/index.mjs +2 -0
  56. package/hono/src/framework/maker-cli/module/core.mjs +123 -52
  57. package/hono/src/framework/maker-cli/module/index.mjs +13 -4
  58. package/hono/src/framework/maker-cli/provider/core.mjs +49 -0
  59. package/hono/src/framework/maker-cli/provider/index.mjs +12 -0
  60. package/hono/src/framework/maker-cli/stubs/notification/controller.ts.stub +90 -85
  61. package/hono/src/framework/maker-cli/stubs/notification/job.ts.stub +10 -12
  62. package/hono/src/framework/maker-cli/stubs/notification/model.ts.stub +29 -0
  63. package/hono/src/framework/maker-cli/stubs/notification/provider.ts.stub +174 -0
  64. package/hono/src/framework/maker-cli/stubs/notification/route.api.ts.stub +11 -10
  65. package/hono/src/framework/maker-cli/stubs/notification/route.plain.ts.stub +1 -1
  66. package/hono/src/framework/maker-cli/stubs/notification/schema.plain.ts.stub +25 -25
  67. package/hono/src/framework/maker-cli/stubs/notification/schema.ts.stub +25 -25
  68. package/hono/src/framework/maker-cli/stubs/provider/module-barrel.ts.stub +19 -0
  69. package/hono/src/framework/maker-cli/stubs/provider/module.ts.stub +21 -0
  70. package/hono/src/framework/maker-cli/stubs/provider/name.ts.stub +21 -0
  71. package/hono/src/framework/maker-cli/utils/help.mjs +6 -1
  72. package/hono/src/framework/notification/index.ts +65 -47
  73. package/hono/src/framework/providers/adapter.ts +139 -0
  74. package/hono/src/framework/providers/contracts.ts +126 -0
  75. package/hono/src/framework/providers/loader.ts +105 -0
  76. package/hono/src/framework/providers/registry.ts +88 -0
  77. package/hono/src/middlewares/auth-middleware.ts +65 -38
  78. package/hono/src/providers/README.md +107 -0
  79. package/hono/src/resources/src/main.ts +1 -1
  80. package/hono/src/resources/src/pages/auth/forgetPassword.vue +1 -1
  81. package/hono/src/resources/src/pages/auth/login.vue +1 -1
  82. package/hono/src/resources/src/pages/auth/register.vue +1 -1
  83. package/hono/src/resources/src/pages/auth/resetPassword.vue +1 -1
  84. package/hono/src/resources/src/pages/auth/verifyEmail.vue +1 -1
  85. package/hono/src/resources/src/pages/dashboard/index.vue +66 -66
  86. package/hono/src/storage/database/nexwire.sqlite +0 -0
  87. package/package.json +10 -10
  88. package/src/index.js +14 -6
  89. package/express/src/modules/auth/database/models/notification.ts +0 -24
  90. package/hono/src/modules/auth/database/models/notification.ts +0 -24
@@ -45,6 +45,11 @@ const STUBS = {
45
45
  facade: {
46
46
  named: "facade/name.ts.stub"
47
47
  },
48
+ provider: {
49
+ named: "provider/name.ts.stub",
50
+ module: "provider/module.ts.stub",
51
+ moduleBarrel: "provider/module-barrel.ts.stub"
52
+ },
48
53
  helper: {
49
54
  named: "helper/name.ts.stub"
50
55
  },
@@ -68,6 +73,8 @@ const STUBS = {
68
73
  },
69
74
  routeApi: "notification/route.api.ts.stub",
70
75
  routePlain: "notification/route.plain.ts.stub",
76
+ model: "notification/model.ts.stub",
77
+ provider: "notification/provider.ts.stub",
71
78
  job: "notification/job.ts.stub"
72
79
  },
73
80
  test: {
@@ -157,6 +164,28 @@ function parsePanelPath(flags) {
157
164
  return segments.join("/");
158
165
  }
159
166
 
167
+ /**
168
+ * Parse `--notifiable=<type>` into the owner class written into the generated
169
+ * controller. Accepts both `--notifiable=user` and `--notifiable user`, and
170
+ * defaults to `user` so the common case needs no flag.
171
+ */
172
+ function parseNotifiable(flags) {
173
+ const matches = flags
174
+ .map((flag, index) => ({ flag, index }))
175
+ .filter(({ flag }) => typeof flag === "string" && flag.startsWith("--notifiable"));
176
+ if (matches.length === 0) return "user";
177
+ if (matches.length > 1) throw new Error("--notifiable can only be given once.");
178
+
179
+ const [{ flag, index }] = matches;
180
+ let raw = flag.slice("--notifiable".length).replace(/^=/, "").trim();
181
+ if (!raw && typeof flags[index + 1] === "string" && !flags[index + 1].startsWith("--")) raw = flags[index + 1].trim();
182
+ if (!raw) throw new Error("--notifiable needs a value, e.g. --notifiable=user");
183
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(raw)) {
184
+ throw new Error(`Invalid --notifiable value "${raw}". Use lowercase letters, numbers, dashes and underscores.`);
185
+ }
186
+ return raw;
187
+ }
188
+
160
189
  /** True when the path is a directory holding at least one entry. */
161
190
  async function dirHasFiles(target) {
162
191
  try {
@@ -167,6 +196,24 @@ async function dirHasFiles(target) {
167
196
  }
168
197
  }
169
198
 
199
+ /**
200
+ * Path of the notification store already shipped by any module, or "".
201
+ *
202
+ * The table/model/provider is a singleton: two modules shipping it would
203
+ * generate the same `notifications` table twice and register two providers for
204
+ * one contract. `module:make-notification` therefore writes the store only when
205
+ * no module owns it yet, and otherwise adds just another HTTP surface over it.
206
+ */
207
+ async function findNotificationStore() {
208
+ const backendSrc = path.resolve(process.cwd(), "src");
209
+ const files = await glob("modules/**/database/models/notification.{ts,js}", {
210
+ cwd: backendSrc,
211
+ nodir: true,
212
+ windowsPathsNoEscape: true
213
+ });
214
+ return files.sort()[0] ? path.join(backendSrc, files.sort()[0]) : "";
215
+ }
216
+
170
217
  /**
171
218
  * Resolve the module a subcommand targets.
172
219
  *
@@ -514,7 +561,15 @@ export async function makeRoute(rawModule, rawControllerOrFlag, extraFlags = [])
514
561
  console.log(`Route ready: ${path.relative(process.cwd(), routePath)}`);
515
562
  }
516
563
 
517
- /** Generate a notification module with controller, routes, and job. */
564
+ /**
565
+ * Generate the notification HTTP surface, plus the shared store the first time.
566
+ *
567
+ * The controller/schema/routes are always written: they are what lets a project
568
+ * expose `/api/notification/*` for a panel. The model, provider and mail job are
569
+ * written only when no module already ships the store, because the store is a
570
+ * singleton — a second copy would create a duplicate `notifications` table and
571
+ * register a second provider for the same contract.
572
+ */
518
573
  export async function makeNotificationModule(rawName = "notification", flags = []) {
519
574
  const { moduleName, leaf } = resolveModuleRef(rawName, flags);
520
575
  const root = moduleRoot(moduleName);
@@ -523,22 +578,39 @@ export async function makeNotificationModule(rawName = "notification", flags = [
523
578
  throw new Error(`Module already exists: src/modules/${moduleName}. Re-run with --force to overwrite its files.`);
524
579
  }
525
580
  const openApi = openApiEnabled();
581
+ const notifiable = parseNotifiable(flags);
582
+ const vars = { module: moduleName, leaf, notifiable };
526
583
 
527
- await writeFiles(root, {
528
- [`controllers/${leaf}.ts`]: await stub(STUBS.notification.controller, {
529
- module: moduleName
530
- }),
531
- [`schemas/${leaf}.ts`]: await stub(openApi ? STUBS.notification.schema.openapi : STUBS.notification.schema.plain, {
532
- module: moduleName
533
- }),
534
- "routes/index.ts": await stub(openApi ? STUBS.notification.routeApi : STUBS.notification.routePlain, { module: moduleName }),
535
- [`jobs/${leaf}.ts`]: await stub(STUBS.notification.job, { module: moduleName })
536
- });
584
+ const files = {
585
+ [`controllers/${leaf}.ts`]: await stub(STUBS.notification.controller, vars),
586
+ [`schemas/${leaf}.ts`]: await stub(openApi ? STUBS.notification.schema.openapi : STUBS.notification.schema.plain, vars),
587
+ "routes/index.ts": await stub(openApi ? STUBS.notification.routeApi : STUBS.notification.routePlain, vars)
588
+ };
589
+
590
+ const existingStore = await findNotificationStore();
591
+ const ownsStore = existingStore ? path.resolve(existingStore).startsWith(root + path.sep) : false;
592
+ const writesStore = !existingStore || (force && ownsStore);
593
+
594
+ if (writesStore) {
595
+ files["database/models/notification.ts"] = await stub(STUBS.notification.model, vars);
596
+ files["providers/notification.ts"] = await stub(STUBS.notification.provider, vars);
597
+ files["jobs/mail.ts"] = await stub(STUBS.notification.job, vars);
598
+ }
599
+
600
+ await writeFiles(root, files);
537
601
 
538
602
  console.log(`Notification module ready: ${moduleName}`);
603
+ if (writesStore) {
604
+ console.log("Notification store included: model, provider, mail job");
605
+ const panelFlag = panelOf(moduleName) ? ` --path=${panelOf(moduleName)}` : "";
606
+ console.log(`Next: bun maker module:migrate ${leaf}${panelFlag}`);
607
+ } else {
608
+ console.log(`Notification store reused: ${path.relative(process.cwd(), existingStore)}`);
609
+ }
539
610
  if (panelOf(moduleName)) console.log(`Panel: ${panelOf(moduleName)} -> /api/${moduleName}`);
611
+ console.log(`Notifiable type: ${notifiable}`);
540
612
  console.log(`Route style: ${openApi ? "openapi" : "plain"}`);
541
- console.log("See docs (packages/docs/guide/notification.md) for Vue UI integration.");
613
+ console.log("See docs (packages/docs/guide/notification.md) for usage and UI integration.");
542
614
  }
543
615
 
544
616
  /** Soft-delete a module by moving it to storage trash. */
@@ -736,6 +808,31 @@ export async function makeLocalMiddleware(rawModule, rawNameOrFlag, extraFlags =
736
808
  await writeFileSafe(path.join(root, `middlewares/${name}.ts`), content, flags, "Middleware file");
737
809
  }
738
810
 
811
+ /**
812
+ * Generate a provider file for an existing module.
813
+ *
814
+ * The file lands in the module's providers/ folder, which the framework
815
+ * imports at boot just like src/providers/. That is what lets a module offer
816
+ * an implementation to the whole application without the application knowing
817
+ * anything about the module's internals.
818
+ */
819
+ export async function makeModuleProvider(rawModule, rawNameOrFlag, extraFlags = []) {
820
+ const { moduleName, leaf } = resolveModuleRef(rawModule, rawNameOrFlag, extraFlags);
821
+ const { name, flags } = parseNameArg(rawNameOrFlag, extraFlags, "Provider name", leaf);
822
+ const root = await assertModuleExists(moduleName);
823
+ const filePath = path.join(root, `providers/${name}.ts`);
824
+ if (hasFlag(flags, "--dry-run")) {
825
+ console.log(`Would create provider file: ${path.relative(process.cwd(), filePath)}`);
826
+ return;
827
+ }
828
+ const content = await stub(STUBS.provider.module, {
829
+ module: moduleName,
830
+ name,
831
+ camel: camelCase(name)
832
+ });
833
+ await writeFileSafe(filePath, content, flags, "Provider file");
834
+ }
835
+
739
836
  /** Generate a types file for an existing module. */
740
837
  export async function makeType(rawModule, rawNameOrFlag, extraFlags = []) {
741
838
  const { moduleName, leaf } = resolveModuleRef(rawModule, rawNameOrFlag, extraFlags);
@@ -923,8 +1020,16 @@ export async function generateSchema(options = {}) {
923
1020
  if (!options.silent) console.log(`Generated database schema with ${files.length} model file(s)`);
924
1021
  }
925
1022
 
926
- /** Generate a temporary schema file for a single module (used for module:generate/migrate). */
927
- async function generateModuleSchemaTemp(moduleName) {
1023
+ /**
1024
+ * Generate and run migrations that include a module.
1025
+ *
1026
+ * Drizzle diffs the schema against the last snapshot, so generating from a
1027
+ * module-only schema would emit `DROP TABLE` for every other module (auth
1028
+ * included). The full schema is used instead: it adds the module's tables and
1029
+ * keeps the rest of the database intact.
1030
+ */
1031
+ export async function runModuleMigrate(rawModuleName, rawArgs = []) {
1032
+ const { moduleName } = resolveModuleRef(rawModuleName, rawArgs);
928
1033
  await assertModuleExists(moduleName);
929
1034
  const backendSrc = path.resolve(process.cwd(), "src");
930
1035
  const files = await glob(`modules/${moduleName}/database/models/*.{ts,js}`, {
@@ -933,47 +1038,13 @@ async function generateModuleSchemaTemp(moduleName) {
933
1038
  windowsPathsNoEscape: true
934
1039
  });
935
1040
  if (!files.length) throw new Error(`No model files found for module '${moduleName}'.`);
936
- const exports = [];
937
- const tempDir = path.resolve(process.cwd(), "src/storage/tmp");
938
- await fs.mkdir(tempDir, { recursive: true });
939
- // Flatten the panel path: "admin/testme" must not become a nested
940
- // storage/tmp/schema.admin/testme.<ts>.ts path.
941
- const slug = moduleName.replaceAll("/", "-");
942
- const tempSchemaPath = path.join(tempDir, `schema.${slug}.${Date.now()}.ts`);
943
- const tempSchemaDir = path.dirname(tempSchemaPath);
944
- for (const file of files.sort()) {
945
- const absoluteFile = path.join(backendSrc, file);
946
- const relativePath = path
947
- .relative(tempSchemaDir, absoluteFile)
948
- .replace(/\\/g, "/")
949
- .replace(/\.(ts|js)$/, ".ts");
950
- const importPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
951
- exports.push(`export * from "${importPath}";`);
952
- }
953
- await fs.writeFile(tempSchemaPath, `${exports.join("\n")}\n`);
954
- return { tempSchemaPath, modelCount: files.length };
955
- }
956
-
957
- /** Generate and run migrations for a single module using a temporary schema. */
958
- export async function runModuleMigrate(rawModuleName, rawArgs = []) {
959
- const { moduleName } = resolveModuleRef(rawModuleName, rawArgs);
960
1041
  await syncMigrationDialect();
961
1042
  await ensureMigrationMeta();
962
1043
  await ensureDatabaseDirectory();
963
- const keepTemp = rawArgs.includes("--keep-temp");
964
- const { tempSchemaPath, modelCount } = await generateModuleSchemaTemp(moduleName);
965
- const drizzleSchemaPath = `./${path.relative(process.cwd(), tempSchemaPath).replace(/\\/g, "/")}`;
966
- const previousSchema = process.env.DRIZZLE_SCHEMA;
967
- try {
968
- process.env.DRIZZLE_SCHEMA = drizzleSchemaPath;
969
- await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), await drizzleGenerateArgs());
970
- await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), ["migrate"]);
971
- console.log(`Module migration complete: ${moduleName} (${modelCount} model file(s))`);
972
- } finally {
973
- if (previousSchema == null) delete process.env.DRIZZLE_SCHEMA;
974
- else process.env.DRIZZLE_SCHEMA = previousSchema;
975
- if (!keepTemp) await fs.rm(tempSchemaPath, { force: true }).catch(() => { });
976
- }
1044
+ await generateSchema();
1045
+ await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), await drizzleGenerateArgs());
1046
+ await runNodeScript(packageScript("drizzle-kit", "bin.cjs"), ["migrate"]);
1047
+ console.log(`Module migration complete: ${moduleName} (${files.length} model file(s))`);
977
1048
  }
978
1049
 
979
1050
  /** Generate a unit test file for a module. */
@@ -12,6 +12,7 @@ import {
12
12
  makeLocalMiddleware,
13
13
  makeModel,
14
14
  makeModule,
15
+ makeModuleProvider,
15
16
  makeModules,
16
17
  makeNotificationModule,
17
18
  makeRoute,
@@ -28,7 +29,7 @@ import {
28
29
  export function registerModuleCommands(program, rawArgs) {
29
30
  program
30
31
  .command("module:make <names...>")
31
- .description("Create one or more modules with facade, schema, service, controller, route, model, and seeder folders")
32
+ .description("Create one or more modules with facade, providers, schema, service, controller, route, model, and seeder folders")
32
33
  .option("--path <panel>", "Nest the module(s) under a panel folder (e.g. --path=admin)")
33
34
  .option("--force", "Overwrite the files of modules that already exist")
34
35
  .allowUnknownOption(true)
@@ -38,8 +39,9 @@ export function registerModuleCommands(program, rawArgs) {
38
39
  });
39
40
  program
40
41
  .command("module:make-notification [name]")
41
- .description("Generate notification backend module (controller, routes, job)")
42
+ .description("Generate the notification HTTP module, and the shared store the first time")
42
43
  .option("--path <panel>", "Nest the module under a panel folder (e.g. --path=admin)")
44
+ .option("--notifiable <type>", "Owner type recorded with each notification (default: user)")
43
45
  .option("--force", "Overwrite the files of a module that already exists")
44
46
  .allowUnknownOption(true)
45
47
  .action(async (name) => {
@@ -128,6 +130,14 @@ export function registerModuleCommands(program, rawArgs) {
128
130
  .option("--path <panel>", "Address a module inside a panel folder (e.g. --path=admin)")
129
131
  .allowUnknownOption(true)
130
132
  .action(async (moduleName, name) => makeLocalMiddleware(moduleName, name, rawArgs.slice(name ? 3 : 2)));
133
+ program
134
+ .command("module:make-provider <module> [name]")
135
+ .description("Generate a provider file in the module's providers folder")
136
+ .option("--force", "Overwrite existing provider file")
137
+ .option("--dry-run", "Print what would be created without executing")
138
+ .option("--path <panel>", "Address a module inside a panel folder (e.g. --path=admin)")
139
+ .allowUnknownOption(true)
140
+ .action(async (moduleName, name) => makeModuleProvider(moduleName, name, rawArgs.slice(name ? 3 : 2)));
131
141
  program
132
142
  .command("module:make-type <module> [name]")
133
143
  .description("Generate a types file for an existing module")
@@ -186,8 +196,7 @@ export function registerModuleCommands(program, rawArgs) {
186
196
  });
187
197
  program
188
198
  .command("module:migrate <module>")
189
- .description("Generate module-only schema, generate migration, then run migrate")
190
- .option("--keep-temp", "Keep temporary schema file after migration")
199
+ .description("Generate and run a migration that includes the module")
191
200
  .option("--path <panel>", "Address a module inside a panel folder (e.g. --path=admin)")
192
201
  .allowUnknownOption(true)
193
202
  .action(async (moduleName) => runModuleMigrate(moduleName, rawArgs));
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { hasFlag } from "../utils/flags.mjs";
4
+ import { assertName, camelCase } from "../utils/naming.mjs";
5
+
6
+ const stubsRoot = path.resolve(import.meta.dirname, "../stubs");
7
+
8
+ /** Read a stub file and replace {{key}} placeholders, removing unfilled ones. */
9
+ async function stub(name, values = {}) {
10
+ let content = await fs.readFile(path.join(stubsRoot, name), "utf8");
11
+ for (const [key, value] of Object.entries(values)) {
12
+ content = content.replaceAll(`{{${key}}}`, String(value));
13
+ }
14
+ return content.replace(/\{\{[A-Z0-9_]+\}\}/g, "");
15
+ }
16
+
17
+ /**
18
+ * Why: Generates the app-wide provider file.
19
+ * When: A developer wants to plug an implementation into the whole app.
20
+ * Where: src/providers/<name>.ts - imported automatically at boot.
21
+ * How: Writes the stub and prints how to reach the provider from anywhere.
22
+ */
23
+ export async function makeProvider(rawName, flags = []) {
24
+ const name = assertName(rawName, "Provider name");
25
+ const camel = camelCase(name);
26
+ const dryRun = hasFlag(flags, "--dry-run");
27
+ const force = hasFlag(flags, "--force") || hasFlag(flags, "--yes");
28
+
29
+ const filePath = path.resolve(process.cwd(), "src/providers", `${name}.ts`);
30
+ if (dryRun) {
31
+ console.log(`Would create provider file: ${path.relative(process.cwd(), filePath)}`);
32
+ return;
33
+ }
34
+
35
+ let exists = false;
36
+ try {
37
+ await fs.access(filePath);
38
+ exists = true;
39
+ } catch {}
40
+ if (exists && !force) {
41
+ throw new Error(`Provider already exists: ${path.relative(process.cwd(), filePath)}. Re-run with --force to overwrite.`);
42
+ }
43
+
44
+ const content = await stub("provider/name.ts.stub", { name, camel });
45
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
46
+ await fs.writeFile(filePath, content);
47
+ console.log(`Provider ready: ${path.relative(process.cwd(), filePath)}`);
48
+ console.log(`It is imported at boot. Reach it with: getProvider("${name}")`);
49
+ }
@@ -0,0 +1,12 @@
1
+ import { makeProvider } from "./core.mjs";
2
+
3
+ /** Register provider:* subcommands on the CLI program. */
4
+ export function registerProviderCommands(program, rawArgs) {
5
+ program
6
+ .command("provider:make <name>")
7
+ .description("Generate a provider file in src/providers")
8
+ .option("--force", "Overwrite existing provider file")
9
+ .option("--dry-run", "Print what would be created without executing")
10
+ .allowUnknownOption(true)
11
+ .action(async (name) => makeProvider(name, rawArgs.filter((arg) => arg.startsWith("--"))));
12
+ }
@@ -1,86 +1,91 @@
1
- import type { Handler } from "hono";
2
- import { and, count, desc, eq, sql } from "drizzle-orm";
3
- import { db } from "@/framework/facade.js";
4
- import { notifications } from "@/modules/auth/database/models/notifications.js";
5
-
6
- export const list: Handler = async (c: any) => {
7
- const userId = c.get("auth").id;
8
- const page = Math.max(1, Number(c.req.query("page")) || 1);
9
- const perPage = Math.min(50, Math.max(1, Number(c.req.query("perPage")) || 20));
10
-
11
- const unread = await db.select({ count: count() }).from(notifications)
12
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
13
-
14
- const total = await db.select({ count: count() }).from(notifications)
15
- .where(eq(notifications.userId, userId));
16
-
17
- const rows = await db.select().from(notifications)
18
- .where(eq(notifications.userId, userId))
19
- .orderBy(desc(notifications.createdAt))
20
- .limit(perPage).offset((page - 1) * perPage);
21
-
22
- return c.json({
23
- data: rows.map(normalize),
24
- total: total[0].count,
25
- unread: unread[0].count,
26
- page,
27
- perPage,
28
- });
29
- };
30
-
31
- export const unreadCount: Handler = async (c: any) => {
32
- const userId = c.get("auth").id;
33
- const rows = await db.select({ count: count() }).from(notifications)
34
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
35
- return c.json({ count: rows[0].count });
36
- };
37
-
38
- export const markRead: Handler = async (c: any) => {
39
- const userId = c.get("auth").id;
40
- const id = Number(c.req.param("id"));
41
-
42
- await db.update(notifications)
43
- .set({ readAt: sql`now()` })
44
- .where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
45
-
46
- return c.json({ ok: true });
47
- };
48
-
49
- export const markAllRead: Handler = async (c: any) => {
50
- const userId = c.get("auth").id;
51
-
52
- await db.update(notifications)
53
- .set({ readAt: sql`now()` })
54
- .where(and(eq(notifications.userId, userId), sql`${notifications.readAt} IS NULL`));
55
-
56
- return c.json({ ok: true });
57
- };
58
-
59
- export const remove: Handler = async (c: any) => {
60
- const userId = c.get("auth").id;
61
- const id = Number(c.req.param("id"));
62
-
63
- await db.delete(notifications)
64
- .where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
65
-
66
- return c.json({ ok: true });
67
- };
68
-
69
- function normalize(row: typeof notifications.$inferSelect) {
70
- let parsed: unknown = null;
71
- if (row.data) {
72
- try { parsed = JSON.parse(row.data); } catch {}
73
- }
74
- return {
75
- id: row.id,
76
- userId: row.userId,
77
- type: row.type,
78
- title: row.title,
79
- body: row.body,
80
- data: parsed,
81
- link: row.link,
82
- readAt: row.readAt?.toISOString() || null,
83
- createdAt: row.createdAt.toISOString(),
84
- };
85
- }
1
+ import type { Handler } from "hono";
2
+ import { HttpStatusCodes, resolveNotificationProvider } from "@/framework/facade.js";
3
+ import type { NotifiableTarget } from "@/framework/providers/contracts.js";
86
4
 
5
+ /** Read the authenticated owner. A missing auth context means the request is anonymous. */
6
+ function targetFrom(c: any): NotifiableTarget | null {
7
+ const auth = c.get("auth");
8
+ if (!auth || auth.id === undefined || auth.id === null) return null;
9
+ return { type: "{{notifiable}}", id: auth.id };
10
+ }
11
+
12
+ export const list: Handler = async (c: any) => {
13
+ const provider = await resolveNotificationProvider();
14
+ if (!provider) {
15
+ return c.json(
16
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
17
+ HttpStatusCodes.SERVICE_UNAVAILABLE
18
+ );
19
+ }
20
+
21
+ const target = targetFrom(c);
22
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
23
+
24
+ const page = Math.max(1, Number(c.req.query("page")) || 1);
25
+ const perPage = Math.min(50, Math.max(1, Number(c.req.query("perPage")) || 20));
26
+
27
+ return c.json(await provider.list(target, { page, perPage }));
28
+ };
29
+
30
+ export const unreadCount: Handler = async (c: any) => {
31
+ const provider = await resolveNotificationProvider();
32
+ if (!provider) {
33
+ return c.json(
34
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
35
+ HttpStatusCodes.SERVICE_UNAVAILABLE
36
+ );
37
+ }
38
+
39
+ const target = targetFrom(c);
40
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
41
+
42
+ return c.json({ count: await provider.unreadCount(target) });
43
+ };
44
+
45
+ export const markRead: Handler = async (c: any) => {
46
+ const provider = await resolveNotificationProvider();
47
+ if (!provider) {
48
+ return c.json(
49
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
50
+ HttpStatusCodes.SERVICE_UNAVAILABLE
51
+ );
52
+ }
53
+
54
+ const target = targetFrom(c);
55
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
56
+
57
+ await provider.markRead(target, c.req.param("id"));
58
+ return c.json({ ok: true });
59
+ };
60
+
61
+ export const markAllRead: Handler = async (c: any) => {
62
+ const provider = await resolveNotificationProvider();
63
+ if (!provider) {
64
+ return c.json(
65
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
66
+ HttpStatusCodes.SERVICE_UNAVAILABLE
67
+ );
68
+ }
69
+
70
+ const target = targetFrom(c);
71
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
72
+
73
+ await provider.markAllRead(target);
74
+ return c.json({ ok: true });
75
+ };
76
+
77
+ export const remove: Handler = async (c: any) => {
78
+ const provider = await resolveNotificationProvider();
79
+ if (!provider) {
80
+ return c.json(
81
+ { message: "Notification provider not available. Restore src/modules/notification or register one in src/providers/." },
82
+ HttpStatusCodes.SERVICE_UNAVAILABLE
83
+ );
84
+ }
85
+
86
+ const target = targetFrom(c);
87
+ if (!target) return c.json({ message: "Unauthorized" }, HttpStatusCodes.UNAUTHORIZED);
88
+
89
+ await provider.remove(target, c.req.param("id"));
90
+ return c.json({ ok: true });
91
+ };
@@ -1,13 +1,11 @@
1
- import { mail } from "@/framework/support/mail.js";
2
- import { logger } from "@/framework/support/logger.js";
3
- import { shouldQueue } from "@/framework/queue/queue.js";
4
-
5
- shouldQueue("{{module}}:mail", "mail", async (job) => {
6
- const { to, subject, html } = job.data;
7
-
8
- await mail.sendMail({ to, subject, html });
9
- logger.info("Notification mail sent", { to, subject });
10
-
11
- return { ok: true };
12
- });
1
+ import { mail, shouldQueue } from "@/framework/facade.js";
2
+ import { logger } from "@/framework/support/logger.js";
13
3
 
4
+ shouldQueue("notification:mail", "mail", async (job) => {
5
+ const { to, subject, html } = job.data;
6
+
7
+ await mail.sendMail({ to, subject, html });
8
+ logger.info("Notification mail sent", { to, subject });
9
+
10
+ return { ok: true };
11
+ });
@@ -0,0 +1,29 @@
1
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * Why: Notifications are stored by a standalone module, not by auth.
5
+ * When: `notify()` writes a row and `/api/notification/*` reads it back.
6
+ * Where: `src/modules/notification/database/models/notification.ts`.
7
+ * How: A polymorphic owner (`notifiable_type` + `notifiable_id`) with no
8
+ * foreign key, so deleting or replacing auth cannot cascade-delete
9
+ * notifications and a UUID or numeric owner id stores the same way.
10
+ */
11
+ export const notifications = sqliteTable(
12
+ "notifications",
13
+ {
14
+ id: text("id").primaryKey(),
15
+ notifiableType: text("notifiable_type").notNull().default("user"),
16
+ notifiableId: text("notifiable_id").notNull(),
17
+ type: text("type").notNull(),
18
+ title: text("title").notNull(),
19
+ body: text("body"),
20
+ data: text("data"),
21
+ link: text("link"),
22
+ readAt: integer("read_at", { mode: "timestamp" }),
23
+ createdAt: integer("created_at", { mode: "timestamp" }).defaultNow().notNull()
24
+ },
25
+ (table) => [
26
+ index("notifications_notifiable_read_idx").on(table.notifiableType, table.notifiableId, table.readAt),
27
+ index("notifications_notifiable_created_idx").on(table.notifiableType, table.notifiableId, table.createdAt)
28
+ ]
29
+ );