@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
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { exists, readText, writeText, ensureDir } from '../helpers/io.js'
|
|
4
|
+
import { parseArgs } from '../helpers/args.js'
|
|
5
|
+
import { getOption, loadNsdbConfig } from '../helpers/config.js'
|
|
6
|
+
import {
|
|
7
|
+
createTsProject,
|
|
8
|
+
addSourceFile,
|
|
9
|
+
loadDatabaseAlias,
|
|
10
|
+
getPublicTablesType
|
|
11
|
+
} from '../helpers/ts.js'
|
|
12
|
+
import { toPascal } from '../helpers/names.js'
|
|
13
|
+
import { markGenerated, removeStaleGeneratedFiles } from '../helpers/generated.js'
|
|
14
|
+
import { getColumnPolicies, selectTableProperties } from '../helpers/tables.js'
|
|
15
|
+
import { getTableMetadata, loadDatabaseMetadata } from '../helpers/metadata.js'
|
|
16
|
+
import { buildRelationCatalog } from '../helpers/relations.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ancien helper (conservé au cas où tu en aurais besoin plus tard).
|
|
20
|
+
* Il n'est plus utilisé pour générer le schema UI, mais je le laisse
|
|
21
|
+
* pour ne pas "perdre" la fonctionnalité.
|
|
22
|
+
*/
|
|
23
|
+
function guessFieldKindFromTypeText(typeText) {
|
|
24
|
+
const normalizedText = String(typeText || '').toLowerCase()
|
|
25
|
+
|
|
26
|
+
if (normalizedText.includes('uuid')) return 'uuid'
|
|
27
|
+
if (normalizedText.includes('timestamp') || normalizedText.includes('date')) return 'timestamp'
|
|
28
|
+
if (normalizedText.includes('bool')) return 'boolean'
|
|
29
|
+
if (
|
|
30
|
+
normalizedText.includes('int') ||
|
|
31
|
+
normalizedText.includes('number') ||
|
|
32
|
+
normalizedText.includes('float') ||
|
|
33
|
+
normalizedText.includes('numeric')
|
|
34
|
+
) {
|
|
35
|
+
return 'number'
|
|
36
|
+
}
|
|
37
|
+
if (normalizedText.includes('json')) return 'json'
|
|
38
|
+
if (
|
|
39
|
+
normalizedText.includes('string') ||
|
|
40
|
+
normalizedText.includes('text') ||
|
|
41
|
+
normalizedText.includes('char') ||
|
|
42
|
+
normalizedText.includes('varchar')
|
|
43
|
+
) {
|
|
44
|
+
return 'string'
|
|
45
|
+
}
|
|
46
|
+
return 'unknown'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Nouveau helper : mappe le type TS (textuel) vers ton FieldType UI
|
|
51
|
+
* ('text' | 'number' | 'checkbox' | 'datetime' | 'textarea' | ...).
|
|
52
|
+
* Les enums seront forcés plus bas à 'select'.
|
|
53
|
+
*/
|
|
54
|
+
function guessFieldTypeForUi(typeText, columnMetadata = null) {
|
|
55
|
+
const databaseType = String(columnMetadata?.dataType ?? '').toLowerCase()
|
|
56
|
+
if (databaseType.endsWith('[]')) return 'array'
|
|
57
|
+
if (databaseType === 'json' || databaseType === 'jsonb') return 'json'
|
|
58
|
+
if (databaseType === 'date') return 'date'
|
|
59
|
+
if (databaseType.includes('timestamp')) return 'datetime'
|
|
60
|
+
if (databaseType.includes('bool')) return 'checkbox'
|
|
61
|
+
if (/^(smallint|integer|bigint|numeric|decimal|real|double precision)/.test(databaseType)) return 'number'
|
|
62
|
+
|
|
63
|
+
const normalized = String(typeText || '').toLowerCase()
|
|
64
|
+
|
|
65
|
+
if (normalized.includes('bool')) return 'checkbox'
|
|
66
|
+
if (
|
|
67
|
+
normalized.includes('int') ||
|
|
68
|
+
normalized.includes('number') ||
|
|
69
|
+
normalized.includes('float') ||
|
|
70
|
+
normalized.includes('numeric')
|
|
71
|
+
) {
|
|
72
|
+
return 'number'
|
|
73
|
+
}
|
|
74
|
+
if (normalized.includes('timestamp') || normalized.includes('date')) {
|
|
75
|
+
return 'datetime'
|
|
76
|
+
}
|
|
77
|
+
if (normalized.includes('json')) return 'json'
|
|
78
|
+
if (normalized.includes('[]')) return 'array'
|
|
79
|
+
// Pour le reste, on part sur du 'text' par défaut
|
|
80
|
+
return 'text'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractEnumNameFromTypeText(typeText) {
|
|
84
|
+
const enumMatch = String(typeText || '')
|
|
85
|
+
.replace(/'/g, '"')
|
|
86
|
+
.match(/Database\["public"\]\["Enums"\]\["([^"]+)"\]/)
|
|
87
|
+
|
|
88
|
+
return enumMatch?.[1] ?? null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractLiteralOptions(typeText) {
|
|
92
|
+
return [...String(typeText || '').matchAll(/["']([^"']+)["']/g)]
|
|
93
|
+
.map(match => match[1])
|
|
94
|
+
.filter((value, index, values) => values.indexOf(value) === index)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function humanizeIdentifier(identifier) {
|
|
98
|
+
const words = String(identifier).replace(/_/g, ' ').trim()
|
|
99
|
+
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : identifier
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isFieldRequired(insertType, fieldName) {
|
|
103
|
+
const insertPropertySymbol = insertType.getProperty(fieldName)
|
|
104
|
+
if (!insertPropertySymbol) return false
|
|
105
|
+
|
|
106
|
+
const declaration = insertPropertySymbol.getDeclarations()?.[0]
|
|
107
|
+
const isOptional = declaration?.hasQuestionToken?.() ?? false
|
|
108
|
+
return !isOptional
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Récupère le texte brut du type tel qu'il est écrit dans `Insert`
|
|
112
|
+
// (ex: `Database["public"]["Enums"]["PROVIDERS"] | null`)
|
|
113
|
+
function getInsertPropertyTypeNodeText(insertType, fieldName) {
|
|
114
|
+
const insertPropertySymbol = insertType.getProperty(fieldName)
|
|
115
|
+
if (!insertPropertySymbol) return null
|
|
116
|
+
|
|
117
|
+
const declaration = insertPropertySymbol.getDeclarations()?.[0]
|
|
118
|
+
if (!declaration) return null
|
|
119
|
+
|
|
120
|
+
const typeNode = declaration.getTypeNode()
|
|
121
|
+
if (!typeNode) return null
|
|
122
|
+
|
|
123
|
+
return typeNode.getText()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Lit la propriété `Relationships` d'une table Supabase
|
|
128
|
+
* et renvoie une liste d'objets JS :
|
|
129
|
+
* {
|
|
130
|
+
* foreignKeyName,
|
|
131
|
+
* columns: string[],
|
|
132
|
+
* isOneToOne: boolean,
|
|
133
|
+
* referencedRelation: string,
|
|
134
|
+
* referencedColumns: string[]
|
|
135
|
+
* }
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ IMPORTANT : on lit les TYPES (getTypeAtLocation + getText),
|
|
138
|
+
* pas des initializers (il n'y en a pas dans les déclarations de type).
|
|
139
|
+
*/
|
|
140
|
+
function getRelationshipsForTable(tableType, locationNode) {
|
|
141
|
+
const relProperty = tableType.getProperty('Relationships')
|
|
142
|
+
if (!relProperty) return []
|
|
143
|
+
|
|
144
|
+
const relType = relProperty.getTypeAtLocation(locationNode)
|
|
145
|
+
const tupleElements = relType.isTuple() ? relType.getTupleElements() : []
|
|
146
|
+
|
|
147
|
+
return tupleElements.map((elType) => {
|
|
148
|
+
const t = elType.getApparentType()
|
|
149
|
+
|
|
150
|
+
function getLiteralFromType(propName) {
|
|
151
|
+
const prop = t.getProperty(propName)
|
|
152
|
+
if (!prop) return undefined
|
|
153
|
+
const type = prop.getTypeAtLocation(locationNode)
|
|
154
|
+
const text = type.getText() // ex: '"songs_profile_id_fkey"' ou 'false'
|
|
155
|
+
return text.replace(/['"`]/g, '')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getArrayFromType(propName) {
|
|
159
|
+
const prop = t.getProperty(propName)
|
|
160
|
+
if (!prop) return []
|
|
161
|
+
const type = prop.getTypeAtLocation(locationNode)
|
|
162
|
+
const text = type.getText() // ex: '["playlist_id"]' ou '["id"]'
|
|
163
|
+
|
|
164
|
+
const matches = []
|
|
165
|
+
const regex = /"([^"]+)"/g
|
|
166
|
+
let m
|
|
167
|
+
while ((m = regex.exec(text)) !== null) {
|
|
168
|
+
matches.push(m[1])
|
|
169
|
+
}
|
|
170
|
+
return matches
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const foreignKeyName = getLiteralFromType('foreignKeyName')
|
|
174
|
+
const columns = getArrayFromType('columns')
|
|
175
|
+
const referencedRelation = getLiteralFromType('referencedRelation')
|
|
176
|
+
const referencedColumns = getArrayFromType('referencedColumns')
|
|
177
|
+
const isOneToOneText = getLiteralFromType('isOneToOne')
|
|
178
|
+
const isOneToOne = isOneToOneText === 'true'
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
foreignKeyName,
|
|
182
|
+
columns,
|
|
183
|
+
isOneToOne,
|
|
184
|
+
referencedRelation,
|
|
185
|
+
referencedColumns,
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildSchemaForTable(tableProperty, locationNode, exposedTableNames, config, databaseMetadata, tableRelations) {
|
|
191
|
+
const tableName = tableProperty.getName()
|
|
192
|
+
const pascalTableName = toPascal(tableName)
|
|
193
|
+
const rowTypeAliasName = `${pascalTableName}Row`
|
|
194
|
+
|
|
195
|
+
const tableType = tableProperty.getTypeAtLocation(locationNode)
|
|
196
|
+
const rowProperty = tableType.getProperty('Row')
|
|
197
|
+
const insertProperty = tableType.getProperty('Insert')
|
|
198
|
+
const updateProperty = tableType.getProperty('Update')
|
|
199
|
+
|
|
200
|
+
if (!rowProperty || !insertProperty || !updateProperty) return null
|
|
201
|
+
|
|
202
|
+
const rowType = rowProperty.getTypeAtLocation(locationNode)
|
|
203
|
+
const insertType = insertProperty.getTypeAtLocation(locationNode)
|
|
204
|
+
const updateType = updateProperty.getTypeAtLocation(locationNode)
|
|
205
|
+
|
|
206
|
+
// 🔥 On lit les relations pour cette table
|
|
207
|
+
const tableMetadata = getTableMetadata(databaseMetadata, tableName)
|
|
208
|
+
const relationships = (tableMetadata?.relationships ?? getRelationshipsForTable(tableType, locationNode))
|
|
209
|
+
.filter(relation => exposedTableNames.has(relation.referencedRelation))
|
|
210
|
+
console.log(`🔍 Table "${tableName}" - found ${relationships.length} relationships.`)
|
|
211
|
+
|
|
212
|
+
// Map: nom de colonne locale -> descriptor de relation
|
|
213
|
+
const relationByColumn = new Map()
|
|
214
|
+
for (const rel of relationships) {
|
|
215
|
+
if (rel.columns.length !== 1) continue
|
|
216
|
+
for (const col of rel.columns) {
|
|
217
|
+
relationByColumn.set(col, rel)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const fieldLines = []
|
|
222
|
+
const rowFields = rowType.getProperties()
|
|
223
|
+
const columnPolicies = getColumnPolicies(config.tables, tableName, rowFields.map(field => field.getName()))
|
|
224
|
+
|
|
225
|
+
for (const rowField of rowFields) {
|
|
226
|
+
const fieldName = rowField.getName()
|
|
227
|
+
const columnMetadata = tableMetadata?.columns?.[fieldName] ?? null
|
|
228
|
+
const columnPolicy = columnPolicies[fieldName]
|
|
229
|
+
if (columnPolicy.serverOnly) continue
|
|
230
|
+
const fieldType = rowField.getTypeAtLocation(locationNode)
|
|
231
|
+
const rowFieldTypeText = fieldType.getText()
|
|
232
|
+
const fallbackNullable = /(^|\s)null(\s|$)/.test(rowFieldTypeText.replace(/\|/g, ' '))
|
|
233
|
+
|
|
234
|
+
const insertPropertySymbol = insertType.getProperty(fieldName)
|
|
235
|
+
const insertFieldTypeText = insertPropertySymbol
|
|
236
|
+
?.getTypeAtLocation(locationNode)
|
|
237
|
+
.getText()
|
|
238
|
+
|
|
239
|
+
const fieldIsRequired = columnMetadata
|
|
240
|
+
? Boolean(columnMetadata.insertable && !columnMetadata.nullable && !columnMetadata.hasDefault)
|
|
241
|
+
: isFieldRequired(insertType, fieldName)
|
|
242
|
+
|
|
243
|
+
// Texte du type tel qu'il est écrit dans Insert
|
|
244
|
+
// => ex: Database["public"]["Enums"]["PROVIDERS"] | null
|
|
245
|
+
const insertRawTypeReferenceText = getInsertPropertyTypeNodeText(
|
|
246
|
+
insertType,
|
|
247
|
+
fieldName
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const enumName = extractEnumNameFromTypeText(insertRawTypeReferenceText)
|
|
251
|
+
const literalOptions = enumName ? [] : extractLiteralOptions(insertRawTypeReferenceText)
|
|
252
|
+
|
|
253
|
+
// Type UI par défaut (FieldType)
|
|
254
|
+
let uiFieldType = guessFieldTypeForUi(
|
|
255
|
+
insertFieldTypeText || rowFieldTypeText,
|
|
256
|
+
columnMetadata,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
// Si on a trouvé un enum sur Insert, on force le type à 'select'
|
|
260
|
+
let optionsAttachment = ''
|
|
261
|
+
if (enumName) {
|
|
262
|
+
uiFieldType = 'select'
|
|
263
|
+
optionsAttachment =
|
|
264
|
+
`, options: Enums.${toPascal(enumName)}Values.map(v => ({ label: String(v), value: v }))`
|
|
265
|
+
}
|
|
266
|
+
else if (literalOptions.length > 1) {
|
|
267
|
+
uiFieldType = 'select'
|
|
268
|
+
optionsAttachment = `, options: ${JSON.stringify(literalOptions)}.map(v => ({ label: String(v), value: v }))`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const isPrimaryKey = columnMetadata
|
|
272
|
+
? Boolean(columnMetadata.primaryKey)
|
|
273
|
+
: fieldName === 'id'
|
|
274
|
+
const policyAllowsEditing = columnPolicy.editable !== false
|
|
275
|
+
const isConventionallyReadonly = isPrimaryKey || ['created_at', 'updated_at', 'inserted_at'].includes(fieldName)
|
|
276
|
+
const isInsertable = policyAllowsEditing && (columnMetadata
|
|
277
|
+
? Boolean(columnMetadata.insertable)
|
|
278
|
+
: Boolean(insertType.getProperty(fieldName)) && !isConventionallyReadonly)
|
|
279
|
+
const isUpdatable = policyAllowsEditing && (columnMetadata
|
|
280
|
+
? Boolean(columnMetadata.updatable)
|
|
281
|
+
: Boolean(updateType.getProperty(fieldName)) && !isConventionallyReadonly)
|
|
282
|
+
const isEditable = isInsertable || isUpdatable
|
|
283
|
+
const isReadOnlyField = !isEditable
|
|
284
|
+
const fallbackHasDefault = !columnMetadata && isInsertable && !fieldIsRequired && !fallbackNullable
|
|
285
|
+
|
|
286
|
+
// 🔗 Relation éventuelle pour ce champ
|
|
287
|
+
const relationDescriptor = relationByColumn.get(fieldName)
|
|
288
|
+
let relationAttachment = ''
|
|
289
|
+
|
|
290
|
+
if (relationDescriptor) {
|
|
291
|
+
// kind simple: si isOneToOne => 'hasOne', sinon 'belongsTo'
|
|
292
|
+
const kind = relationDescriptor.isOneToOne ? 'hasOne' : 'belongsTo'
|
|
293
|
+
const catalogRelation = tableRelations.find(
|
|
294
|
+
relation => relation.foreignKeyName === relationDescriptor.foreignKeyName && relation.direction === 'forward',
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
uiFieldType = 'relation' // on force le type UI pour les FK
|
|
298
|
+
|
|
299
|
+
relationAttachment =
|
|
300
|
+
`, relation: {` +
|
|
301
|
+
` alias: '${catalogRelation?.alias ?? relationDescriptor.referencedRelation}',` +
|
|
302
|
+
` kind: '${kind}',` +
|
|
303
|
+
` direction: '${catalogRelation?.direction ?? 'forward'}',` +
|
|
304
|
+
` referencedTable: '${relationDescriptor.referencedRelation}',` +
|
|
305
|
+
` embedResource: '${catalogRelation?.embedResource ?? relationDescriptor.referencedRelation}',` +
|
|
306
|
+
` localColumns: [${relationDescriptor.columns.map((c) => `'${c}'`).join(', ')}],` +
|
|
307
|
+
` referencedColumns: [${relationDescriptor.referencedColumns
|
|
308
|
+
.map((c) => `'${c}'`)
|
|
309
|
+
.join(', ')}],` +
|
|
310
|
+
` foreignKeyName: '${relationDescriptor.foreignKeyName}',` +
|
|
311
|
+
` nullable: ${catalogRelation?.nullable ?? false},` +
|
|
312
|
+
` composite: ${catalogRelation?.composite ?? false}` +
|
|
313
|
+
` }`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
fieldLines.push(
|
|
317
|
+
`\t${fieldName}: {` +
|
|
318
|
+
` label: '${humanizeIdentifier(fieldName)}',` +
|
|
319
|
+
` type: '${uiFieldType}',` +
|
|
320
|
+
` required: ${fieldIsRequired}` +
|
|
321
|
+
`, selectable: ${columnPolicy.selectable}` +
|
|
322
|
+
`, editable: ${isEditable}` +
|
|
323
|
+
`, insertable: ${isInsertable}` +
|
|
324
|
+
`, updatable: ${isUpdatable}` +
|
|
325
|
+
`${columnPolicy.hidden ? ', hidden: true' : ''}` +
|
|
326
|
+
`${isReadOnlyField ? ', readonly: true' : ''}` +
|
|
327
|
+
`${isPrimaryKey ? ', primaryKey: true' : ''}` +
|
|
328
|
+
`${columnMetadata ? `, nullable: ${columnMetadata.nullable}, hasDefault: ${columnMetadata.hasDefault}, databaseType: ${JSON.stringify(columnMetadata.dataType)}, defaultExpression: ${JSON.stringify(columnMetadata.defaultExpression)}` : `, nullable: ${fallbackNullable}, hasDefault: ${fallbackHasDefault}`}` +
|
|
329
|
+
`${optionsAttachment}` +
|
|
330
|
+
`${relationAttachment}` +
|
|
331
|
+
` },`
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { tableName, pascalTableName, rowTypeAliasName, fieldLines, relations: tableRelations }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderTemplate(templateContent, descriptor) {
|
|
339
|
+
return templateContent
|
|
340
|
+
.replace(/__TABLE__/g, descriptor.tableName)
|
|
341
|
+
.replace(/__PASCAL__/g, descriptor.pascalTableName)
|
|
342
|
+
.replace(/__ROW__/g, descriptor.rowTypeAliasName)
|
|
343
|
+
.replace('__RELATIONS__', JSON.stringify(descriptor.relations, null, 2))
|
|
344
|
+
.replace('// __FIELDS__', descriptor.fieldLines.join('\n'))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function main() {
|
|
348
|
+
const parsedArguments = parseArgs()
|
|
349
|
+
const currentWorkingDirectory = process.cwd()
|
|
350
|
+
const { config } = await loadNsdbConfig(currentWorkingDirectory, parsedArguments.get('config', ''))
|
|
351
|
+
|
|
352
|
+
const typesFilePath = path.resolve(
|
|
353
|
+
currentWorkingDirectory,
|
|
354
|
+
getOption(parsedArguments, config, 'types', 'paths.types')
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const outputDirectory = path.resolve(
|
|
358
|
+
currentWorkingDirectory,
|
|
359
|
+
getOption(parsedArguments, config, 'outDir', 'paths.schemas')
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
const barrelFilePath = path.join(outputDirectory, 'index.ts')
|
|
363
|
+
|
|
364
|
+
const templateFilePath = path.resolve(
|
|
365
|
+
currentWorkingDirectory,
|
|
366
|
+
getOption(parsedArguments, config, 'template', 'templates.schema')
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if (!exists(typesFilePath)) {
|
|
370
|
+
console.error(`❌ Missing types file: ${typesFilePath}`)
|
|
371
|
+
process.exit(1)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!exists(templateFilePath)) {
|
|
375
|
+
console.error(`❌ Missing template: ${templateFilePath}`)
|
|
376
|
+
process.exit(1)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
ensureDir(outputDirectory)
|
|
380
|
+
|
|
381
|
+
const project = createTsProject()
|
|
382
|
+
const sourceFile = addSourceFile(project, typesFilePath)
|
|
383
|
+
const databaseAlias = loadDatabaseAlias(sourceFile)
|
|
384
|
+
|
|
385
|
+
if (!databaseAlias) {
|
|
386
|
+
console.error('❌ Type alias "Database" not found')
|
|
387
|
+
process.exit(1)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const tablesType = getPublicTablesType(databaseAlias)
|
|
391
|
+
|
|
392
|
+
if (!tablesType) {
|
|
393
|
+
console.error('❌ Database["public"]["Tables"] not found')
|
|
394
|
+
process.exit(1)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const templateContent = readText(templateFilePath)
|
|
398
|
+
const databaseMetadata = loadDatabaseMetadata(currentWorkingDirectory, config)
|
|
399
|
+
const exportStatements = []
|
|
400
|
+
const generatedFileNames = []
|
|
401
|
+
|
|
402
|
+
const tableProperties = selectTableProperties(tablesType, config.tables)
|
|
403
|
+
const exposedTableNames = new Set(tableProperties.map(tableProperty => tableProperty.getName()))
|
|
404
|
+
const relationCatalog = buildRelationCatalog(databaseMetadata, exposedTableNames)
|
|
405
|
+
for (const tableProperty of tableProperties) {
|
|
406
|
+
const tableName = tableProperty.getName()
|
|
407
|
+
const schemaDescriptor = buildSchemaForTable(
|
|
408
|
+
tableProperty,
|
|
409
|
+
databaseAlias,
|
|
410
|
+
exposedTableNames,
|
|
411
|
+
config,
|
|
412
|
+
databaseMetadata,
|
|
413
|
+
relationCatalog[tableName] ?? [],
|
|
414
|
+
)
|
|
415
|
+
if (!schemaDescriptor || !schemaDescriptor.fieldLines.length) continue
|
|
416
|
+
|
|
417
|
+
const fileContent = renderTemplate(templateContent, schemaDescriptor)
|
|
418
|
+
const schemaFilePath = path.join(
|
|
419
|
+
outputDirectory,
|
|
420
|
+
`${schemaDescriptor.tableName}.ts`
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
writeText(schemaFilePath, markGenerated(fileContent))
|
|
424
|
+
console.log('✅ schema:', path.relative(currentWorkingDirectory, schemaFilePath))
|
|
425
|
+
|
|
426
|
+
exportStatements.push(
|
|
427
|
+
`export * from './${schemaDescriptor.tableName}' // ${schemaDescriptor.pascalTableName}Schema`
|
|
428
|
+
)
|
|
429
|
+
generatedFileNames.push(`${schemaDescriptor.tableName}.ts`)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
writeText(barrelFilePath, markGenerated(exportStatements.join('\n') + '\n'))
|
|
433
|
+
removeStaleGeneratedFiles(outputDirectory, ['index.ts', ...generatedFileNames])
|
|
434
|
+
console.log('✅ schemas barrel:', path.relative(currentWorkingDirectory, barrelFilePath))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
438
|
+
main().catch((error) => {
|
|
439
|
+
console.error('❌ Unexpected error while generating schemas.')
|
|
440
|
+
console.error(error)
|
|
441
|
+
process.exit(1)
|
|
442
|
+
})
|
|
443
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { parseArgs } from '../helpers/args.js'
|
|
4
|
+
import { getBoolOption, getOption, loadNsdbConfig } from '../helpers/config.js'
|
|
5
|
+
import { exists, listFiles, writeText, ensureDir } from '../helpers/io.js'
|
|
6
|
+
import { toPascal, storeName } from '../helpers/names.js'
|
|
7
|
+
import { markGenerated, removeStaleGeneratedFiles } from '../helpers/generated.js'
|
|
8
|
+
import { isNsdbGeneratedFile } from '../helpers/generated.js'
|
|
9
|
+
import { getTableMetadata, loadDatabaseMetadata } from '../helpers/metadata.js'
|
|
10
|
+
|
|
11
|
+
function loadTableNames(modelsDirectoryPath) {
|
|
12
|
+
if (!exists(modelsDirectoryPath)) {
|
|
13
|
+
console.error(`❌ Missing models directory: ${modelsDirectoryPath}`)
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entries = listFiles(modelsDirectoryPath)
|
|
18
|
+
return entries
|
|
19
|
+
.filter((entry) => entry.endsWith('.ts') && entry !== 'index.ts')
|
|
20
|
+
.filter((entry) => isNsdbGeneratedFile(path.join(modelsDirectoryPath, entry)))
|
|
21
|
+
.map((entry) => entry.replace(/\.ts$/, ''))
|
|
22
|
+
.sort()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderStoreFile({ tableName, storeIdentifier, rowTypeName, typesImportPath, primaryKey }) {
|
|
26
|
+
return `import { createDbStore } from '@lucashw68/nsdb/createDbStore'
|
|
27
|
+
import type { Tables } from '${typesImportPath}'
|
|
28
|
+
|
|
29
|
+
type ${rowTypeName} = Tables<'${tableName}'>
|
|
30
|
+
|
|
31
|
+
export const ${storeIdentifier} = createDbStore<${rowTypeName}>('${tableName}', {
|
|
32
|
+
key: '${primaryKey}',
|
|
33
|
+
orderBy: '${primaryKey}',
|
|
34
|
+
defaultSort: 'desc',
|
|
35
|
+
})
|
|
36
|
+
`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function main() {
|
|
40
|
+
const parsedArguments = parseArgs()
|
|
41
|
+
const currentWorkingDirectory = process.cwd()
|
|
42
|
+
const { config } = await loadNsdbConfig(currentWorkingDirectory, parsedArguments.get('config', ''))
|
|
43
|
+
const modelsDirectoryPath = path.resolve(currentWorkingDirectory, getOption(parsedArguments, config, 'models-dir', 'paths.models'))
|
|
44
|
+
const storesDirectoryPath = path.resolve(currentWorkingDirectory, getOption(parsedArguments, config, 'stores-dir', 'paths.stores'))
|
|
45
|
+
const typesImportPath = getOption(parsedArguments, config, 'types-import-path', 'imports.databaseTypes')
|
|
46
|
+
const shouldOverwriteExisting = getBoolOption(parsedArguments, config, 'force', 'generators.force', false)
|
|
47
|
+
const databaseMetadata = loadDatabaseMetadata(currentWorkingDirectory, config)
|
|
48
|
+
|
|
49
|
+
const tableNames = loadTableNames(modelsDirectoryPath)
|
|
50
|
+
if (!tableNames.length) {
|
|
51
|
+
console.warn('⚠️ No model files found. Run generate:models first.')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ensureDir(storesDirectoryPath)
|
|
56
|
+
|
|
57
|
+
for (const tableName of tableNames) {
|
|
58
|
+
const pascalName = toPascal(tableName)
|
|
59
|
+
const rowTypeName = `${pascalName}Row`
|
|
60
|
+
const storeIdentifier = storeName(tableName)
|
|
61
|
+
const primaryKeyColumns = getTableMetadata(databaseMetadata, tableName)?.primaryKey ?? ['id']
|
|
62
|
+
if (primaryKeyColumns.length !== 1) {
|
|
63
|
+
throw new Error(`[nsdb] ${tableName} requires exactly one primary key for a generated store.`)
|
|
64
|
+
}
|
|
65
|
+
const primaryKey = primaryKeyColumns[0]
|
|
66
|
+
const storeFilePath = path.join(storesDirectoryPath, `${storeIdentifier}.ts`)
|
|
67
|
+
|
|
68
|
+
if (exists(storeFilePath) && !shouldOverwriteExisting) {
|
|
69
|
+
console.log(`⚠️ ${path.relative(currentWorkingDirectory, storeFilePath)} already exists, skipping.`)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fileContent = renderStoreFile({ tableName, storeIdentifier, rowTypeName, typesImportPath, primaryKey })
|
|
74
|
+
writeText(storeFilePath, markGenerated(fileContent))
|
|
75
|
+
console.log(`✅ store: ${path.relative(currentWorkingDirectory, storeFilePath)}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
removeStaleGeneratedFiles(
|
|
79
|
+
storesDirectoryPath,
|
|
80
|
+
tableNames.map(tableName => `${storeName(tableName)}.ts`),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
85
|
+
main().catch((error) => {
|
|
86
|
+
console.error('❌ Unexpected error while generating stores.')
|
|
87
|
+
console.error(error)
|
|
88
|
+
process.exit(1)
|
|
89
|
+
})
|
|
90
|
+
}
|