@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.
- package/CHANGELOG.md +34 -0
- package/GET_STARTED.md +709 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/cli/index.js +83 -0
- package/helpers/args.js +22 -0
- package/helpers/config.js +142 -0
- package/helpers/generated.js +48 -0
- package/helpers/io.js +39 -0
- package/helpers/metadata.js +19 -0
- package/helpers/names.js +16 -0
- package/helpers/relations.js +101 -0
- package/helpers/shell.js +15 -0
- package/helpers/tables.js +79 -0
- package/helpers/ts.js +37 -0
- package/module.ts +151 -0
- package/nsdb.config.example.mjs +39 -0
- package/nsdb.config.example.ts +42 -0
- package/package.json +114 -0
- package/runtime/components/Form/NsdbRelationSelect.vue +258 -0
- package/runtime/components/NsdbForm.vue +865 -0
- package/runtime/components/NsdbList.vue +961 -0
- package/runtime/composables/useNsdbProfile.ts +119 -0
- package/runtime/composables/useNsdbSchemas.ts +176 -0
- package/runtime/composables/useSupabaseApi.ts +177 -0
- package/runtime/composables/useSupabaseApiStorage.ts +337 -0
- package/runtime/composables/useSupabaseModels.ts +412 -0
- package/runtime/query.ts +126 -0
- package/runtime/stores/createDbStore.ts +439 -0
- package/runtime/stores/createSingletonDbStore.ts +67 -0
- package/runtime/utils/dataFreshness.ts +47 -0
- package/runtime/utils/storage.ts +41 -0
- package/scripts/clear.js +64 -0
- package/scripts/generate-composables.js +100 -0
- package/scripts/generate-enums.js +106 -0
- package/scripts/generate-metadata.js +165 -0
- package/scripts/generate-models.js +164 -0
- package/scripts/generate-schemas.js +443 -0
- package/scripts/generate-stores.js +90 -0
- package/scripts/generate-types.js +196 -0
- package/scripts/init.js +225 -0
- package/templates/model.template.ts +48 -0
- package/templates/schema.template.ts +13 -0
- package/templates/useNsdbModel.template.ts +9 -0
- package/types/config.ts +50 -0
- package/types/entities.ts +66 -0
- package/types/index.ts +14 -0
- package/types/list.ts +78 -0
- 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
|
+
}
|
package/helpers/args.js
ADDED
|
@@ -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
|
+
}
|
package/helpers/names.js
ADDED
|
@@ -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
|
+
}
|
package/helpers/shell.js
ADDED
|
@@ -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
|
+
}
|