@frigear-nu/module-kit 0.0.3 → 0.0.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.
package/README.md CHANGED
@@ -7,51 +7,166 @@ A simple nuxt module to simplify development of other nuxt modules within frigea
7
7
  - Be a shared module kit for building nuxt modules within frigear – as generic as possible within the frigear requirements
8
8
  - E.g building a 'user' or 'blog' or 'post' module for frigear-nu
9
9
 
10
- ## Extending a database table
10
+ ## Utils & composables
11
11
 
12
- Modules can register a table schema using `extendDatabaseTable` from `@frigear-nu/module-kit/utils`:
12
+ All of the helpers below are exported from `@frigear-nu/module-kit/utils`.
13
+
14
+ ### Module helpers
15
+
16
+ #### `registerModule`
17
+
18
+ Registers a module name against `nuxt.options.moduleKit._modules`, de-duplicating entries. Useful during a module's
19
+ `setup()` to keep track of which Frigear modules are active in the current Nuxt app:
13
20
 
14
21
  ```ts
15
- extendDatabaseTable(nuxt, {
22
+ import { registerModule } from '@frigear-nu/module-kit/utils'
23
+
24
+ registerModule('@frigear-nu/users', nuxt)
25
+ ```
26
+
27
+ #### `defineModulePage`
28
+
29
+ Tags a `NuxtPage` with the owning module's name in `page.meta.moduleName`, so pages registered by a module can be
30
+ identified later (e.g. for navigation or permission checks):
31
+
32
+ ```ts
33
+ import { defineModulePage } from '@frigear-nu/module-kit/utils'
34
+
35
+ extendPages((pages) => {
36
+ pages.push(defineModulePage('@frigear-nu/users', {
37
+ name: 'users-profile',
38
+ path: '/users/profile',
39
+ file: resolver.resolve('./runtime/pages/profile.vue'),
40
+ }))
41
+ })
42
+ ```
43
+
44
+ ### URL helpers
45
+
46
+ #### `moduleUrl` and `moduleApiUrl`
47
+
48
+ Build consistent, kebab-cased URLs for a module's pages and API routes. If `path` starts with `/` it is treated as
49
+ absolute and the module name is not prefixed:
50
+
51
+ ```ts
52
+ import { moduleUrl, moduleApiUrl } from '@frigear-nu/module-kit/utils'
53
+
54
+ moduleUrl('OAuthServer') // => '/o-auth-server'
55
+ moduleUrl('OAuthServer', 'login') // => '/o-auth-server/login'
56
+ moduleApiUrl('OAuthServer', 'token') // => '/api/o-auth-server/token'
57
+ ```
58
+
59
+ ### Database schema helpers
60
+
61
+ These helpers let one module own a Drizzle table while letting other modules (and host applications) extend it with
62
+ additional columns, without the owning module needing to know about them.
63
+
64
+ #### `registerDatabaseTable`
65
+
66
+ Modules register a table schema using `registerDatabaseTable`. The simplest usage needs no wrapper at all — `schema`
67
+ can be a *regular* drizzle file:
68
+
69
+ ```ts
70
+ // runtime/db/schema.sqlite.ts
71
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
72
+
73
+ export const users = sqliteTable('users', {
74
+ id: integer().primaryKey({ autoIncrement: true }),
75
+ email: text().notNull().unique(),
76
+ })
77
+ ```
78
+
79
+ ```ts
80
+ import { registerDatabaseTable } from '@frigear-nu/module-kit/utils'
81
+
82
+ registerDatabaseTable(nuxt, {
16
83
  name: 'users',
17
- factory: 'defineUsersTable',
18
84
  resolver: createResolver(import.meta.url),
19
85
  schema: dialect => `./runtime/db/schema.${dialect}`,
20
- override: options.settings?.schemas?.users,
86
+ })
87
+ ```
88
+
89
+ #### `extendDatabaseTable`
90
+
91
+ Other modules can register column files during setup. These can be regular files too — either a full table
92
+ definition or a plain columns object, both without any helper wrapper:
93
+
94
+ ```ts
95
+ import { extendDatabaseTable } from '@frigear-nu/module-kit/utils'
96
+
97
+ extendDatabaseTable(nuxt, {
98
+ name: 'users',
99
+ resolver: createResolver(import.meta.url),
100
+ file: dialect => `./runtime/db/extra.${dialect}`,
21
101
  })
22
102
  ```
23
103
 
24
104
  ```ts
25
- import { ColumnBuilderBase } from "drizzle-orm";
105
+ // runtime/db/extra.sqlite.ts
106
+ import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
26
107
 
27
- const defineUsersTable = (extras: Record<string, ColumnBuilderBase>) => {
28
- return sqliteTable('users', {
108
+ export const users = sqliteTable('users', {
109
+ locale: text().default('da'),
110
+ })
111
+ ```
112
+
113
+ Module-kit parses each file's exported table (or plain object) matching `name`, and merges their columns directly —
114
+ later registrations override columns from earlier ones with the same key — before generating a single `sqliteTable(...)`
115
+ call with the merged columns and deduplicated imports. The base `schema` file must export a genuine table call (e.g.
116
+ `sqliteTable('users', { ... })`) matching `name`, since it determines which factory (`sqliteTable`/`pgTable`/etc.) and
117
+ SQL table name to use for the merged result.
118
+
119
+ #### Using a `factory`
120
+
121
+ Pass `factory` to `registerDatabaseTable` when you need shared defaults (e.g. timestamps) applied consistently across
122
+ dialects. Wrap the factory with `defineExtendableTable` so it receives a single `{ name, columns }` object instead of
123
+ a plain columns object — passing `name` through means the table's name only has to be maintained in one place, the
124
+ `registerDatabaseTable` call, instead of being duplicated as a string literal inside the factory itself. The factory
125
+ must spread `columns` *last*, after its own fields (including timestamps), so extended columns always end up last in
126
+ the generated migration:
127
+
128
+ ```ts
129
+ import type { ColumnBuilderBase } from 'drizzle-orm'
130
+ import { defineExtendableTable } from '@frigear-nu/module-kit/utils'
131
+
132
+ const defineUsersTable = defineExtendableTable(({ name, columns }: { name: string, columns: Record<string, ColumnBuilderBase> }) => {
133
+ return sqliteTable(name, {
29
134
  id: serial('id').primaryKey(),
30
135
  email: text('email').notNull().unique(),
31
136
  password: text('password').notNull(),
32
137
  createdAt: timestamp('created_at').defaultNow().notNull(),
33
138
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
34
- ...extras || {},
139
+ ...columns,
35
140
  })
36
- }
141
+ })
37
142
  ```
38
143
 
39
-
40
- The schema file exports the named factory, which accepts an object of additional columns and returns a table. The factory must spread the received `extra` columns *last* in the object passed to the table builder (after every one of its own fields, including timestamps), so extended columns always end up last in the generated migration, matching the order they were registered in. Other modules can register column files during setup:
41
-
42
144
  ```ts
43
- registerDatabaseTableExtension(nuxt, {
145
+ registerDatabaseTable(nuxt, {
44
146
  name: 'users',
147
+ factory: 'defineUsersTable',
45
148
  resolver: createResolver(import.meta.url),
46
- file: dialect => `./runtime/db/extra.${dialect}`,
149
+ schema: dialect => `./runtime/db/schema.${dialect}`,
150
+ override: options.settings?.schemas?.users,
47
151
  })
48
152
  ```
49
153
 
50
- Each extension file exports column objects named for their tables. For example, a Nuxt layer can extend several tables in `server/db/extend.sqlite.ts`:
154
+ #### `defineTableColumns`
155
+
156
+ A typed identity helper for defining a table's extension columns. Each extension file exports column objects named
157
+ for their tables. For example, a Nuxt layer can extend several tables in `server/db/extend.sqlite.ts`:
51
158
 
52
159
  ```ts
53
- export const users = defineExtendedColumns({ locale: text().default('da') })
54
- export const posts = defineExtendedColumns({ summary: text() })
160
+ import { defineTableColumns } from '@frigear-nu/module-kit/utils'
161
+
162
+ export const users = defineTableColumns({ locale: text().default('da') })
163
+ export const posts = defineTableColumns({ summary: text() })
55
164
  ```
56
165
 
57
- Only the export matching the table being built is used. Existing registered files are merged in registration order (later fields replace earlier fields); missing files for a dialect are skipped. Registered files with legacy default exports still work. Nuxt layer files are merged next, from base layers toward the host application. Older `server/db/<name>.extend.<dialect>` files with default exports are also loaded before each layer's shared file, so a shared export can override them. Pass `extensionFile: dialect => 'path/to/columns.' + dialect + '.ts'` to change the per-layer file location. If `override` is a path instead of `'default'`, the host schema replaces the generated table entirely; the path is resolved from the application's root directory.
166
+ Only the export matching the table being built is used. Existing registered files are merged in registration order
167
+ (later fields replace earlier fields); missing files for a dialect are skipped. Registered files with legacy default
168
+ exports still work. Nuxt layer files are merged next, from base layers toward the host application. Older
169
+ `server/db/<name>.extend.<dialect>` files with default exports are also loaded before each layer's shared file, so a
170
+ shared export can override them. Pass `extensionFile: dialect => 'path/to/columns.' + dialect + '.ts'` to change the
171
+ per-layer file location. If `override` is a path instead of `'default'`, the host schema replaces the generated table
172
+ entirely; the path is resolved from the application's root directory.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=4.0.0"
6
6
  },
7
- "version": "0.0.3",
7
+ "version": "0.0.4",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "unknown"
@@ -0,0 +1,23 @@
1
+ export type ParsedTableExport = {
2
+ /** The callee + sql table name, present only when the export is a table factory call, e.g. `sqliteTable('users', {...})`. */
3
+ tableCall?: {
4
+ callee: string;
5
+ tableName: string;
6
+ };
7
+ /** Column (or spread) entries keyed by property name, in source order. Values are the raw `key: value` source text. */
8
+ properties: Map<string, string>;
9
+ /** Named imports collected from the file, keyed by module specifier. */
10
+ imports: Map<string, Set<string>>;
11
+ };
12
+ /**
13
+ * Parses a "regular" drizzle schema file - i.e. a file that exports a table (or a plain columns object) without
14
+ * requiring any `defineExtendableTable`/`defineTableColumns` wrapper - and extracts the column properties and
15
+ * imports belonging to the export matching `exportName`. Supports:
16
+ *
17
+ * - `export const users = sqliteTable('users', { ... })` (or `pgTable`/`mysqlTable`, any 2+ arg factory call)
18
+ * - `export const users = { ... }` (a plain columns object)
19
+ * - `export const users = defineTableColumns({ ... })` (a single-argument wrapper call)
20
+ * - `export default { ... }` / `export default sqliteTable(...)` / `export default defineTableColumns({ ... })`
21
+ * when `exportName` is `'default'`
22
+ */
23
+ export declare const parseRegularTableExport: (filePath: string, exportName: string) => ParsedTableExport | undefined;
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ts from "typescript";
3
+ export const parseRegularTableExport = (filePath, exportName) => {
4
+ const text = readFileSync(filePath, "utf8");
5
+ const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
6
+ const imports = /* @__PURE__ */ new Map();
7
+ for (const statement of source.statements) {
8
+ if (ts.isImportDeclaration(statement) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && ts.isStringLiteralLike(statement.moduleSpecifier)) {
9
+ const specifiers = imports.get(statement.moduleSpecifier.text) ?? /* @__PURE__ */ new Set();
10
+ for (const element of statement.importClause.namedBindings.elements) specifiers.add(element.getText());
11
+ imports.set(statement.moduleSpecifier.text, specifiers);
12
+ }
13
+ }
14
+ const toResult = (initializer) => {
15
+ let objectLiteral;
16
+ let tableCall;
17
+ if (ts.isCallExpression(initializer)) {
18
+ const args = initializer.arguments;
19
+ if (args.length >= 2 && ts.isStringLiteralLike(args[0]) && ts.isObjectLiteralExpression(args[1])) {
20
+ tableCall = { callee: initializer.expression.getText(), tableName: args[0].text };
21
+ objectLiteral = args[1];
22
+ } else if (args.length === 1 && ts.isObjectLiteralExpression(args[0])) {
23
+ objectLiteral = args[0];
24
+ }
25
+ } else if (ts.isObjectLiteralExpression(initializer)) {
26
+ objectLiteral = initializer;
27
+ }
28
+ if (!objectLiteral) return void 0;
29
+ const properties = /* @__PURE__ */ new Map();
30
+ for (const property of objectLiteral.properties) {
31
+ if (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) {
32
+ properties.set(property.name.getText(), property.getText());
33
+ } else if (ts.isSpreadAssignment(property)) {
34
+ properties.set(`__spread_${properties.size}`, property.getText());
35
+ }
36
+ }
37
+ return { tableCall, properties, imports };
38
+ };
39
+ for (const statement of source.statements) {
40
+ if (ts.isVariableStatement(statement) && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
41
+ for (const declaration of statement.declarationList.declarations) {
42
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === exportName && declaration.initializer) {
43
+ const result = toResult(declaration.initializer);
44
+ if (result) return result;
45
+ }
46
+ }
47
+ } else if (exportName === "default" && ts.isExportAssignment(statement) && !statement.isExportEquals) {
48
+ const result = toResult(statement.expression);
49
+ if (result) return result;
50
+ }
51
+ }
52
+ return void 0;
53
+ };
@@ -16,11 +16,40 @@ declare module '@nuxt/schema' {
16
16
  'module-kit:db:table:extend': TableExtenderFn;
17
17
  }
18
18
  }
19
- export declare const defineExtendedColumns: <T extends Record<string, ColumnBuilderBase>>(columns: T) => T;
20
- export declare const extendDatabaseSchema: (nuxt: Nuxt, extender: DbExtenderFn) => Promise<() => void>;
19
+ /**
20
+ * A typed identity helper for defining a table's extension columns. It exists purely for type
21
+ * inference/clarity when authoring `server/db/extend.<dialect>.ts` files — it returns the columns
22
+ * object unchanged.
23
+ */
24
+ export declare const defineTableColumns: <T extends Record<string, ColumnBuilderBase>>(columns: T) => T;
25
+ type TableDefinerProps = {
26
+ name: string;
27
+ columns: Record<string, ColumnBuilderBase>;
28
+ };
29
+ type TableDefinerFn<T> = (props: TableDefinerProps) => T;
30
+ /**
31
+ * Wraps a Drizzle table factory so it receives `{ name, columns }`, letting the factory pass
32
+ * `name` straight through to e.g. `sqliteTable(name, columns)` instead of hard-coding it, so the
33
+ * table name only has to be maintained once, in the `registerDatabaseTable` call.
34
+ */
35
+ export declare const defineExtendableTable: <T>(table: TableDefinerFn<T>) => (props: TableDefinerProps) => T;
36
+ /**
37
+ * Lower-level primitive behind `registerDatabaseTable`. Registers a callback on the
38
+ * `hub:db:schema:extend` hook, which receives the current `dialect` and the `paths` array of
39
+ * schema files to include in the generated database schema. Most modules should use
40
+ * `registerDatabaseTable` instead, unless they need full control over how their schema file is
41
+ * built.
42
+ */
43
+ export declare const onDatabaseSchemaExtend: (nuxt: Nuxt, extender: DbExtenderFn) => Promise<() => void>;
21
44
  type DatabaseTableOptions = {
22
45
  name: string;
23
- factory: string;
46
+ /**
47
+ * The exported factory function name to call with merged extension columns (e.g. `defineUsersTable`), as produced
48
+ * by `defineExtendableTable`. When omitted, `schema` (and every registered/layer extension file) is treated as a
49
+ * "regular" drizzle file - e.g. `export const users = sqliteTable('users', { ... })` - and its columns are merged
50
+ * directly, without requiring any helper wrapper.
51
+ */
52
+ factory?: string;
24
53
  resolver: ReturnType<typeof createResolver>;
25
54
  schema: (dialect: string) => string;
26
55
  override?: string;
@@ -31,6 +60,17 @@ type TableExtensionOptions = {
31
60
  resolver: ReturnType<typeof createResolver>;
32
61
  file: (dialect: string) => string;
33
62
  };
34
- export declare const registerDatabaseTableExtension: (nuxt: Nuxt, options: TableExtensionOptions) => void;
35
- export declare const extendDatabaseTable: (nuxt: Nuxt, options: DatabaseTableOptions) => Promise<() => void>;
63
+ /**
64
+ * Registers a column file to be merged into a named table whenever its schema is generated (see
65
+ * `registerDatabaseTable`). Fires the `module-kit:db:table:extend` hook internally, which
66
+ * `registerDatabaseTable` listens to.
67
+ */
68
+ export declare const extendDatabaseTable: (nuxt: Nuxt, options: TableExtensionOptions) => void;
69
+ /**
70
+ * The table-owning module registers its schema with `registerDatabaseTable`. It generates the
71
+ * table by importing the named `factory` from `schema`, merging in any columns registered
72
+ * through `extendDatabaseTable` and any Nuxt-layer extension files, then calling the factory with
73
+ * `{ name, columns }`.
74
+ */
75
+ export declare const registerDatabaseTable: (nuxt: Nuxt, options: DatabaseTableOptions) => Promise<() => void>;
36
76
  export {};
@@ -1,12 +1,18 @@
1
1
  import { addTemplate, createResolver } from "@nuxt/kit";
2
2
  import { existsSync } from "node:fs";
3
- export const defineExtendedColumns = (columns) => {
3
+ import { parseRegularTableExport } from "./regular-table.js";
4
+ export const defineTableColumns = (columns) => {
4
5
  return columns;
5
6
  };
6
- export const extendDatabaseSchema = async (nuxt, extender) => {
7
+ export const defineExtendableTable = (table) => {
8
+ return (props) => {
9
+ return table(props);
10
+ };
11
+ };
12
+ export const onDatabaseSchemaExtend = async (nuxt, extender) => {
7
13
  return nuxt.hook("hub:db:schema:extend", extender);
8
14
  };
9
- export const registerDatabaseTableExtension = (nuxt, options) => {
15
+ export const extendDatabaseTable = (nuxt, options) => {
10
16
  nuxt.hook("module-kit:db:table:extend", async ({ name, dialect, paths }) => {
11
17
  if (name === options.name) {
12
18
  const file = await options.resolver.resolvePath(options.file(dialect));
@@ -14,9 +20,9 @@ export const registerDatabaseTableExtension = (nuxt, options) => {
14
20
  }
15
21
  });
16
22
  };
17
- export const extendDatabaseTable = (nuxt, options) => {
23
+ export const registerDatabaseTable = (nuxt, options) => {
18
24
  const { name, factory, resolver, schema, override, extensionFile } = options;
19
- return extendDatabaseSchema(nuxt, async ({ dialect, paths }) => {
25
+ return onDatabaseSchemaExtend(nuxt, async ({ dialect, paths }) => {
20
26
  if (override && override !== "default") {
21
27
  paths.push(await resolver.resolvePath(override, { cwd: nuxt.options.rootDir }));
22
28
  return;
@@ -34,15 +40,55 @@ export const extendDatabaseTable = (nuxt, options) => {
34
40
  }
35
41
  if (existsSync(extensionPath)) extensions.push({ file: extensionPath, legacy: false });
36
42
  }
43
+ if (!factory) {
44
+ const schemaPath = await resolver.resolvePath(schema(dialect));
45
+ const template2 = buildRegularTableTemplate({ name, dialect, schemaPath, extensionFiles: extensions.map(({ file }) => file) });
46
+ paths.push(template2.dst);
47
+ return;
48
+ }
37
49
  const template = addTemplate({
38
50
  filename: `${name}/schema.${dialect}.ts`,
39
51
  write: true,
40
52
  getContents: () => `
41
53
  import { ${factory} } from ${JSON.stringify(resolver.resolve(schema(dialect)))}
42
54
  ${extensions.map(({ file }, index) => `import * as extra${index} from ${JSON.stringify(file)}`).join("\n")}
43
- export const ${name} = ${factory}({ ${extensions.map(({ legacy }, index) => legacy ? `...(extra${index}[${JSON.stringify(name)} as keyof typeof extra${index}] ?? Object.entries(extra${index}).find(([key]) => key === 'default')?.[1] ?? {})` : `...(extra${index}[${JSON.stringify(name)} as keyof typeof extra${index}] ?? {})`).join(", ")} })
55
+ export const ${name} = ${factory}({ name: ${JSON.stringify(name)}, columns: { ${extensions.map(({ legacy }, index) => legacy ? `...(extra${index}[${JSON.stringify(name)} as keyof typeof extra${index}] ?? Object.entries(extra${index}).find(([key]) => key === 'default')?.[1] ?? {})` : `...(extra${index}[${JSON.stringify(name)} as keyof typeof extra${index}] ?? {})`).join(", ")} } })
44
56
  `
45
57
  });
46
58
  paths.push(template.dst);
47
59
  });
48
60
  };
61
+ const buildRegularTableTemplate = (options) => {
62
+ const { name, dialect, schemaPath, extensionFiles } = options;
63
+ const base = parseRegularTableExport(schemaPath, name);
64
+ if (!base?.tableCall) {
65
+ throw new Error(
66
+ `[module-kit] Could not find a table export named "${name}" in "${schemaPath}". When "factory" is omitted, "schema" must be a regular drizzle file, e.g. \`export const ${name} = sqliteTable(${JSON.stringify(name)}, { ... })\`.`
67
+ );
68
+ }
69
+ const properties = new Map(base.properties);
70
+ const importsByModule = new Map([...base.imports].map(([module, specifiers]) => [module, new Set(specifiers)]));
71
+ for (const file of extensionFiles) {
72
+ const parsed = parseRegularTableExport(file, name);
73
+ if (!parsed) continue;
74
+ for (const [key, value] of parsed.properties) {
75
+ properties.delete(key);
76
+ properties.set(key, value);
77
+ }
78
+ for (const [module, specifiers] of parsed.imports) {
79
+ const set = importsByModule.get(module) ?? /* @__PURE__ */ new Set();
80
+ for (const specifier of specifiers) set.add(specifier);
81
+ importsByModule.set(module, set);
82
+ }
83
+ }
84
+ return addTemplate({
85
+ filename: `${name}/schema.${dialect}.ts`,
86
+ write: true,
87
+ getContents: () => `
88
+ ${[...importsByModule].map(([module, specifiers]) => `import { ${[...specifiers].join(", ")} } from ${JSON.stringify(module)}`).join("\n")}
89
+ export const ${name} = ${base.tableCall.callee}(${JSON.stringify(base.tableCall.tableName)}, {
90
+ ${[...properties.values()].map((property) => ` ${property},`).join("\n")}
91
+ })
92
+ `
93
+ });
94
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frigear-nu/module-kit",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Frigear Module Kit",
@@ -38,8 +38,10 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "@nuxt/kit": "^4.5.2",
41
+ "@nuxthub/core": "^0.10.8",
41
42
  "defu": "^6.1.7",
42
43
  "scule": "^1.3.0",
44
+ "typescript": "^6.0.3",
43
45
  "ufo": "^1.6.4",
44
46
  "zod": "^4.6.5"
45
47
  },
@@ -53,7 +55,6 @@
53
55
  "changelogen": "^0.6.2",
54
56
  "eslint": "^10.10.0",
55
57
  "nuxt": "^4.5.2",
56
- "typescript": "^6.0.3",
57
58
  "vitest": "^5.0.1",
58
59
  "vue-tsc": "^3.3.11"
59
60
  },