@lucashw68/nsdb 1.0.0-rc.2

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 (49) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/GET_STARTED.md +709 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -0
  5. package/cli/index.js +83 -0
  6. package/helpers/args.js +22 -0
  7. package/helpers/config.js +142 -0
  8. package/helpers/generated.js +48 -0
  9. package/helpers/io.js +39 -0
  10. package/helpers/metadata.js +19 -0
  11. package/helpers/names.js +16 -0
  12. package/helpers/relations.js +101 -0
  13. package/helpers/shell.js +15 -0
  14. package/helpers/tables.js +79 -0
  15. package/helpers/ts.js +37 -0
  16. package/module.ts +151 -0
  17. package/nsdb.config.example.mjs +39 -0
  18. package/nsdb.config.example.ts +42 -0
  19. package/package.json +114 -0
  20. package/runtime/components/Form/NsdbRelationSelect.vue +258 -0
  21. package/runtime/components/NsdbForm.vue +865 -0
  22. package/runtime/components/NsdbList.vue +961 -0
  23. package/runtime/composables/useNsdbProfile.ts +119 -0
  24. package/runtime/composables/useNsdbSchemas.ts +176 -0
  25. package/runtime/composables/useSupabaseApi.ts +177 -0
  26. package/runtime/composables/useSupabaseApiStorage.ts +337 -0
  27. package/runtime/composables/useSupabaseModels.ts +412 -0
  28. package/runtime/query.ts +126 -0
  29. package/runtime/stores/createDbStore.ts +439 -0
  30. package/runtime/stores/createSingletonDbStore.ts +67 -0
  31. package/runtime/utils/dataFreshness.ts +47 -0
  32. package/runtime/utils/storage.ts +41 -0
  33. package/scripts/clear.js +64 -0
  34. package/scripts/generate-composables.js +100 -0
  35. package/scripts/generate-enums.js +106 -0
  36. package/scripts/generate-metadata.js +165 -0
  37. package/scripts/generate-models.js +164 -0
  38. package/scripts/generate-schemas.js +443 -0
  39. package/scripts/generate-stores.js +90 -0
  40. package/scripts/generate-types.js +196 -0
  41. package/scripts/init.js +225 -0
  42. package/templates/model.template.ts +48 -0
  43. package/templates/schema.template.ts +13 -0
  44. package/templates/useNsdbModel.template.ts +9 -0
  45. package/types/config.ts +50 -0
  46. package/types/entities.ts +66 -0
  47. package/types/index.ts +14 -0
  48. package/types/list.ts +78 -0
  49. package/types/model.ts +57 -0
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # NSDB — Nuxt Supabase Data Bridge
2
+
3
+ NSDB transforme les types d'une base Supabase en modèles Nuxt typés, stores Pinia optionnels et composants CRUD génériques. Supabase reste la source de vérité pour les données, Auth et RLS.
4
+
5
+ > **Statut de publication :** `1.0.0-rc.2` est la release candidate actuelle. Elle n'a pas encore été publiée sur npm. Les commandes d'installation ci-dessous s'appliqueront après publication.
6
+
7
+ ## Quick start
8
+
9
+ Prérequis validés pour la RC : Node 22.14+, Nuxt 4.2.1+, Vue 3.5.24+, `@nuxtjs/supabase` 1.6.1 ou 2.x, `@pinia/nuxt` 0.11.2+, Pinia 3.0.3+, un projet Supabase et ses policies RLS. Le CLI Supabase est requis pour générer les types ; une URL PostgreSQL directe est recommandée pour les métadonnées exactes.
10
+
11
+ ```bash
12
+ npm install @lucashw68/nsdb @nuxtjs/supabase @pinia/nuxt pinia
13
+ npm install --save-dev supabase
14
+ npx nsdb init
15
+ npx nsdb generate:all
16
+ ```
17
+
18
+ ```ts
19
+ // nuxt.config.ts
20
+ export default defineNuxtConfig({
21
+ modules: ['@lucashw68/nsdb', '@pinia/nuxt', '@nuxtjs/supabase'],
22
+ })
23
+ ```
24
+
25
+ ```ts
26
+ const playlists = usePlaylists()
27
+
28
+ await playlists.fetch()
29
+ const playlist = await playlists.create({ title: 'Nouvelle playlist' })
30
+
31
+ await playlists.update(playlist, { title: 'Renommée' })
32
+ await playlists.remove(playlist)
33
+ ```
34
+
35
+ ```vue
36
+ <NsdbList model="playlists" />
37
+ <NsdbForm model="playlists" />
38
+ ```
39
+
40
+ Le guide d'installation complet est dans [GET_STARTED.md](./GET_STARTED.md).
41
+
42
+ ## Modèles générés
43
+
44
+ Le modèle est l'API recommandée. Les opérations asynchrones rejettent leur Promise en cas d'erreur et `error` expose également la dernière erreur réactive.
45
+
46
+ ```ts
47
+ const playlists = usePlaylists({ store: true })
48
+
49
+ await playlists.fetch()
50
+ await playlists.refresh()
51
+ playlists.invalidate()
52
+
53
+ await playlists.create({ title: 'Rock' })
54
+ await playlists.update(id, { title: 'Jazz' })
55
+ await playlists.remove(id)
56
+
57
+ playlists.subscribe()
58
+ await playlists.unsubscribe()
59
+ ```
60
+
61
+ `update()` et `remove()` acceptent soit la valeur de clé primaire, soit une ligne du même modèle. Lorsque la ligne est déjà disponible, NSDB en extrait la clé primaire générée sans envoyer le reste de la ligne dans la mutation.
62
+
63
+ `{ store: true }` active l'état partagé, le TTL et éventuellement la persistance. Sans store, chaque handle possède sa collection et contacte Supabase à chaque `fetch()`.
64
+
65
+ ## Requêtes
66
+
67
+ `fetch(options)` est l'unique syntaxe canonique pour les listes filtrées :
68
+
69
+ ```ts
70
+ await playlists.fetch({
71
+ where: {
72
+ active: true,
73
+ created_at: { op: 'gte', value: '2026-01-01' },
74
+ },
75
+ search: 'rock',
76
+ searchColumns: ['title', 'provider'],
77
+ orderBy: 'created_at',
78
+ orderDirection: 'desc',
79
+ limit: 20,
80
+ offset: 0,
81
+ })
82
+ ```
83
+
84
+ Relations courantes :
85
+
86
+ ```ts
87
+ const posts = await usePosts().fetch({ include: ['author', 'tags'] })
88
+ ```
89
+
90
+ `select` et `orderForeignTable` restent disponibles comme échappatoires PostgREST avancées.
91
+
92
+ ## Composants
93
+
94
+ ```vue
95
+ <NsdbList model="playlists" searchable :search-columns="['title']" :page-size="20">
96
+ <template #cell="{ column, row, value }">
97
+ <strong v-if="column.key === 'title'">{{ row.title }}</strong>
98
+ <span v-else>{{ value }}</span>
99
+ </template>
100
+ </NsdbList>
101
+ ```
102
+
103
+ ```vue
104
+ <NsdbForm model="playlists" @saved="onSaved">
105
+ <template #field-cover_url="{ value, update }">
106
+ <MyUploader :model-value="value" @update:model-value="update" />
107
+ </template>
108
+ </NsdbForm>
109
+ ```
110
+
111
+ Les slots permettent de remplacer le rendu sans introduire un framework UI.
112
+
113
+ ## API bas niveau
114
+
115
+ Pour les cas qui dépassent les modèles générés :
116
+
117
+ ```ts
118
+ const api = useSupabaseApi()
119
+ const result = await api.all('playlists', { where: { active: true }, limit: 20 })
120
+ const row = await api.getById('playlists', id)
121
+ await api.remove('playlists', id)
122
+ ```
123
+
124
+ Cette couche renvoie des objets discriminés `{ success, data, error, count }` au lieu de rejeter les erreurs Supabase.
125
+
126
+ Storage conserve son vocabulaire métier :
127
+
128
+ ```ts
129
+ const storage = useSupabaseApiStorage()
130
+ await storage.upload('avatars', 'users/me.png', file)
131
+ const signed = await storage.createSignedUrl('avatars', 'users/me.png', 300)
132
+ await storage.remove('avatars', 'users/me.png')
133
+ ```
134
+
135
+ ## Configuration et sécurité
136
+
137
+ La configuration minimale générée par `nsdb init` suffit pour un projet standard. Les options avancées couvrent les chemins, la génération distante, les tables exposées et les politiques de colonnes.
138
+
139
+ ```ts
140
+ import type { NsdbConfig } from '@lucashw68/nsdb/types'
141
+
142
+ export default {
143
+ supabase: { schema: 'public', linked: true },
144
+ tables: {
145
+ include: ['playlists'],
146
+ columns: { playlists: { internal_note: { serverOnly: true } } },
147
+ },
148
+ } satisfies NsdbConfig
149
+ ```
150
+
151
+ Les règles `serverOnly`, `hidden` et `editable` réduisent la surface client ; elles ne remplacent jamais les policies RLS.
152
+
153
+ ## Documentation
154
+
155
+ La documentation publique officielle est maintenue dans le dossier [`website/`](https://github.com/Lucashw68/nsdb/tree/main/website) du repository. Consultez notamment les guides d'API publique, de migration, de cache/Realtime et de sécurité/RLS avant d'adopter la prochaine release candidate.
156
+
157
+ ## Licence
158
+
159
+ NSDB est distribué sous [licence MIT](./LICENSE).
package/cli/index.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from 'node:child_process'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const currentFilePath = fileURLToPath(import.meta.url)
8
+ const currentDirectoryPath = path.dirname(currentFilePath)
9
+
10
+ const commands = {
11
+ 'clear': path.resolve(currentDirectoryPath, '../scripts/clear.js'),
12
+ 'init': path.resolve(currentDirectoryPath, '../scripts/init.js'),
13
+ 'generate:types': path.resolve(currentDirectoryPath, '../scripts/generate-types.js'),
14
+ 'generate:metadata': path.resolve(currentDirectoryPath, '../scripts/generate-metadata.js'),
15
+ 'generate:enums': path.resolve(currentDirectoryPath, '../scripts/generate-enums.js'),
16
+ 'generate:schemas': path.resolve(currentDirectoryPath, '../scripts/generate-schemas.js'),
17
+ 'generate:models': path.resolve(currentDirectoryPath, '../scripts/generate-models.js'),
18
+ 'generate:composables': path.resolve(currentDirectoryPath, '../scripts/generate-composables.js'),
19
+ 'generate:stores': path.resolve(currentDirectoryPath, '../scripts/generate-stores.js'),
20
+ 'generate:all': [
21
+ path.resolve(currentDirectoryPath, '../scripts/generate-types.js'),
22
+ path.resolve(currentDirectoryPath, '../scripts/generate-metadata.js'),
23
+ path.resolve(currentDirectoryPath, '../scripts/generate-enums.js'),
24
+ path.resolve(currentDirectoryPath, '../scripts/generate-schemas.js'),
25
+ path.resolve(currentDirectoryPath, '../scripts/generate-models.js'),
26
+ path.resolve(currentDirectoryPath, '../scripts/generate-stores.js'),
27
+ path.resolve(currentDirectoryPath, '../scripts/generate-models.js'),
28
+ path.resolve(currentDirectoryPath, '../scripts/generate-composables.js'),
29
+ ],
30
+ }
31
+
32
+ const help = `NSDB — Nuxt Supabase Data Bridge
33
+
34
+ Usage:
35
+ nsdb init [options]
36
+ nsdb generate:all [options]
37
+ nsdb generate:types|metadata|enums|schemas|models|stores|composables [options]
38
+ nsdb clear [--dry-run]
39
+
40
+ Run "nsdb init --help" is not required: init options are documented in GET_STARTED.md.`
41
+
42
+ if (!process.argv[2] || ['help', '--help', '-h'].includes(process.argv[2])) {
43
+ console.log(help)
44
+ process.exit(0)
45
+ }
46
+
47
+ const command = commands[process.argv[2]]
48
+ const forwardedArguments = process.argv.slice(3)
49
+
50
+ function quoteShellArgument(argument) {
51
+ return `"${String(argument).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
52
+ }
53
+
54
+ function buildCommandLine(command) {
55
+ const forwardedCommandLine = forwardedArguments.map(quoteShellArgument).join(' ')
56
+
57
+ if (Array.isArray(command)) {
58
+ return command
59
+ .map(scriptPath => {
60
+ const baseCommandLine = `node "${scriptPath}"`
61
+ return forwardedCommandLine ? `${baseCommandLine} ${forwardedCommandLine}` : baseCommandLine
62
+ })
63
+ .join(' && ')
64
+ }
65
+
66
+ const baseCommandLine = `node "${command}"`
67
+ return forwardedCommandLine ? `${baseCommandLine} ${forwardedCommandLine}` : baseCommandLine
68
+ }
69
+
70
+ if (!command) {
71
+ console.error(`❌ Command not found: ${process.argv[2]}`)
72
+ process.exit(1)
73
+ }
74
+
75
+ try {
76
+ execSync(buildCommandLine(command), {
77
+ stdio: 'inherit',
78
+ shell: true
79
+ })
80
+ } catch (err) {
81
+ console.error(`❌ Failed to execute command: ${process.argv[2]}`)
82
+ process.exit(1)
83
+ }
@@ -0,0 +1,22 @@
1
+ // helpers/args.js
2
+ export function parseArgs(argv = process.argv.slice(2)) {
3
+ const args = [...argv]
4
+
5
+ const get = (name, def = '') => {
6
+ const i = args.findIndex(a => a === `--${name}`)
7
+ return i !== -1 && args[i + 1] ? args[i + 1] : def
8
+ }
9
+
10
+ const getBool = (name, def = false) => {
11
+ const i = args.findIndex(a => a === `--${name}`)
12
+ if (i === -1) return def
13
+ const next = args[i + 1]
14
+ if (next === 'true') return true
15
+ if (next === 'false') return false
16
+ return true
17
+ }
18
+
19
+ const rest = () => args.filter(a => !a.startsWith('--') && !a.startsWith('-'))
20
+
21
+ return { get, getBool, rest, raw: args }
22
+ }
@@ -0,0 +1,142 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { exists, readText } from './io.js'
4
+
5
+ export const defaultNsdbConfig = {
6
+ supabase: {
7
+ schema: 'public',
8
+ projectId: '',
9
+ dbUrl: '',
10
+ linked: false,
11
+ remoteTypes: {
12
+ sshHost: '',
13
+ projectPath: '',
14
+ dbUrl: '',
15
+ remoteOutput: '/tmp/database.types.ts',
16
+ beforeCommand: '',
17
+ supabaseCommand: 'npx supabase',
18
+ },
19
+ },
20
+ paths: {
21
+ types: 'types/database.types.ts',
22
+ metadata: 'nsdb/database.metadata.json',
23
+ enums: 'nsdb/enums.ts',
24
+ schemas: 'nsdb/schemas',
25
+ models: 'nsdb/models',
26
+ composables: 'nsdb/composables',
27
+ stores: 'stores',
28
+ },
29
+ imports: {
30
+ databaseTypes: '~~/types/database.types',
31
+ },
32
+ tables: {
33
+ include: [],
34
+ exclude: [],
35
+ columns: {},
36
+ },
37
+ templates: {
38
+ model: 'node_modules/@lucashw68/nsdb/templates/model.template.ts',
39
+ schema: 'node_modules/@lucashw68/nsdb/templates/schema.template.ts',
40
+ useNsdbModel: 'node_modules/@lucashw68/nsdb/templates/useNsdbModel.template.ts',
41
+ store: 'node_modules/@lucashw68/nsdb/templates/store.template.ts',
42
+ },
43
+ }
44
+
45
+ function isPlainObject(value) {
46
+ return value != null && typeof value === 'object' && !Array.isArray(value)
47
+ }
48
+
49
+ function mergeConfig(baseConfig, overrideConfig) {
50
+ const mergedConfig = { ...baseConfig }
51
+
52
+ for (const [key, overrideValue] of Object.entries(overrideConfig ?? {})) {
53
+ const baseValue = mergedConfig[key]
54
+ if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
55
+ mergedConfig[key] = mergeConfig(baseValue, overrideValue)
56
+ continue
57
+ }
58
+
59
+ mergedConfig[key] = overrideValue
60
+ }
61
+
62
+ return mergedConfig
63
+ }
64
+
65
+ function findConfigFile(currentWorkingDirectory, explicitConfigPath) {
66
+ if (explicitConfigPath) {
67
+ const absoluteConfigPath = path.resolve(currentWorkingDirectory, explicitConfigPath)
68
+ return exists(absoluteConfigPath) ? absoluteConfigPath : null
69
+ }
70
+
71
+ const candidates = [
72
+ 'nsdb.config.ts',
73
+ 'nsdb.config.mts',
74
+ 'nsdb.config.mjs',
75
+ 'nsdb.config.js',
76
+ 'nsdb.config.cjs',
77
+ 'nsdb.config.cts',
78
+ 'nsdb.config.json',
79
+ ]
80
+
81
+ return candidates
82
+ .map(candidate => path.resolve(currentWorkingDirectory, candidate))
83
+ .find(candidatePath => exists(candidatePath)) ?? null
84
+ }
85
+
86
+ async function readConfigFile(configFilePath) {
87
+ if (!configFilePath) return {}
88
+
89
+ if (configFilePath.endsWith('.json')) {
90
+ return JSON.parse(readText(configFilePath))
91
+ }
92
+
93
+ if (configFilePath.endsWith('.ts') || configFilePath.endsWith('.mts') || configFilePath.endsWith('.cts')) {
94
+ const { tsImport } = await import('tsx/esm/api')
95
+ const importedConfig = await tsImport(pathToFileURL(configFilePath).href, import.meta.url)
96
+ return resolveConfigExport(importedConfig)
97
+ }
98
+
99
+ const importedConfig = await import(pathToFileURL(configFilePath).href)
100
+ return resolveConfigExport(importedConfig)
101
+ }
102
+
103
+ function resolveConfigExport(importedConfig) {
104
+ const configExport = importedConfig.default ?? importedConfig.nsdb ?? importedConfig
105
+
106
+ if (
107
+ configExport &&
108
+ typeof configExport === 'object' &&
109
+ configExport.__esModule &&
110
+ configExport.default
111
+ ) {
112
+ return configExport.default
113
+ }
114
+
115
+ return configExport
116
+ }
117
+
118
+ export async function loadNsdbConfig(currentWorkingDirectory, explicitConfigPath = '') {
119
+ const configFilePath = findConfigFile(currentWorkingDirectory, explicitConfigPath)
120
+ const fileConfig = await readConfigFile(configFilePath)
121
+
122
+ return {
123
+ config: mergeConfig(defaultNsdbConfig, fileConfig),
124
+ configFilePath,
125
+ }
126
+ }
127
+
128
+ export function getConfigValue(config, dottedPath, fallbackValue = undefined) {
129
+ return dottedPath
130
+ .split('.')
131
+ .reduce((currentValue, pathPart) => currentValue?.[pathPart], config) ?? fallbackValue
132
+ }
133
+
134
+ export function getOption(parsedArguments, config, argumentName, configPath, fallbackValue = '') {
135
+ const configValue = getConfigValue(config, configPath, fallbackValue)
136
+ return parsedArguments.get(argumentName, configValue)
137
+ }
138
+
139
+ export function getBoolOption(parsedArguments, config, argumentName, configPath, fallbackValue = false) {
140
+ const configValue = getConfigValue(config, configPath, fallbackValue)
141
+ return parsedArguments.getBool(argumentName, configValue)
142
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const GENERATED_FILE_MARKER = '// @generated by @lucashw68/nsdb - DO NOT EDIT.'
5
+
6
+ export function markGenerated(content) {
7
+ const normalizedContent = String(content).replace(/^\uFEFF/, '')
8
+ if (normalizedContent.startsWith(GENERATED_FILE_MARKER)) return normalizedContent
9
+ return `${GENERATED_FILE_MARKER}\n${normalizedContent}`
10
+ }
11
+
12
+ export function isNsdbGeneratedFile(filePath) {
13
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
14
+ const descriptor = fs.openSync(filePath, 'r')
15
+ try {
16
+ const buffer = Buffer.alloc(256)
17
+ const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0)
18
+ return buffer.subarray(0, bytesRead).toString('utf8').includes(GENERATED_FILE_MARKER)
19
+ } finally {
20
+ fs.closeSync(descriptor)
21
+ }
22
+ }
23
+
24
+ export function listGeneratedFiles(directoryPath) {
25
+ if (!fs.existsSync(directoryPath) || !fs.statSync(directoryPath).isDirectory()) return []
26
+
27
+ return fs.readdirSync(directoryPath, { withFileTypes: true }).flatMap((entry) => {
28
+ const entryPath = path.join(directoryPath, entry.name)
29
+ if (entry.isDirectory()) return listGeneratedFiles(entryPath)
30
+ return entry.isFile() && isNsdbGeneratedFile(entryPath) ? [entryPath] : []
31
+ })
32
+ }
33
+
34
+ export function removeGeneratedFile(filePath, { dryRun = false, verbose = false } = {}) {
35
+ if (!isNsdbGeneratedFile(filePath)) return false
36
+ if (!dryRun) fs.unlinkSync(filePath)
37
+ if (verbose || dryRun) {
38
+ console.log(`${dryRun ? 'Would remove' : 'Removed'} generated file: ${path.relative(process.cwd(), filePath)}`)
39
+ }
40
+ return true
41
+ }
42
+
43
+ export function removeStaleGeneratedFiles(directoryPath, retainedFileNames) {
44
+ const retained = new Set(retainedFileNames)
45
+ for (const filePath of listGeneratedFiles(directoryPath)) {
46
+ if (!retained.has(path.basename(filePath))) fs.unlinkSync(filePath)
47
+ }
48
+ }
package/helpers/io.js ADDED
@@ -0,0 +1,39 @@
1
+ // helpers/io.js
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+
5
+ export function ensureDir(dirPath) {
6
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true })
7
+ }
8
+
9
+ export function writeText(filePath, content, verbose = false) {
10
+ ensureDir(path.dirname(filePath))
11
+ fs.writeFileSync(filePath, content, 'utf8')
12
+ if (verbose) console.log(`✍️ Wrote: ${path.relative(process.cwd(), filePath)}`)
13
+ }
14
+
15
+ export function readText(filePath) {
16
+ return fs.readFileSync(filePath, 'utf8')
17
+ }
18
+
19
+ export function removeDirIfExists(dirPath, verbose = false) {
20
+ if (!fs.existsSync(dirPath)) return false
21
+ fs.rmSync(dirPath, { recursive: true, force: true })
22
+ if (verbose) console.log(`🗑️ Removed dir: ${path.relative(process.cwd(), dirPath)}`)
23
+ return true
24
+ }
25
+
26
+ export function removeFileIfExists(filePath, verbose = false) {
27
+ if (!fs.existsSync(filePath)) return false
28
+ fs.unlinkSync(filePath)
29
+ if (verbose) console.log(`🗑️ Removed file: ${path.relative(process.cwd(), filePath)}`)
30
+ return true
31
+ }
32
+
33
+ export function listFiles(dirPath) {
34
+ return fs.existsSync(dirPath) ? fs.readdirSync(dirPath) : []
35
+ }
36
+
37
+ export function exists(p) {
38
+ return fs.existsSync(p)
39
+ }
@@ -0,0 +1,19 @@
1
+ import path from 'node:path'
2
+ import { exists, readText } from './io.js'
3
+
4
+ export function loadDatabaseMetadata(currentWorkingDirectory, config) {
5
+ const configuredPath = config?.paths?.metadata
6
+ if (!configuredPath) return null
7
+ const metadataPath = path.resolve(currentWorkingDirectory, configuredPath)
8
+ if (!exists(metadataPath)) return null
9
+
10
+ const metadata = JSON.parse(readText(metadataPath))
11
+ if (metadata?.version !== 1 || !metadata.tables || typeof metadata.tables !== 'object') {
12
+ throw new Error(`[nsdb] Invalid database metadata file: ${metadataPath}`)
13
+ }
14
+ return metadata
15
+ }
16
+
17
+ export function getTableMetadata(metadata, tableName) {
18
+ return metadata?.tables?.[tableName] ?? null
19
+ }
@@ -0,0 +1,16 @@
1
+ // helpers/names.js
2
+ export const toPascal = (s) => (
3
+ String(s)
4
+ .replace(/[_\-./\s]+/g, ' ')
5
+ .trim()
6
+ .replace(/(^|\s)([a-zA-Z])/g, (_, __, c) => c.toUpperCase())
7
+ .replace(/\s+/g, '')
8
+ )
9
+
10
+ export const singular = (s) => (
11
+ String(s).endsWith('s') ? s.slice(0, -1) : s
12
+ )
13
+
14
+ export const modelHookName = (table) => `use${toPascal(table)}`
15
+ export const storeName = (table) => `use${toPascal(singular(table))}Store`
16
+ export const schemaName = (table) => `${toPascal(singular(table))}Schema`
@@ -0,0 +1,101 @@
1
+ function singular(value) {
2
+ return value.endsWith('ies') ? `${value.slice(0, -3)}y` : value.replace(/s$/, '')
3
+ }
4
+
5
+ function forwardAlias(relation) {
6
+ const raw = relation.columns.join('_').replace(/_id(?:_|$)/g, '_').replace(/_+$/, '')
7
+ return raw || singular(relation.referencedRelation)
8
+ }
9
+
10
+ function addUnique(catalog, tableName, relation) {
11
+ const entries = catalog[tableName] ??= []
12
+ let alias = relation.alias
13
+ if (entries.some(entry => entry.alias === alias)) {
14
+ alias = `${alias}_${relation.foreignKeyName}`.replace(/[^a-zA-Z0-9_]/g, '_')
15
+ }
16
+ entries.push({ ...relation, alias })
17
+ }
18
+
19
+ function isJoinTable(table) {
20
+ if (table.relationships.length !== 2) return false
21
+ const relationColumns = table.relationships.flatMap(relation => relation.columns)
22
+ const uniqueRelationColumns = [...new Set(relationColumns)].sort()
23
+ const constrained = [table.primaryKey, ...table.uniqueConstraints.map(item => item.columns)]
24
+ .some(columns => [...columns].sort().join('\0') === uniqueRelationColumns.join('\0'))
25
+ if (!constrained) return false
26
+
27
+ return Object.entries(table.columns).every(([column, metadata]) =>
28
+ uniqueRelationColumns.includes(column) || metadata.nullable || metadata.hasDefault || metadata.generated,
29
+ )
30
+ }
31
+
32
+ export function buildRelationCatalog(databaseMetadata, exposedTableNames) {
33
+ const catalog = Object.fromEntries([...exposedTableNames].map(tableName => [tableName, []]))
34
+ if (!databaseMetadata) return catalog
35
+
36
+ for (const [sourceTable, table] of Object.entries(databaseMetadata.tables)) {
37
+ if (!exposedTableNames.has(sourceTable)) continue
38
+ for (const relation of table.relationships) {
39
+ if (!exposedTableNames.has(relation.referencedRelation)) continue
40
+ const alias = forwardAlias(relation)
41
+ const nullable = relation.columns.some(column => table.columns[column]?.nullable)
42
+ addUnique(catalog, sourceTable, {
43
+ alias,
44
+ kind: relation.isOneToOne ? 'hasOne' : 'belongsTo',
45
+ direction: 'forward',
46
+ nullable,
47
+ referencedTable: relation.referencedRelation,
48
+ embedResource: sourceTable === relation.referencedRelation && relation.columns.length === 1
49
+ ? relation.columns[0]
50
+ : relation.referencedRelation,
51
+ localColumns: relation.columns,
52
+ referencedColumns: relation.referencedColumns,
53
+ foreignKeyName: relation.foreignKeyName,
54
+ composite: relation.columns.length > 1,
55
+ })
56
+
57
+ const duplicates = table.relationships.filter(item => item.referencedRelation === relation.referencedRelation).length
58
+ const inverseAlias = sourceTable === relation.referencedRelation && alias === 'parent'
59
+ ? 'children'
60
+ : duplicates > 1 ? `${alias}_${sourceTable}` : sourceTable
61
+ addUnique(catalog, relation.referencedRelation, {
62
+ alias: inverseAlias,
63
+ kind: relation.isOneToOne ? 'hasOne' : 'hasMany',
64
+ direction: 'inverse',
65
+ nullable: false,
66
+ referencedTable: sourceTable,
67
+ embedResource: sourceTable,
68
+ localColumns: relation.referencedColumns,
69
+ referencedColumns: relation.columns,
70
+ foreignKeyName: relation.foreignKeyName,
71
+ composite: relation.columns.length > 1,
72
+ })
73
+ }
74
+ }
75
+
76
+ for (const [joinTableName, table] of Object.entries(databaseMetadata.tables)) {
77
+ if (!exposedTableNames.has(joinTableName) || !isJoinTable(table)) continue
78
+ const [left, right] = table.relationships
79
+ if (!exposedTableNames.has(left.referencedRelation) || !exposedTableNames.has(right.referencedRelation)) continue
80
+ addUnique(catalog, left.referencedRelation, {
81
+ alias: right.referencedRelation,
82
+ kind: 'manyToMany', direction: 'through', nullable: false,
83
+ referencedTable: right.referencedRelation,
84
+ embedResource: right.referencedRelation,
85
+ localColumns: left.referencedColumns,
86
+ referencedColumns: right.referencedColumns,
87
+ throughTable: joinTableName,
88
+ })
89
+ addUnique(catalog, right.referencedRelation, {
90
+ alias: left.referencedRelation,
91
+ kind: 'manyToMany', direction: 'through', nullable: false,
92
+ referencedTable: left.referencedRelation,
93
+ embedResource: left.referencedRelation,
94
+ localColumns: right.referencedColumns,
95
+ referencedColumns: left.referencedColumns,
96
+ throughTable: joinTableName,
97
+ })
98
+ }
99
+
100
+ return catalog
101
+ }
@@ -0,0 +1,15 @@
1
+ // helpers/shell.js
2
+ import { execSync } from 'node:child_process'
3
+
4
+ export function run(cmd, { inherit = true, env = process.env } = {}) {
5
+ execSync(cmd, { stdio: inherit ? 'inherit' : 'pipe', shell: true, env })
6
+ }
7
+
8
+ export function isAvailable(cmd) {
9
+ try {
10
+ execSync(cmd, { stdio: 'ignore' })
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }