@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,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { parseArgs } from '../helpers/args.js'
|
|
4
|
+
import { getOption, loadNsdbConfig } from '../helpers/config.js'
|
|
5
|
+
import { exists, readText, writeText, ensureDir } from '../helpers/io.js'
|
|
6
|
+
import {
|
|
7
|
+
createTsProject,
|
|
8
|
+
addSourceFile,
|
|
9
|
+
loadDatabaseAlias,
|
|
10
|
+
getPublicTablesType
|
|
11
|
+
} from '../helpers/ts.js'
|
|
12
|
+
import { modelHookName } from '../helpers/names.js'
|
|
13
|
+
import { markGenerated } from '../helpers/generated.js'
|
|
14
|
+
import { selectTableProperties } from '../helpers/tables.js'
|
|
15
|
+
|
|
16
|
+
export async function main() {
|
|
17
|
+
const parsedArguments = parseArgs()
|
|
18
|
+
const currentWorkingDirectory = process.cwd()
|
|
19
|
+
const { config } = await loadNsdbConfig(currentWorkingDirectory, parsedArguments.get('config', ''))
|
|
20
|
+
|
|
21
|
+
const typesFilePath = path.resolve(
|
|
22
|
+
currentWorkingDirectory,
|
|
23
|
+
getOption(parsedArguments, config, 'types', 'paths.types')
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const outputDirectory = path.resolve(
|
|
27
|
+
currentWorkingDirectory,
|
|
28
|
+
getOption(parsedArguments, config, 'outDir', 'paths.composables')
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const outputFilePath = path.join(outputDirectory, 'useNsdbModels.ts')
|
|
32
|
+
|
|
33
|
+
const templateFilePath = path.resolve(
|
|
34
|
+
currentWorkingDirectory,
|
|
35
|
+
getOption(parsedArguments, config, 'template', 'templates.useNsdbModel')
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if (!exists(typesFilePath)) {
|
|
39
|
+
console.error(`❌ Missing types file: ${typesFilePath}`)
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!exists(templateFilePath)) {
|
|
44
|
+
console.error(`❌ Missing template: ${templateFilePath}`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ensureDir(outputDirectory)
|
|
49
|
+
|
|
50
|
+
const project = createTsProject()
|
|
51
|
+
const sourceFile = addSourceFile(project, typesFilePath)
|
|
52
|
+
const databaseAlias = loadDatabaseAlias(sourceFile)
|
|
53
|
+
|
|
54
|
+
if (!databaseAlias) {
|
|
55
|
+
console.error('❌ Type alias "Database" not found')
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tablesType = getPublicTablesType(databaseAlias)
|
|
60
|
+
if (!tablesType) {
|
|
61
|
+
console.error('❌ Database["public"]["Tables"] not found')
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const templateContent = readText(templateFilePath)
|
|
66
|
+
|
|
67
|
+
const importLines = []
|
|
68
|
+
const caseLines = []
|
|
69
|
+
|
|
70
|
+
const tableProperties = selectTableProperties(tablesType, config.tables)
|
|
71
|
+
for (const tableProperty of tableProperties) {
|
|
72
|
+
const tableName = tableProperty.getName()
|
|
73
|
+
const hookName = modelHookName(tableName) // ex: playlists -> usePlaylists
|
|
74
|
+
|
|
75
|
+
importLines.push(
|
|
76
|
+
`import { ${hookName} } from '~~/nsdb/models/${tableName}'`
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
caseLines.push(
|
|
80
|
+
`\t\tcase '${tableName}':\n\t\t\treturn ${hookName}(opts)`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const finalContent =
|
|
85
|
+
templateContent
|
|
86
|
+
.replace('// __IMPORTS__', importLines.join('\n'))
|
|
87
|
+
.replace('// __CASES__', caseLines.join('\n'))
|
|
88
|
+
.trimEnd() + '\n'
|
|
89
|
+
|
|
90
|
+
writeText(outputFilePath, markGenerated(finalContent))
|
|
91
|
+
console.log('✅ useNsdbModels:', path.relative(currentWorkingDirectory, outputFilePath))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
95
|
+
main().catch((error) => {
|
|
96
|
+
console.error('❌ Unexpected error while generating composables.')
|
|
97
|
+
console.error(error)
|
|
98
|
+
process.exit(1)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { exists, ensureDir, writeText } 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
|
+
getPublicEnumsType
|
|
11
|
+
} from '../helpers/ts.js'
|
|
12
|
+
import { toPascal } from '../helpers/names.js'
|
|
13
|
+
import { GENERATED_FILE_MARKER } from '../helpers/generated.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the metadata needed to emit strongly typed enums derived from Database["public"]["Enums"].
|
|
17
|
+
*/
|
|
18
|
+
function extractEnumDescriptors(sourceFile, enumsType) {
|
|
19
|
+
const enumProperties = enumsType.getProperties()
|
|
20
|
+
return enumProperties.map((enumProperty) => {
|
|
21
|
+
const enumName = enumProperty.getName()
|
|
22
|
+
const pascalName = toPascal(enumName)
|
|
23
|
+
const enumType = enumProperty.getTypeAtLocation(sourceFile)
|
|
24
|
+
const unionMembers = enumType.isUnion() ? enumType.getUnionTypes() : []
|
|
25
|
+
const literalValues = unionMembers
|
|
26
|
+
.map((unionMember) => (
|
|
27
|
+
typeof unionMember.getLiteralValue === 'function'
|
|
28
|
+
? unionMember.getLiteralValue()
|
|
29
|
+
: undefined
|
|
30
|
+
))
|
|
31
|
+
.filter((literalValue) => typeof literalValue === 'string')
|
|
32
|
+
|
|
33
|
+
return { enumName, pascalName, literalValues }
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildContent(databaseImportPath, descriptors) {
|
|
38
|
+
const headerLines = [
|
|
39
|
+
GENERATED_FILE_MARKER,
|
|
40
|
+
`// Source: Database["public"]["Enums"]`,
|
|
41
|
+
``,
|
|
42
|
+
`import type { Database } from '${databaseImportPath}'`,
|
|
43
|
+
``,
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const enumBlocks = descriptors.flatMap((descriptor) => ([
|
|
47
|
+
`// Enum: ${descriptor.enumName}`,
|
|
48
|
+
`export type ${descriptor.pascalName} = Database['public']['Enums']['${descriptor.enumName}']`,
|
|
49
|
+
descriptor.literalValues.length
|
|
50
|
+
? `export const ${descriptor.pascalName}Values = ${JSON.stringify(descriptor.literalValues)} as const`
|
|
51
|
+
: `export const ${descriptor.pascalName}Values = [] as const`,
|
|
52
|
+
``,
|
|
53
|
+
]))
|
|
54
|
+
|
|
55
|
+
const enumMapLines = [
|
|
56
|
+
`export const Enums = {`,
|
|
57
|
+
...descriptors.map((descriptor) => `\t'${descriptor.enumName}': ${descriptor.pascalName}Values`),
|
|
58
|
+
`} as const`,
|
|
59
|
+
``,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
return [...headerLines, ...enumBlocks, ...enumMapLines].join('\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function main() {
|
|
66
|
+
const parsedArguments = parseArgs()
|
|
67
|
+
const currentWorkingDirectory = process.cwd()
|
|
68
|
+
const { config } = await loadNsdbConfig(currentWorkingDirectory, parsedArguments.get('config', ''))
|
|
69
|
+
const typesFilePath = path.resolve(currentWorkingDirectory, getOption(parsedArguments, config, 'types', 'paths.types'))
|
|
70
|
+
const outputFilePath = path.resolve(currentWorkingDirectory, getOption(parsedArguments, config, 'out', 'paths.enums'))
|
|
71
|
+
const databaseImportPath = getOption(parsedArguments, config, 'db-import-path', 'imports.databaseTypes')
|
|
72
|
+
|
|
73
|
+
if (!exists(typesFilePath)) {
|
|
74
|
+
console.error(`❌ Missing types file: ${typesFilePath}`)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const project = createTsProject()
|
|
79
|
+
const sourceFile = addSourceFile(project, typesFilePath)
|
|
80
|
+
const databaseAlias = loadDatabaseAlias(sourceFile)
|
|
81
|
+
if (!databaseAlias) {
|
|
82
|
+
console.error(`❌ Type alias "Database" not found in ${typesFilePath}`)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
const enumsType = getPublicEnumsType(databaseAlias)
|
|
86
|
+
if (!enumsType) {
|
|
87
|
+
console.warn('⚠️ Database["public"]["Enums"] not found — writing an empty file.')
|
|
88
|
+
ensureDir(path.dirname(outputFilePath))
|
|
89
|
+
writeText(outputFilePath, `${GENERATED_FILE_MARKER}\nexport {}\n`)
|
|
90
|
+
console.log(`✅ enums: ${path.relative(currentWorkingDirectory, outputFilePath)}`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const descriptors = extractEnumDescriptors(sourceFile, enumsType)
|
|
95
|
+
const fileContent = buildContent(databaseImportPath, descriptors)
|
|
96
|
+
writeText(outputFilePath, fileContent)
|
|
97
|
+
console.log(`✅ enums: ${path.relative(currentWorkingDirectory, outputFilePath)} — ${descriptors.length} enum(s)`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
101
|
+
main().catch((error) => {
|
|
102
|
+
console.error('❌ Unexpected error while generating enums.')
|
|
103
|
+
console.error(error)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import postgres from 'postgres'
|
|
4
|
+
import { parseArgs } from '../helpers/args.js'
|
|
5
|
+
import { getOption, loadNsdbConfig } from '../helpers/config.js'
|
|
6
|
+
import { writeText } from '../helpers/io.js'
|
|
7
|
+
|
|
8
|
+
function sameColumns(left, right) {
|
|
9
|
+
return left.length === right.length && left.every((column, index) => column === right[index])
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function introspectDatabase({ dbUrl, schemaName = 'public' }) {
|
|
13
|
+
const sql = postgres(dbUrl, { max: 1, prepare: false })
|
|
14
|
+
try {
|
|
15
|
+
const columns = await sql`
|
|
16
|
+
select
|
|
17
|
+
cls.relname as table_name,
|
|
18
|
+
att.attname as column_name,
|
|
19
|
+
att.attnum as ordinal_position,
|
|
20
|
+
pg_catalog.format_type(att.atttypid, att.atttypmod) as data_type,
|
|
21
|
+
not att.attnotnull as nullable,
|
|
22
|
+
pg_get_expr(def.adbin, def.adrelid) as default_expression,
|
|
23
|
+
att.attidentity as identity_kind,
|
|
24
|
+
att.attgenerated as generated_kind,
|
|
25
|
+
typ.typtype = 'e' as is_enum,
|
|
26
|
+
typ.typname as database_type
|
|
27
|
+
from pg_catalog.pg_attribute att
|
|
28
|
+
join pg_catalog.pg_class cls on cls.oid = att.attrelid
|
|
29
|
+
join pg_catalog.pg_namespace ns on ns.oid = cls.relnamespace
|
|
30
|
+
join pg_catalog.pg_type typ on typ.oid = att.atttypid
|
|
31
|
+
left join pg_catalog.pg_attrdef def
|
|
32
|
+
on def.adrelid = att.attrelid and def.adnum = att.attnum
|
|
33
|
+
where ns.nspname = ${schemaName}
|
|
34
|
+
and cls.relkind in ('r', 'p')
|
|
35
|
+
and att.attnum > 0
|
|
36
|
+
and not att.attisdropped
|
|
37
|
+
order by cls.relname, att.attnum
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const constraints = await sql`
|
|
41
|
+
select
|
|
42
|
+
con.conname as constraint_name,
|
|
43
|
+
con.contype as constraint_type,
|
|
44
|
+
local_table.relname as table_name,
|
|
45
|
+
foreign_table.relname as referenced_table,
|
|
46
|
+
array(
|
|
47
|
+
select local_att.attname
|
|
48
|
+
from unnest(con.conkey) with ordinality as key(attnum, position)
|
|
49
|
+
join pg_catalog.pg_attribute local_att
|
|
50
|
+
on local_att.attrelid = con.conrelid and local_att.attnum = key.attnum
|
|
51
|
+
order by key.position
|
|
52
|
+
) as columns,
|
|
53
|
+
case when con.confkey is null then array[]::text[] else array(
|
|
54
|
+
select foreign_att.attname
|
|
55
|
+
from unnest(con.confkey) with ordinality as key(attnum, position)
|
|
56
|
+
join pg_catalog.pg_attribute foreign_att
|
|
57
|
+
on foreign_att.attrelid = con.confrelid and foreign_att.attnum = key.attnum
|
|
58
|
+
order by key.position
|
|
59
|
+
) end as referenced_columns
|
|
60
|
+
from pg_catalog.pg_constraint con
|
|
61
|
+
join pg_catalog.pg_class local_table on local_table.oid = con.conrelid
|
|
62
|
+
join pg_catalog.pg_namespace ns on ns.oid = local_table.relnamespace
|
|
63
|
+
left join pg_catalog.pg_class foreign_table on foreign_table.oid = con.confrelid
|
|
64
|
+
where ns.nspname = ${schemaName} and con.contype in ('p', 'u', 'f')
|
|
65
|
+
order by local_table.relname, con.conname
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
const tables = {}
|
|
69
|
+
for (const column of columns) {
|
|
70
|
+
const table = tables[column.table_name] ??= {
|
|
71
|
+
primaryKey: [],
|
|
72
|
+
uniqueConstraints: [],
|
|
73
|
+
columns: {},
|
|
74
|
+
relationships: [],
|
|
75
|
+
}
|
|
76
|
+
const identity = column.identity_kind === 'a'
|
|
77
|
+
? 'always'
|
|
78
|
+
: column.identity_kind === 'd' ? 'byDefault' : false
|
|
79
|
+
const generated = column.generated_kind === 's'
|
|
80
|
+
? 'stored'
|
|
81
|
+
: column.generated_kind === 'v' ? 'virtual' : false
|
|
82
|
+
table.columns[column.column_name] = {
|
|
83
|
+
position: Number(column.ordinal_position),
|
|
84
|
+
dataType: column.data_type,
|
|
85
|
+
databaseType: column.database_type,
|
|
86
|
+
nullable: Boolean(column.nullable),
|
|
87
|
+
hasDefault: column.default_expression != null || Boolean(identity),
|
|
88
|
+
defaultExpression: column.default_expression,
|
|
89
|
+
identity,
|
|
90
|
+
generated,
|
|
91
|
+
enum: Boolean(column.is_enum),
|
|
92
|
+
primaryKey: false,
|
|
93
|
+
unique: false,
|
|
94
|
+
insertable: !generated && identity !== 'always',
|
|
95
|
+
updatable: !generated && identity !== 'always',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const constraint of constraints) {
|
|
100
|
+
const table = tables[constraint.table_name]
|
|
101
|
+
if (!table) continue
|
|
102
|
+
const constraintColumns = [...constraint.columns]
|
|
103
|
+
if (constraint.constraint_type === 'p') {
|
|
104
|
+
table.primaryKey = constraintColumns
|
|
105
|
+
for (const column of constraintColumns) {
|
|
106
|
+
table.columns[column].primaryKey = true
|
|
107
|
+
table.columns[column].updatable = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (constraint.constraint_type === 'p' || constraint.constraint_type === 'u') {
|
|
111
|
+
table.uniqueConstraints.push({ name: constraint.constraint_name, columns: constraintColumns })
|
|
112
|
+
if (constraintColumns.length === 1) table.columns[constraintColumns[0]].unique = true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const constraint of constraints.filter(item => item.constraint_type === 'f')) {
|
|
117
|
+
const table = tables[constraint.table_name]
|
|
118
|
+
if (!table) continue
|
|
119
|
+
const relationColumns = [...constraint.columns]
|
|
120
|
+
table.relationships.push({
|
|
121
|
+
foreignKeyName: constraint.constraint_name,
|
|
122
|
+
columns: relationColumns,
|
|
123
|
+
referencedRelation: constraint.referenced_table,
|
|
124
|
+
referencedColumns: [...constraint.referenced_columns],
|
|
125
|
+
isOneToOne: table.uniqueConstraints.some(unique => sameColumns(unique.columns, relationColumns)),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { version: 1, schema: schemaName, tables }
|
|
130
|
+
} finally {
|
|
131
|
+
await sql.end()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function main() {
|
|
136
|
+
const parsedArguments = parseArgs()
|
|
137
|
+
const currentWorkingDirectory = process.cwd()
|
|
138
|
+
const { config } = await loadNsdbConfig(currentWorkingDirectory, parsedArguments.get('config', ''))
|
|
139
|
+
const dbUrl = getOption(parsedArguments, config, 'db-url', 'supabase.dbUrl', process.env.SUPABASE_DB_URL || '')
|
|
140
|
+
const schemaName = getOption(parsedArguments, config, 'schema', 'supabase.schema', 'public')
|
|
141
|
+
const outputPath = path.resolve(
|
|
142
|
+
currentWorkingDirectory,
|
|
143
|
+
getOption(parsedArguments, config, 'out', 'paths.metadata', 'nsdb/database.metadata.json'),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if (!dbUrl) {
|
|
147
|
+
console.warn(
|
|
148
|
+
'⚠️ Metadata introspection skipped: configure supabase.dbUrl or SUPABASE_DB_URL. ' +
|
|
149
|
+
'Generation will fall back to Supabase TypeScript types and cannot reliably infer SQL defaults, identity/generated columns, composite constraints, or inverse relations.'
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const metadata = await introspectDatabase({ dbUrl, schemaName })
|
|
155
|
+
writeText(outputPath, `${JSON.stringify(metadata, null, 2)}\n`)
|
|
156
|
+
console.log(`✅ metadata: ${path.relative(currentWorkingDirectory, outputPath)}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
160
|
+
main().catch((error) => {
|
|
161
|
+
console.error('❌ Failed to introspect PostgreSQL metadata.')
|
|
162
|
+
console.error(error)
|
|
163
|
+
process.exit(1)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { parseArgs } from '../helpers/args.js'
|
|
4
|
+
import { getOption, loadNsdbConfig } from '../helpers/config.js'
|
|
5
|
+
import { exists, readText, writeText, ensureDir } from '../helpers/io.js'
|
|
6
|
+
import {
|
|
7
|
+
createTsProject,
|
|
8
|
+
addSourceFile,
|
|
9
|
+
loadDatabaseAlias,
|
|
10
|
+
getPublicTablesType
|
|
11
|
+
} from '../helpers/ts.js'
|
|
12
|
+
import { toPascal, modelHookName, storeName, schemaName } from '../helpers/names.js'
|
|
13
|
+
import { markGenerated, removeStaleGeneratedFiles } from '../helpers/generated.js'
|
|
14
|
+
import { selectTableProperties } from '../helpers/tables.js'
|
|
15
|
+
import { getColumnPolicies } from '../helpers/tables.js'
|
|
16
|
+
import { getTableMetadata, loadDatabaseMetadata } from '../helpers/metadata.js'
|
|
17
|
+
import { buildRelationCatalog } from '../helpers/relations.js'
|
|
18
|
+
|
|
19
|
+
function asKeyUnion(keys) {
|
|
20
|
+
return keys.length ? keys.map(key => `'${key}'`).join(' | ') : 'never'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function relationRowsType(relations, config, tableColumnsByName) {
|
|
24
|
+
if (!relations.length) return '{}'
|
|
25
|
+
const lines = relations.map(relation => {
|
|
26
|
+
const targetColumns = tableColumnsByName.get(relation.referencedTable) ?? []
|
|
27
|
+
const targetPolicies = getColumnPolicies(config.tables, relation.referencedTable, targetColumns)
|
|
28
|
+
const omitted = targetColumns.filter(column => targetPolicies[column].serverOnly || !targetPolicies[column].selectable)
|
|
29
|
+
const rowType = `Omit<Tables<'${relation.referencedTable}'>, ${asKeyUnion(omitted)}>`
|
|
30
|
+
const valueType = relation.kind === 'hasMany' || relation.kind === 'manyToMany'
|
|
31
|
+
? `${rowType}[]`
|
|
32
|
+
: `${rowType}${relation.nullable ? ' | null' : ''}`
|
|
33
|
+
return `\t'${relation.alias}': ${valueType}`
|
|
34
|
+
})
|
|
35
|
+
return `{\n${lines.join('\n')}\n}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildModelCode(tableProperty, templateContent, currentWorkingDirectory, config, databaseMetadata, locationNode, tableRelations, tableColumnsByName) {
|
|
39
|
+
const tableName = tableProperty.getName()
|
|
40
|
+
const pascalName = toPascal(tableName)
|
|
41
|
+
const rowTypeName = `${pascalName}Row`
|
|
42
|
+
const hookName = modelHookName(tableName)
|
|
43
|
+
const storeIdentifier = storeName(tableName)
|
|
44
|
+
const storeFileRelativePath = `stores/${storeIdentifier}.ts`
|
|
45
|
+
const storeAbsolutePath = path.resolve(currentWorkingDirectory, storeFileRelativePath)
|
|
46
|
+
const storeExists = exists(storeAbsolutePath)
|
|
47
|
+
const tableType = tableProperty.getTypeAtLocation(locationNode)
|
|
48
|
+
const rowType = tableType.getProperty('Row')?.getTypeAtLocation(locationNode)
|
|
49
|
+
const columnNames = rowType?.getProperties().map(property => property.getName()) ?? []
|
|
50
|
+
const policies = getColumnPolicies(config.tables, tableName, columnNames)
|
|
51
|
+
const tableMetadata = getTableMetadata(databaseMetadata, tableName)
|
|
52
|
+
const primaryKeyColumns = tableMetadata?.primaryKey ?? (columnNames.includes('id') ? ['id'] : [])
|
|
53
|
+
if (primaryKeyColumns.length !== 1) {
|
|
54
|
+
throw new Error(`[nsdb] ${tableName} requires exactly one primary key for generated CRUD; found ${primaryKeyColumns.length}.`)
|
|
55
|
+
}
|
|
56
|
+
const primaryKey = primaryKeyColumns[0]
|
|
57
|
+
const conventionalReadonly = ['id', 'created_at', 'updated_at', 'inserted_at']
|
|
58
|
+
const rowOmit = columnNames.filter(column => policies[column].serverOnly || !policies[column].selectable)
|
|
59
|
+
const insertOmit = columnNames.filter(column => {
|
|
60
|
+
if (policies[column].serverOnly || policies[column].editable === false) return true
|
|
61
|
+
if (tableMetadata) return !tableMetadata.columns[column]?.insertable
|
|
62
|
+
return conventionalReadonly.includes(column)
|
|
63
|
+
})
|
|
64
|
+
const updateOmit = columnNames.filter(column => {
|
|
65
|
+
if (policies[column].serverOnly || policies[column].editable === false) return true
|
|
66
|
+
if (tableMetadata) return !tableMetadata.columns[column]?.updatable
|
|
67
|
+
return conventionalReadonly.includes(column)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const code = templateContent
|
|
71
|
+
.replace(/__TABLE__/g, tableName)
|
|
72
|
+
.replace(/__PASCAL__/g, pascalName)
|
|
73
|
+
.replace(/__ROW__/g, rowTypeName)
|
|
74
|
+
.replace(/__HOOK__/g, hookName)
|
|
75
|
+
.replace(/__PRIMARY_KEY__/g, primaryKey)
|
|
76
|
+
.replace(/__ROW_OMIT__/g, asKeyUnion(rowOmit))
|
|
77
|
+
.replace(/__INSERT_OMIT__/g, asKeyUnion(insertOmit))
|
|
78
|
+
.replace(/__UPDATE_OMIT__/g, asKeyUnion(updateOmit))
|
|
79
|
+
.replace(/__RELATION_ROWS__/g, relationRowsType(tableRelations, config, tableColumnsByName))
|
|
80
|
+
.replace(/__STORE_IMPORT__/g, storeExists ? `import { ${storeIdentifier} } from '~~/stores/${storeIdentifier}'` : '')
|
|
81
|
+
.replace(/__STORE_CREATOR__/g, storeExists ? `(() => ${storeIdentifier}() as any)` : 'undefined')
|
|
82
|
+
|
|
83
|
+
return { code, hookName }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function main() {
|
|
87
|
+
const parsedArguments = parseArgs()
|
|
88
|
+
const currentWorkingDirectory = process.cwd()
|
|
89
|
+
const { config } = await loadNsdbConfig(currentWorkingDirectory, parsedArguments.get('config', ''))
|
|
90
|
+
const typesFilePath = path.resolve(currentWorkingDirectory, getOption(parsedArguments, config, 'types', 'paths.types'))
|
|
91
|
+
const outputDirectory = path.resolve(currentWorkingDirectory, getOption(parsedArguments, config, 'outDir', 'paths.models'))
|
|
92
|
+
const barrelFilePath = path.join(outputDirectory, 'index.ts')
|
|
93
|
+
const templateFilePath = path.resolve(
|
|
94
|
+
currentWorkingDirectory,
|
|
95
|
+
getOption(parsedArguments, config, 'template', 'templates.model')
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (!exists(typesFilePath)) {
|
|
99
|
+
console.error(`❌ Missing types file: ${typesFilePath}`)
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
if (!exists(templateFilePath)) {
|
|
103
|
+
console.error(`❌ Missing template: ${templateFilePath}`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
ensureDir(outputDirectory)
|
|
107
|
+
|
|
108
|
+
const project = createTsProject()
|
|
109
|
+
const sourceFile = addSourceFile(project, typesFilePath)
|
|
110
|
+
const databaseAlias = loadDatabaseAlias(sourceFile)
|
|
111
|
+
if (!databaseAlias) {
|
|
112
|
+
console.error('❌ Type alias "Database" not found')
|
|
113
|
+
process.exit(1)
|
|
114
|
+
}
|
|
115
|
+
const tablesType = getPublicTablesType(databaseAlias)
|
|
116
|
+
if (!tablesType) {
|
|
117
|
+
console.error('❌ Database["public"]["Tables"] not found')
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const templateContent = readText(templateFilePath)
|
|
122
|
+
const databaseMetadata = loadDatabaseMetadata(currentWorkingDirectory, config)
|
|
123
|
+
const exportStatements = []
|
|
124
|
+
const generatedFileNames = []
|
|
125
|
+
|
|
126
|
+
const tableProperties = selectTableProperties(tablesType, config.tables)
|
|
127
|
+
const exposedTableNames = new Set(tableProperties.map(property => property.getName()))
|
|
128
|
+
const relationCatalog = buildRelationCatalog(databaseMetadata, exposedTableNames)
|
|
129
|
+
const tableColumnsByName = new Map(tableProperties.map(property => {
|
|
130
|
+
const tableType = property.getTypeAtLocation(databaseAlias)
|
|
131
|
+
const rowType = tableType.getProperty('Row')?.getTypeAtLocation(databaseAlias)
|
|
132
|
+
return [property.getName(), rowType?.getProperties().map(column => column.getName()) ?? []]
|
|
133
|
+
}))
|
|
134
|
+
for (const tableProperty of tableProperties) {
|
|
135
|
+
const tableName = tableProperty.getName()
|
|
136
|
+
const { code, hookName } = buildModelCode(
|
|
137
|
+
tableProperty,
|
|
138
|
+
templateContent,
|
|
139
|
+
currentWorkingDirectory,
|
|
140
|
+
config,
|
|
141
|
+
databaseMetadata,
|
|
142
|
+
databaseAlias,
|
|
143
|
+
relationCatalog[tableName] ?? [],
|
|
144
|
+
tableColumnsByName,
|
|
145
|
+
)
|
|
146
|
+
const modelFilePath = path.join(outputDirectory, `${tableName}.ts`)
|
|
147
|
+
writeText(modelFilePath, markGenerated(code))
|
|
148
|
+
console.log('✅ model:', path.relative(currentWorkingDirectory, modelFilePath))
|
|
149
|
+
exportStatements.push(`export * from './${tableName}' // ${hookName}`)
|
|
150
|
+
generatedFileNames.push(`${tableName}.ts`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
writeText(barrelFilePath, markGenerated(exportStatements.join('\n') + '\n'))
|
|
154
|
+
removeStaleGeneratedFiles(outputDirectory, ['index.ts', ...generatedFileNames])
|
|
155
|
+
console.log('✅ models barrel:', path.relative(currentWorkingDirectory, barrelFilePath))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
159
|
+
main().catch((error) => {
|
|
160
|
+
console.error('❌ Unexpected error while generating models.')
|
|
161
|
+
console.error(error)
|
|
162
|
+
process.exit(1)
|
|
163
|
+
})
|
|
164
|
+
}
|