on-zero 0.1.22 → 0.1.24
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/dist/cjs/cli.cjs +17 -424
- package/dist/cjs/cli.js +7 -402
- package/dist/cjs/cli.js.map +2 -2
- package/dist/cjs/cli.native.js +15 -519
- package/dist/cjs/cli.native.js.map +1 -1
- package/dist/cjs/generate.cjs +370 -0
- package/dist/cjs/generate.js +339 -0
- package/dist/cjs/generate.js.map +6 -0
- package/dist/cjs/generate.native.js +464 -0
- package/dist/cjs/generate.native.js.map +1 -0
- package/dist/cjs/generate.test.cjs +113 -0
- package/dist/cjs/generate.test.js +126 -0
- package/dist/cjs/generate.test.js.map +6 -0
- package/dist/cjs/generate.test.native.js +116 -0
- package/dist/cjs/generate.test.native.js.map +1 -0
- package/dist/cjs/vite-plugin.cjs +37 -121
- package/dist/cjs/vite-plugin.js +41 -100
- package/dist/cjs/vite-plugin.js.map +1 -1
- package/dist/cjs/vite-plugin.native.js +47 -157
- package/dist/cjs/vite-plugin.native.js.map +1 -1
- package/dist/esm/cli.js +8 -388
- package/dist/esm/cli.js.map +2 -2
- package/dist/esm/cli.mjs +17 -402
- package/dist/esm/cli.mjs.map +1 -1
- package/dist/esm/cli.native.js +15 -497
- package/dist/esm/cli.native.js.map +1 -1
- package/dist/esm/generate.js +317 -0
- package/dist/esm/generate.js.map +6 -0
- package/dist/esm/generate.mjs +335 -0
- package/dist/esm/generate.mjs.map +1 -0
- package/dist/esm/generate.native.js +426 -0
- package/dist/esm/generate.native.js.map +1 -0
- package/dist/esm/generate.test.js +130 -0
- package/dist/esm/generate.test.js.map +6 -0
- package/dist/esm/generate.test.mjs +114 -0
- package/dist/esm/generate.test.mjs.map +1 -0
- package/dist/esm/generate.test.native.js +114 -0
- package/dist/esm/generate.test.native.js.map +1 -0
- package/dist/esm/vite-plugin.js +42 -102
- package/dist/esm/vite-plugin.js.map +1 -1
- package/dist/esm/vite-plugin.mjs +37 -121
- package/dist/esm/vite-plugin.mjs.map +1 -1
- package/dist/esm/vite-plugin.native.js +47 -157
- package/dist/esm/vite-plugin.native.js.map +1 -1
- package/package.json +6 -3
- package/readme.md +0 -29
- package/src/cli.ts +9 -646
- package/src/generate.test.ts +201 -0
- package/src/generate.ts +491 -0
- package/src/vite-plugin.ts +61 -189
- package/types/generate.d.ts +21 -0
- package/types/generate.d.ts.map +1 -0
- package/types/generate.test.d.ts +2 -0
- package/types/generate.test.d.ts.map +1 -0
- package/types/vite-plugin.d.ts +6 -29
- package/types/vite-plugin.d.ts.map +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { generate } from './generate'
|
|
8
|
+
|
|
9
|
+
const testDir = join(tmpdir(), 'on-zero-test-' + Date.now())
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
mkdirSync(join(testDir, 'models'), { recursive: true })
|
|
13
|
+
mkdirSync(join(testDir, 'queries'), { recursive: true })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(testDir, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('generate', () => {
|
|
21
|
+
test('generates models.ts, types.ts, tables.ts from model files', async () => {
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(testDir, 'models/post.ts'),
|
|
24
|
+
`
|
|
25
|
+
import { table, string, boolean } from 'on-zero'
|
|
26
|
+
|
|
27
|
+
export const schema = table('post', {
|
|
28
|
+
id: string(),
|
|
29
|
+
title: string(),
|
|
30
|
+
published: boolean(),
|
|
31
|
+
})
|
|
32
|
+
`
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(testDir, 'models/comment.ts'),
|
|
37
|
+
`
|
|
38
|
+
import { table, string } from 'on-zero'
|
|
39
|
+
|
|
40
|
+
export const schema = table('comment', {
|
|
41
|
+
id: string(),
|
|
42
|
+
postId: string(),
|
|
43
|
+
body: string(),
|
|
44
|
+
})
|
|
45
|
+
`
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const result = await generate({ dir: testDir, silent: true })
|
|
49
|
+
|
|
50
|
+
expect(result.modelCount).toBe(2)
|
|
51
|
+
expect(result.schemaCount).toBe(2)
|
|
52
|
+
expect(result.filesChanged).toBeGreaterThan(0)
|
|
53
|
+
|
|
54
|
+
// check generated files exist
|
|
55
|
+
expect(existsSync(join(testDir, 'generated/models.ts'))).toBe(true)
|
|
56
|
+
expect(existsSync(join(testDir, 'generated/types.ts'))).toBe(true)
|
|
57
|
+
expect(existsSync(join(testDir, 'generated/tables.ts'))).toBe(true)
|
|
58
|
+
|
|
59
|
+
// check models.ts content
|
|
60
|
+
const modelsContent = readFileSync(join(testDir, 'generated/models.ts'), 'utf-8')
|
|
61
|
+
expect(modelsContent).toContain("import * as comment from '../models/comment'")
|
|
62
|
+
expect(modelsContent).toContain("import * as post from '../models/post'")
|
|
63
|
+
expect(modelsContent).toContain('export const models = {')
|
|
64
|
+
|
|
65
|
+
// check types.ts content
|
|
66
|
+
const typesContent = readFileSync(join(testDir, 'generated/types.ts'), 'utf-8')
|
|
67
|
+
expect(typesContent).toContain(
|
|
68
|
+
'export type Post = TableInsertRow<typeof schema.post>'
|
|
69
|
+
)
|
|
70
|
+
expect(typesContent).toContain(
|
|
71
|
+
'export type Comment = TableInsertRow<typeof schema.comment>'
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// check tables.ts content
|
|
75
|
+
const tablesContent = readFileSync(join(testDir, 'generated/tables.ts'), 'utf-8')
|
|
76
|
+
expect(tablesContent).toContain("export { schema as post } from '../models/post'")
|
|
77
|
+
expect(tablesContent).toContain(
|
|
78
|
+
"export { schema as comment } from '../models/comment'"
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('generates query validators from query files', async () => {
|
|
83
|
+
// need at least one model
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(testDir, 'models/post.ts'),
|
|
86
|
+
`export const schema = table('post', { id: string() })`
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
writeFileSync(
|
|
90
|
+
join(testDir, 'queries/post.ts'),
|
|
91
|
+
`
|
|
92
|
+
import { zero } from '../zero'
|
|
93
|
+
|
|
94
|
+
export const allPosts = () => zero.query.post
|
|
95
|
+
|
|
96
|
+
export const postById = ({ id }: { id: string }) => zero.query.post.where('id', id)
|
|
97
|
+
|
|
98
|
+
export const postsByAuthor = ({ authorId, limit }: { authorId: string; limit?: number }) =>
|
|
99
|
+
zero.query.post.where('authorId', authorId).limit(limit ?? 10)
|
|
100
|
+
`
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const result = await generate({ dir: testDir, silent: true })
|
|
104
|
+
|
|
105
|
+
expect(result.queryCount).toBe(3)
|
|
106
|
+
|
|
107
|
+
// check query files exist
|
|
108
|
+
expect(existsSync(join(testDir, 'generated/groupedQueries.ts'))).toBe(true)
|
|
109
|
+
expect(existsSync(join(testDir, 'generated/syncedQueries.ts'))).toBe(true)
|
|
110
|
+
|
|
111
|
+
// check groupedQueries.ts
|
|
112
|
+
const groupedContent = readFileSync(
|
|
113
|
+
join(testDir, 'generated/groupedQueries.ts'),
|
|
114
|
+
'utf-8'
|
|
115
|
+
)
|
|
116
|
+
expect(groupedContent).toContain("export * as post from '../queries/post'")
|
|
117
|
+
|
|
118
|
+
// check syncedQueries.ts has validators
|
|
119
|
+
const syncedContent = readFileSync(
|
|
120
|
+
join(testDir, 'generated/syncedQueries.ts'),
|
|
121
|
+
'utf-8'
|
|
122
|
+
)
|
|
123
|
+
expect(syncedContent).toContain('allPosts: defineQuery')
|
|
124
|
+
expect(syncedContent).toContain('postById: defineQuery')
|
|
125
|
+
expect(syncedContent).toContain('postsByAuthor: defineQuery')
|
|
126
|
+
expect(syncedContent).toContain('v.object')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('skips permission exports in queries', async () => {
|
|
130
|
+
writeFileSync(
|
|
131
|
+
join(testDir, 'models/post.ts'),
|
|
132
|
+
`export const schema = table('post', { id: string() })`
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
writeFileSync(
|
|
136
|
+
join(testDir, 'queries/post.ts'),
|
|
137
|
+
`
|
|
138
|
+
export const permission = () => ({ canRead: true })
|
|
139
|
+
export const allPosts = () => zero.query.post
|
|
140
|
+
`
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const result = await generate({ dir: testDir, silent: true })
|
|
144
|
+
|
|
145
|
+
expect(result.queryCount).toBe(1)
|
|
146
|
+
|
|
147
|
+
const syncedContent = readFileSync(
|
|
148
|
+
join(testDir, 'generated/syncedQueries.ts'),
|
|
149
|
+
'utf-8'
|
|
150
|
+
)
|
|
151
|
+
expect(syncedContent).toContain('allPosts')
|
|
152
|
+
expect(syncedContent).not.toContain('permission:')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('handles user model special case (userPublic)', async () => {
|
|
156
|
+
writeFileSync(
|
|
157
|
+
join(testDir, 'models/user.ts'),
|
|
158
|
+
`export const schema = table('user', { id: string(), name: string() })`
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
await generate({ dir: testDir, silent: true })
|
|
162
|
+
|
|
163
|
+
const modelsContent = readFileSync(join(testDir, 'generated/models.ts'), 'utf-8')
|
|
164
|
+
expect(modelsContent).toContain("import * as userPublic from '../models/user'")
|
|
165
|
+
expect(modelsContent).toContain('userPublic,')
|
|
166
|
+
|
|
167
|
+
const typesContent = readFileSync(join(testDir, 'generated/types.ts'), 'utf-8')
|
|
168
|
+
expect(typesContent).toContain('typeof schema.userPublic')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('runs after command when files change', async () => {
|
|
172
|
+
writeFileSync(
|
|
173
|
+
join(testDir, 'models/post.ts'),
|
|
174
|
+
`export const schema = table('post', { id: string() })`
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// use a command that creates a marker file
|
|
178
|
+
const markerFile = join(testDir, 'after-ran')
|
|
179
|
+
const result = await generate({
|
|
180
|
+
dir: testDir,
|
|
181
|
+
silent: true,
|
|
182
|
+
after: `touch ${markerFile}`,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
expect(result.filesChanged).toBeGreaterThan(0)
|
|
186
|
+
expect(existsSync(markerFile)).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('does not regenerate when nothing changed', async () => {
|
|
190
|
+
writeFileSync(
|
|
191
|
+
join(testDir, 'models/post.ts'),
|
|
192
|
+
`export const schema = table('post', { id: string() })`
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const first = await generate({ dir: testDir, silent: true })
|
|
196
|
+
expect(first.filesChanged).toBeGreaterThan(0)
|
|
197
|
+
|
|
198
|
+
const second = await generate({ dir: testDir, silent: true })
|
|
199
|
+
expect(second.filesChanged).toBe(0)
|
|
200
|
+
})
|
|
201
|
+
})
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const hash = (s: string) => createHash('sha256').update(s).digest('hex')
|
|
6
|
+
|
|
7
|
+
let generateCache: Record<string, string> = {}
|
|
8
|
+
let generateCachePath = ''
|
|
9
|
+
|
|
10
|
+
function getCacheDir() {
|
|
11
|
+
let dir = process.cwd()
|
|
12
|
+
while (dir !== '/') {
|
|
13
|
+
const nm = resolve(dir, 'node_modules')
|
|
14
|
+
if (existsSync(nm)) {
|
|
15
|
+
const cacheDir = resolve(nm, '.on-zero')
|
|
16
|
+
if (!existsSync(cacheDir)) {
|
|
17
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
18
|
+
}
|
|
19
|
+
return cacheDir
|
|
20
|
+
}
|
|
21
|
+
dir = resolve(dir, '..')
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadCache() {
|
|
27
|
+
const cacheDir = getCacheDir()
|
|
28
|
+
if (!cacheDir) return
|
|
29
|
+
generateCachePath = resolve(cacheDir, 'generate-cache.json')
|
|
30
|
+
try {
|
|
31
|
+
generateCache = JSON.parse(readFileSync(generateCachePath, 'utf-8'))
|
|
32
|
+
} catch {
|
|
33
|
+
generateCache = {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveCache() {
|
|
38
|
+
if (generateCachePath) {
|
|
39
|
+
writeFileSync(generateCachePath, JSON.stringify(generateCache) + '\n', 'utf-8')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeFileIfChanged(filePath: string, content: string): boolean {
|
|
44
|
+
const contentHash = hash(content)
|
|
45
|
+
const cachedHash = generateCache[filePath]
|
|
46
|
+
|
|
47
|
+
if (cachedHash === contentHash && existsSync(filePath)) {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
writeFileSync(filePath, content, 'utf-8')
|
|
52
|
+
generateCache[filePath] = contentHash
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function generateModelsFile(modelFiles: string[]) {
|
|
57
|
+
const modelNames = modelFiles.map((f) => basename(f, '.ts')).sort()
|
|
58
|
+
const getImportName = (name: string) => (name === 'user' ? 'userPublic' : name)
|
|
59
|
+
|
|
60
|
+
const imports = modelNames
|
|
61
|
+
.map((name) => `import * as ${getImportName(name)} from '../models/${name}'`)
|
|
62
|
+
.join('\n')
|
|
63
|
+
|
|
64
|
+
const sortedByImportName = [...modelNames].sort((a, b) =>
|
|
65
|
+
getImportName(a).localeCompare(getImportName(b))
|
|
66
|
+
)
|
|
67
|
+
const modelsObj = `export const models = {\n${sortedByImportName.map((name) => ` ${getImportName(name)},`).join('\n')}\n}`
|
|
68
|
+
|
|
69
|
+
const hmrBoundary = `
|
|
70
|
+
if (import.meta.hot) {
|
|
71
|
+
import.meta.hot.accept()
|
|
72
|
+
}
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
return `// auto-generated by: on-zero generate\n${imports}\n\n${modelsObj}\n${hmrBoundary}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function generateTypesFile(modelFiles: string[]) {
|
|
79
|
+
const modelNames = modelFiles.map((f) => basename(f, '.ts')).sort()
|
|
80
|
+
const getSchemaName = (name: string) => (name === 'user' ? 'userPublic' : name)
|
|
81
|
+
|
|
82
|
+
const typeExports = modelNames
|
|
83
|
+
.map((name) => {
|
|
84
|
+
const pascalName = name.charAt(0).toUpperCase() + name.slice(1)
|
|
85
|
+
const schemaName = getSchemaName(name)
|
|
86
|
+
return `export type ${pascalName} = TableInsertRow<typeof schema.${schemaName}>\nexport type ${pascalName}Update = TableUpdateRow<typeof schema.${schemaName}>`
|
|
87
|
+
})
|
|
88
|
+
.join('\n\n')
|
|
89
|
+
|
|
90
|
+
return `import type { TableInsertRow, TableUpdateRow } from 'on-zero'\nimport type * as schema from './tables'\n\n${typeExports}\n`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function generateTablesFile(modelFiles: string[]) {
|
|
94
|
+
const modelNames = modelFiles.map((f) => basename(f, '.ts')).sort()
|
|
95
|
+
const getExportName = (name: string) => (name === 'user' ? 'userPublic' : name)
|
|
96
|
+
|
|
97
|
+
const exports = modelNames
|
|
98
|
+
.map((name) => `export { schema as ${getExportName(name)} } from '../models/${name}'`)
|
|
99
|
+
.join('\n')
|
|
100
|
+
|
|
101
|
+
return `// auto-generated by: on-zero generate\n// this is separate from models as otherwise you end up with circular types :/\n\n${exports}\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function generateReadmeFile() {
|
|
105
|
+
return `# generated
|
|
106
|
+
|
|
107
|
+
this folder is auto-generated by on-zero. do not edit files here directly.
|
|
108
|
+
|
|
109
|
+
## what's generated
|
|
110
|
+
|
|
111
|
+
- \`models.ts\` - exports all models from ../models
|
|
112
|
+
- \`types.ts\` - typescript types derived from table schemas
|
|
113
|
+
- \`tables.ts\` - exports table schemas for type inference
|
|
114
|
+
- \`groupedQueries.ts\` - namespaced query re-exports for client setup
|
|
115
|
+
- \`syncedQueries.ts\` - namespaced syncedQuery wrappers for server setup
|
|
116
|
+
|
|
117
|
+
## usage guidelines
|
|
118
|
+
|
|
119
|
+
**do not import generated files outside of the data folder.**
|
|
120
|
+
|
|
121
|
+
### queries
|
|
122
|
+
|
|
123
|
+
write your queries as plain functions in \`../queries/\` and import them directly:
|
|
124
|
+
|
|
125
|
+
\`\`\`ts
|
|
126
|
+
// ✅ good - import from queries
|
|
127
|
+
import { channelMessages } from '~/data/queries/message'
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
the generated query files are only used internally by zero client/server setup.
|
|
131
|
+
|
|
132
|
+
### types
|
|
133
|
+
|
|
134
|
+
you can import types from this folder, but prefer re-exporting from \`../types.ts\`:
|
|
135
|
+
|
|
136
|
+
\`\`\`ts
|
|
137
|
+
// ❌ okay but not preferred
|
|
138
|
+
import type { Message } from '~/data/generated/types'
|
|
139
|
+
|
|
140
|
+
// ✅ better - re-export from types.ts
|
|
141
|
+
import type { Message } from '~/data/types'
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
## regeneration
|
|
145
|
+
|
|
146
|
+
files are regenerated when you run:
|
|
147
|
+
|
|
148
|
+
\`\`\`bash
|
|
149
|
+
bun on-zero generate
|
|
150
|
+
\`\`\`
|
|
151
|
+
|
|
152
|
+
or in watch mode:
|
|
153
|
+
|
|
154
|
+
\`\`\`bash
|
|
155
|
+
bun on-zero generate --watch
|
|
156
|
+
\`\`\`
|
|
157
|
+
|
|
158
|
+
## more info
|
|
159
|
+
|
|
160
|
+
see the [on-zero readme](./node_modules/on-zero/README.md) for full documentation.
|
|
161
|
+
`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function generateGroupedQueriesFile(
|
|
165
|
+
queries: Array<{ name: string; sourceFile: string }>
|
|
166
|
+
) {
|
|
167
|
+
const sortedFiles = [...new Set(queries.map((q) => q.sourceFile))].sort()
|
|
168
|
+
|
|
169
|
+
const exports = sortedFiles
|
|
170
|
+
.map((file) => `export * as ${file} from '../queries/${file}'`)
|
|
171
|
+
.join('\n')
|
|
172
|
+
|
|
173
|
+
return `/**
|
|
174
|
+
* auto-generated by: on-zero generate
|
|
175
|
+
*
|
|
176
|
+
* grouped query re-exports for minification-safe query identity.
|
|
177
|
+
* this file re-exports all query modules - while this breaks tree-shaking,
|
|
178
|
+
* queries are typically small and few in number even in larger apps.
|
|
179
|
+
*/
|
|
180
|
+
${exports}
|
|
181
|
+
`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function generateSyncedQueriesFile(
|
|
185
|
+
queries: Array<{
|
|
186
|
+
name: string
|
|
187
|
+
params: string
|
|
188
|
+
valibotCode: string
|
|
189
|
+
sourceFile: string
|
|
190
|
+
}>
|
|
191
|
+
) {
|
|
192
|
+
const queryByFile = new Map<string, typeof queries>()
|
|
193
|
+
for (const q of queries) {
|
|
194
|
+
if (!queryByFile.has(q.sourceFile)) {
|
|
195
|
+
queryByFile.set(q.sourceFile, [])
|
|
196
|
+
}
|
|
197
|
+
queryByFile.get(q.sourceFile)!.push(q)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const sortedFiles = Array.from(queryByFile.keys()).sort()
|
|
201
|
+
|
|
202
|
+
const imports = `// auto-generated by: on-zero generate
|
|
203
|
+
// server-side query definitions with validators
|
|
204
|
+
import { defineQuery, defineQueries } from '@rocicorp/zero'
|
|
205
|
+
import * as v from 'valibot'
|
|
206
|
+
import * as Queries from './groupedQueries'
|
|
207
|
+
`
|
|
208
|
+
|
|
209
|
+
const namespaceDefs = sortedFiles
|
|
210
|
+
.map((file) => {
|
|
211
|
+
const fileQueries = queryByFile
|
|
212
|
+
.get(file)!
|
|
213
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
214
|
+
|
|
215
|
+
const queryDefs = fileQueries
|
|
216
|
+
.map((q) => {
|
|
217
|
+
const lines = q.valibotCode.split('\n').filter((l) => l.trim())
|
|
218
|
+
const schemaLineIndex = lines.findIndex((l) =>
|
|
219
|
+
l.startsWith('export const QueryParams')
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
let validatorDef = ''
|
|
223
|
+
if (schemaLineIndex !== -1) {
|
|
224
|
+
const schemaLines: string[] = []
|
|
225
|
+
let openBraces = 0
|
|
226
|
+
let started = false
|
|
227
|
+
|
|
228
|
+
for (let i = schemaLineIndex; i < lines.length; i++) {
|
|
229
|
+
const line = lines[i]!
|
|
230
|
+
const cleaned = started
|
|
231
|
+
? line
|
|
232
|
+
: line.replace('export const QueryParams = ', '')
|
|
233
|
+
schemaLines.push(cleaned)
|
|
234
|
+
started = true
|
|
235
|
+
|
|
236
|
+
openBraces += (cleaned.match(/\{/g) || []).length
|
|
237
|
+
openBraces -= (cleaned.match(/\}/g) || []).length
|
|
238
|
+
openBraces += (cleaned.match(/\(/g) || []).length
|
|
239
|
+
openBraces -= (cleaned.match(/\)/g) || []).length
|
|
240
|
+
|
|
241
|
+
if (openBraces === 0 && schemaLines.length > 0) {
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
validatorDef = schemaLines.join('\n')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (q.params === 'void' || !validatorDef) {
|
|
249
|
+
return ` ${q.name}: defineQuery(() => Queries.${file}.${q.name}()),`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const indentedValidator = validatorDef
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
|
255
|
+
.join('\n')
|
|
256
|
+
|
|
257
|
+
return ` ${q.name}: defineQuery(
|
|
258
|
+
${indentedValidator},
|
|
259
|
+
({ args }) => Queries.${file}.${q.name}(args)
|
|
260
|
+
),`
|
|
261
|
+
})
|
|
262
|
+
.join('\n')
|
|
263
|
+
|
|
264
|
+
return `const ${file} = {\n${queryDefs}\n}`
|
|
265
|
+
})
|
|
266
|
+
.join('\n\n')
|
|
267
|
+
|
|
268
|
+
const queriesObject = sortedFiles.map((file) => ` ${file},`).join('\n')
|
|
269
|
+
|
|
270
|
+
return `${imports}
|
|
271
|
+
${namespaceDefs}
|
|
272
|
+
|
|
273
|
+
export const queries = defineQueries({
|
|
274
|
+
${queriesObject}
|
|
275
|
+
})
|
|
276
|
+
`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface GenerateOptions {
|
|
280
|
+
/** base data directory */
|
|
281
|
+
dir: string
|
|
282
|
+
/** run after generation */
|
|
283
|
+
after?: string
|
|
284
|
+
/** suppress output */
|
|
285
|
+
silent?: boolean
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface WatchOptions extends GenerateOptions {
|
|
289
|
+
/** debounce delay in ms */
|
|
290
|
+
debounce?: number
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface GenerateResult {
|
|
294
|
+
filesChanged: number
|
|
295
|
+
modelCount: number
|
|
296
|
+
schemaCount: number
|
|
297
|
+
queryCount: number
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function generate(options: GenerateOptions): Promise<GenerateResult> {
|
|
301
|
+
const { dir, after, silent } = options
|
|
302
|
+
const baseDir = resolve(dir)
|
|
303
|
+
const modelsDir = resolve(baseDir, 'models')
|
|
304
|
+
const generatedDir = resolve(baseDir, 'generated')
|
|
305
|
+
const queriesDir = resolve(baseDir, 'queries')
|
|
306
|
+
|
|
307
|
+
if (!existsSync(generatedDir)) {
|
|
308
|
+
mkdirSync(generatedDir, { recursive: true })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
loadCache()
|
|
312
|
+
|
|
313
|
+
const allModelFiles = readdirSync(modelsDir)
|
|
314
|
+
.filter((f) => f.endsWith('.ts'))
|
|
315
|
+
.sort()
|
|
316
|
+
|
|
317
|
+
const filesWithSchema = allModelFiles.filter((f) =>
|
|
318
|
+
readFileSync(resolve(modelsDir, f), 'utf-8').includes('export const schema = table(')
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const writeResults = [
|
|
322
|
+
writeFileIfChanged(
|
|
323
|
+
resolve(generatedDir, 'models.ts'),
|
|
324
|
+
generateModelsFile(allModelFiles)
|
|
325
|
+
),
|
|
326
|
+
writeFileIfChanged(
|
|
327
|
+
resolve(generatedDir, 'types.ts'),
|
|
328
|
+
generateTypesFile(filesWithSchema)
|
|
329
|
+
),
|
|
330
|
+
writeFileIfChanged(
|
|
331
|
+
resolve(generatedDir, 'tables.ts'),
|
|
332
|
+
generateTablesFile(filesWithSchema)
|
|
333
|
+
),
|
|
334
|
+
writeFileIfChanged(resolve(generatedDir, 'README.md'), generateReadmeFile()),
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
let filesChanged = writeResults.filter(Boolean).length
|
|
338
|
+
let queryCount = 0
|
|
339
|
+
|
|
340
|
+
// generate query files if queries directory exists
|
|
341
|
+
if (existsSync(queriesDir)) {
|
|
342
|
+
const ts = await import('typescript')
|
|
343
|
+
const { ModelToValibot } = await import('@sinclair/typebox-codegen/model/index.js')
|
|
344
|
+
const { TypeScriptToModel } =
|
|
345
|
+
await import('@sinclair/typebox-codegen/typescript/index.js')
|
|
346
|
+
|
|
347
|
+
const queryFiles = readdirSync(queriesDir).filter((f) => f.endsWith('.ts'))
|
|
348
|
+
|
|
349
|
+
const allQueries: Array<{
|
|
350
|
+
name: string
|
|
351
|
+
params: string
|
|
352
|
+
valibotCode: string
|
|
353
|
+
sourceFile: string
|
|
354
|
+
}> = []
|
|
355
|
+
|
|
356
|
+
for (const file of queryFiles) {
|
|
357
|
+
const filePath = resolve(queriesDir, file)
|
|
358
|
+
const fileBaseName = basename(file, '.ts')
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
362
|
+
const sourceFile = ts.createSourceFile(
|
|
363
|
+
filePath,
|
|
364
|
+
content,
|
|
365
|
+
ts.ScriptTarget.Latest,
|
|
366
|
+
true
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
370
|
+
if (ts.isVariableStatement(node)) {
|
|
371
|
+
const exportModifier = node.modifiers?.find(
|
|
372
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword
|
|
373
|
+
)
|
|
374
|
+
if (!exportModifier) return
|
|
375
|
+
|
|
376
|
+
const declaration = node.declarationList.declarations[0]
|
|
377
|
+
if (!declaration || !ts.isVariableDeclaration(declaration)) return
|
|
378
|
+
|
|
379
|
+
const name = declaration.name.getText(sourceFile)
|
|
380
|
+
if (name === 'permission') return
|
|
381
|
+
|
|
382
|
+
if (declaration.initializer && ts.isArrowFunction(declaration.initializer)) {
|
|
383
|
+
const params = declaration.initializer.parameters
|
|
384
|
+
let paramType = 'void'
|
|
385
|
+
|
|
386
|
+
if (params.length > 0) {
|
|
387
|
+
const param = params[0]!
|
|
388
|
+
paramType = param.type?.getText(sourceFile) || 'unknown'
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const typeString = `type QueryParams = ${paramType}`
|
|
393
|
+
const model = TypeScriptToModel.Generate(typeString)
|
|
394
|
+
const valibotCode = ModelToValibot.Generate(model)
|
|
395
|
+
|
|
396
|
+
allQueries.push({
|
|
397
|
+
name,
|
|
398
|
+
params: paramType,
|
|
399
|
+
valibotCode,
|
|
400
|
+
sourceFile: fileBaseName,
|
|
401
|
+
})
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (!silent) console.error(`✗ ${name}: ${err}`)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (!silent) console.error(`Error processing ${file}:`, err)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
queryCount = allQueries.length
|
|
414
|
+
|
|
415
|
+
const groupedChanged = writeFileIfChanged(
|
|
416
|
+
resolve(generatedDir, 'groupedQueries.ts'),
|
|
417
|
+
generateGroupedQueriesFile(allQueries)
|
|
418
|
+
)
|
|
419
|
+
const syncedChanged = writeFileIfChanged(
|
|
420
|
+
resolve(generatedDir, 'syncedQueries.ts'),
|
|
421
|
+
generateSyncedQueriesFile(allQueries)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if (groupedChanged) filesChanged++
|
|
425
|
+
if (syncedChanged) filesChanged++
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (filesChanged > 0 && !silent) {
|
|
429
|
+
console.info(
|
|
430
|
+
`✓ ${allModelFiles.length} models (${filesWithSchema.length} schemas)${queryCount ? `, ${queryCount} queries` : ''}`
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// run after command
|
|
435
|
+
if (filesChanged > 0 && after) {
|
|
436
|
+
const { execSync } = await import('node:child_process')
|
|
437
|
+
try {
|
|
438
|
+
execSync(after, {
|
|
439
|
+
stdio: 'inherit',
|
|
440
|
+
env: { ...process.env, ON_ZERO_GENERATED_DIR: generatedDir },
|
|
441
|
+
})
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (!silent) console.error(`Error running after command: ${err}`)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
saveCache()
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
filesChanged,
|
|
451
|
+
modelCount: allModelFiles.length,
|
|
452
|
+
schemaCount: filesWithSchema.length,
|
|
453
|
+
queryCount,
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function watch(options: WatchOptions) {
|
|
458
|
+
const { dir, debounce = 1000 } = options
|
|
459
|
+
const baseDir = resolve(dir)
|
|
460
|
+
const modelsDir = resolve(baseDir, 'models')
|
|
461
|
+
const queriesDir = resolve(baseDir, 'queries')
|
|
462
|
+
const generatedDir = resolve(baseDir, 'generated')
|
|
463
|
+
|
|
464
|
+
// initial run (silent)
|
|
465
|
+
await generate({ ...options, silent: true })
|
|
466
|
+
console.info('👀 watching...\n')
|
|
467
|
+
|
|
468
|
+
const chokidar = await import('chokidar')
|
|
469
|
+
|
|
470
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
471
|
+
|
|
472
|
+
const debouncedRegenerate = (path: string, event: string) => {
|
|
473
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
474
|
+
console.info(`\n${event} ${path}`)
|
|
475
|
+
debounceTimer = setTimeout(() => {
|
|
476
|
+
generate({ ...options, silent: false })
|
|
477
|
+
}, debounce)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const watcher = chokidar.watch([modelsDir, queriesDir], {
|
|
481
|
+
persistent: true,
|
|
482
|
+
ignoreInitial: true,
|
|
483
|
+
ignored: [generatedDir],
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
watcher.on('change', (path) => debouncedRegenerate(path, '📝'))
|
|
487
|
+
watcher.on('add', (path) => debouncedRegenerate(path, '➕'))
|
|
488
|
+
watcher.on('unlink', (path) => debouncedRegenerate(path, '🗑️ '))
|
|
489
|
+
|
|
490
|
+
return watcher
|
|
491
|
+
}
|