@coreframe/scripts 0.0.0 → 0.1.0
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/bin/coreframe.js +35 -0
- package/config.test.ts +15 -0
- package/config.ts +9 -0
- package/coreframe.ts +272 -0
- package/db/client.ts +72 -0
- package/db/config.ts +41 -0
- package/db/drizzle-kit.test.ts +27 -0
- package/db/drizzle-kit.ts +39 -0
- package/db/ensure.ts +67 -0
- package/db/migrate-held.test.ts +81 -0
- package/db/migrate-held.ts +166 -0
- package/db/migrate.ts +39 -0
- package/db/project.ts +28 -0
- package/db/reset.ts +24 -0
- package/db/seed.ts +38 -0
- package/db/setup.ts +18 -0
- package/deploy/check-migrations.ts +31 -0
- package/deploy/checks.ts +38 -0
- package/deploy/config.test.ts +24 -0
- package/deploy/config.ts +68 -0
- package/deploy/migrations.test.ts +110 -0
- package/deploy/migrations.ts +317 -0
- package/deploy/pending-migrations.test.ts +93 -0
- package/deploy/pending-migrations.ts +45 -0
- package/deploy/tauri-release.test.ts +196 -0
- package/deploy/tauri-release.ts +816 -0
- package/deploy/version.test.ts +29 -0
- package/deploy/version.ts +65 -0
- package/deploy.test.ts +101 -0
- package/deploy.ts +346 -0
- package/dev.test.ts +153 -0
- package/dev.ts +155 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +3 -0
- package/dist/coreframe.d.ts +280 -0
- package/dist/coreframe.js +240 -0
- package/dist/db/client.d.ts +22 -0
- package/dist/db/client.js +29 -0
- package/dist/db/config.d.ts +21 -0
- package/dist/db/config.js +32 -0
- package/dist/db/drizzle-kit.d.ts +8 -0
- package/dist/db/drizzle-kit.js +22 -0
- package/dist/db/ensure.d.ts +2 -0
- package/dist/db/ensure.js +40 -0
- package/dist/db/migrate-held.d.ts +12 -0
- package/dist/db/migrate-held.js +119 -0
- package/dist/db/migrate.d.ts +2 -0
- package/dist/db/migrate.js +25 -0
- package/dist/db/project.d.ts +8 -0
- package/dist/db/project.js +9 -0
- package/dist/db/reset.d.ts +2 -0
- package/dist/db/reset.js +19 -0
- package/dist/db/seed.d.ts +3 -0
- package/dist/db/seed.js +23 -0
- package/dist/db/setup.d.ts +3 -0
- package/dist/db/setup.js +15 -0
- package/dist/deploy/check-migrations.d.ts +2 -0
- package/dist/deploy/check-migrations.js +18 -0
- package/dist/deploy/checks.d.ts +2 -0
- package/dist/deploy/checks.js +27 -0
- package/dist/deploy/config.d.ts +50 -0
- package/dist/deploy/config.js +9 -0
- package/dist/deploy/migrations.d.ts +21 -0
- package/dist/deploy/migrations.js +203 -0
- package/dist/deploy/pending-migrations.d.ts +5 -0
- package/dist/deploy/pending-migrations.js +30 -0
- package/dist/deploy/tauri-release.d.ts +85 -0
- package/dist/deploy/tauri-release.js +512 -0
- package/dist/deploy/version.d.ts +18 -0
- package/dist/deploy/version.js +41 -0
- package/dist/deploy.d.ts +17 -0
- package/dist/deploy.js +247 -0
- package/dist/dev.d.ts +13 -0
- package/dist/dev.js +96 -0
- package/dist/image-generator.d.ts +2 -0
- package/dist/image-generator.js +59 -0
- package/dist/project.d.ts +1 -0
- package/dist/project.js +13 -0
- package/dist/typecheck.d.ts +2 -0
- package/dist/typecheck.js +45 -0
- package/dist/upgrade/check-skill-current.d.ts +6 -0
- package/dist/upgrade/check-skill-current.js +88 -0
- package/dist/upgrade/ensure-template-remote.d.ts +6 -0
- package/dist/upgrade/ensure-template-remote.js +57 -0
- package/dist/upgrade/fast-forward-template-files.d.ts +7 -0
- package/dist/upgrade/fast-forward-template-files.js +280 -0
- package/image-generator.ts +84 -0
- package/package.json +48 -8
- package/project.ts +6 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +20 -0
- package/typecheck.ts +55 -0
- package/upgrade/check-skill-current.ts +112 -0
- package/upgrade/ensure-template-remote.ts +79 -0
- package/upgrade/fast-forward-template-files.ts +408 -0
- package/readme.md +0 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { access, readFile, readdir } from "node:fs/promises"
|
|
2
|
+
import { basename, join, relative, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { HELD_MIGRATIONS_FOLDER, MIGRATIONS_FOLDER } from "../db/config.ts"
|
|
5
|
+
|
|
6
|
+
export type MigrationFile = {
|
|
7
|
+
id: string
|
|
8
|
+
path: string
|
|
9
|
+
relativePath: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MigrationRisk = {
|
|
13
|
+
code: string
|
|
14
|
+
line: number
|
|
15
|
+
match: string
|
|
16
|
+
reason: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MigrationScanResult = {
|
|
20
|
+
migration: MigrationFile
|
|
21
|
+
risks: MigrationRisk[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StaticPattern = {
|
|
25
|
+
code: string
|
|
26
|
+
pattern: RegExp
|
|
27
|
+
reason: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const staticBlockedPatterns: StaticPattern[] = [
|
|
31
|
+
{
|
|
32
|
+
code: "drop-schema-object",
|
|
33
|
+
pattern:
|
|
34
|
+
/\bdrop\s+(?:table|view|materialized\s+view|index|sequence|type|schema|function|trigger|policy)\b/giu,
|
|
35
|
+
reason: "drops schema that the currently deployed app may still depend on",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
code: "drop-column",
|
|
39
|
+
pattern: /\balter\s+table\b[^;]*?\bdrop\s+column\b/giu,
|
|
40
|
+
reason: "removes a column that the currently deployed app may still read or write",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
code: "drop-constraint",
|
|
44
|
+
pattern: /\balter\s+table\b[^;]*?\bdrop\s+constraint\b/giu,
|
|
45
|
+
reason: "removes a constraint outside the staged contract phase",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
code: "truncate",
|
|
49
|
+
pattern: /\btruncate\b/giu,
|
|
50
|
+
reason: "removes data outside the deploy-safe migration path",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
code: "rename-table-or-column",
|
|
54
|
+
pattern: /\balter\s+table\b[^;]*?\brename(?:\s+column)?\b/giu,
|
|
55
|
+
reason: "renames schema in a way old and new app versions cannot both address",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
code: "rewrite-column-type",
|
|
59
|
+
pattern: /\balter\s+table\b[^;]*?\balter\s+column\b[^;]*?\btype\b/giu,
|
|
60
|
+
reason: "rewrites a column type outside a staged expand/contract rollout",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: "set-not-null",
|
|
64
|
+
pattern: /\balter\s+table\b[^;]*?\balter\s+column\b[^;]*?\bset\s+not\s+null\b/giu,
|
|
65
|
+
reason: "enforces NOT NULL before a separate backfill and verification step",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
code: "set-default",
|
|
69
|
+
pattern: /\balter\s+table\b[^;]*?\balter\s+column\b[^;]*?\bset\s+default\b/giu,
|
|
70
|
+
reason: "changes default write behavior while old app code may still be running",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
code: "broad-update",
|
|
74
|
+
pattern: /\bupdate\b/giu,
|
|
75
|
+
reason: "changes data outside a separately reviewed backfill or held migration",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
code: "broad-delete",
|
|
79
|
+
pattern: /\bdelete\s+from\b/giu,
|
|
80
|
+
reason: "removes data outside a held migration",
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
export async function collectDeployMigrationFiles(cwd = process.cwd()): Promise<MigrationFile[]> {
|
|
85
|
+
return collectDrizzleMigrationFiles(resolve(cwd, MIGRATIONS_FOLDER), cwd)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function collectHeldMigrationFiles(cwd = process.cwd()): Promise<MigrationFile[]> {
|
|
89
|
+
return collectFlatSqlFiles(resolve(cwd, HELD_MIGRATIONS_FOLDER), cwd)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function scanDeployMigrationFiles(
|
|
93
|
+
migrations: MigrationFile[],
|
|
94
|
+
): Promise<MigrationScanResult[]> {
|
|
95
|
+
const results = await Promise.all(
|
|
96
|
+
migrations.map(async (migration) => {
|
|
97
|
+
const sql = await readFile(migration.path, "utf8")
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
migration,
|
|
101
|
+
risks: scanMigrationSql(sql),
|
|
102
|
+
}
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return results.filter((result) => result.risks.length > 0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function scanMigrationSql(sql: string): MigrationRisk[] {
|
|
110
|
+
const maskedSql = maskSql(sql)
|
|
111
|
+
const createdTables = collectCreatedTables(maskedSql)
|
|
112
|
+
const risks: MigrationRisk[] = []
|
|
113
|
+
|
|
114
|
+
for (const blocked of staticBlockedPatterns) {
|
|
115
|
+
for (const match of maskedSql.matchAll(blocked.pattern)) {
|
|
116
|
+
risks.push(riskForMatch(sql, match, blocked.code, blocked.reason))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const match of maskedSql.matchAll(
|
|
121
|
+
/\balter\s+table\s+(?:if\s+exists\s+|only\s+)?(?<table>(?:"[^"]+"|\w+)(?:\s*\.\s*(?:"[^"]+"|\w+))?)\s+add\s+column\b[^;]*?\bnot\s+null\b/giu,
|
|
122
|
+
)) {
|
|
123
|
+
if (!createdTables.has(normalizeIdentifier(match.groups?.table))) {
|
|
124
|
+
risks.push(
|
|
125
|
+
riskForMatch(
|
|
126
|
+
sql,
|
|
127
|
+
match,
|
|
128
|
+
"add-not-null-column",
|
|
129
|
+
"adds a required column to an existing table before a separate compatibility rollout",
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const match of maskedSql.matchAll(
|
|
136
|
+
/\balter\s+table\s+(?:if\s+exists\s+|only\s+)?(?<table>(?:"[^"]+"|\w+)(?:\s*\.\s*(?:"[^"]+"|\w+))?)\s+add\s+constraint\b[^;]*?\b(?:foreign\s+key|unique|primary\s+key|check)\b/giu,
|
|
137
|
+
)) {
|
|
138
|
+
if (!createdTables.has(normalizeIdentifier(match.groups?.table))) {
|
|
139
|
+
risks.push(
|
|
140
|
+
riskForMatch(
|
|
141
|
+
sql,
|
|
142
|
+
match,
|
|
143
|
+
"add-constraint",
|
|
144
|
+
"enforces a constraint on an existing table before a separate validation step",
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const match of maskedSql.matchAll(
|
|
151
|
+
/\bcreate\s+unique\s+index(?:\s+concurrently)?(?:\s+if\s+not\s+exists)?\s+(?:"[^"]+"|\w+)\s+on\s+(?<table>(?:"[^"]+"|\w+)(?:\s*\.\s*(?:"[^"]+"|\w+))?)/giu,
|
|
152
|
+
)) {
|
|
153
|
+
if (!createdTables.has(normalizeIdentifier(match.groups?.table))) {
|
|
154
|
+
risks.push(
|
|
155
|
+
riskForMatch(
|
|
156
|
+
sql,
|
|
157
|
+
match,
|
|
158
|
+
"create-unique-index",
|
|
159
|
+
"adds uniqueness to existing data before a separate dedupe and validation step",
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return risks.sort((left, right) => left.line - right.line || left.code.localeCompare(right.code))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatMigrationScanFailures(results: MigrationScanResult[]) {
|
|
169
|
+
return results
|
|
170
|
+
.flatMap((result) =>
|
|
171
|
+
result.risks.map(
|
|
172
|
+
(risk) =>
|
|
173
|
+
`${result.migration.relativePath}:${risk.line} ${risk.code}: ${risk.reason} (${risk.match.trim()})`,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
.join("\n")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatMigrationList(migrations: MigrationFile[]) {
|
|
180
|
+
if (migrations.length === 0) {
|
|
181
|
+
return "No migration files found."
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return migrations.map((migration) => `- ${migration.relativePath}`).join("\n")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function collectCreatedTables(sql: string) {
|
|
188
|
+
const tables = new Set<string>()
|
|
189
|
+
|
|
190
|
+
for (const match of sql.matchAll(
|
|
191
|
+
/\bcreate\s+(?:temporary\s+|temp\s+)?table\s+(?:if\s+not\s+exists\s+)?(?<table>(?:"[^"]+"|\w+)(?:\s*\.\s*(?:"[^"]+"|\w+))?)/giu,
|
|
192
|
+
)) {
|
|
193
|
+
tables.add(normalizeIdentifier(match.groups?.table))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return tables
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function collectDrizzleMigrationFiles(migrationsPath: string, cwd: string) {
|
|
200
|
+
const entries = await readDirectoryOrEmpty(migrationsPath)
|
|
201
|
+
const migrations: MigrationFile[] = []
|
|
202
|
+
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
const entryPath = join(migrationsPath, entry.name)
|
|
205
|
+
|
|
206
|
+
if (entry.isDirectory()) {
|
|
207
|
+
const migrationPath = join(entryPath, "migration.sql")
|
|
208
|
+
|
|
209
|
+
if (await fileExists(migrationPath)) {
|
|
210
|
+
migrations.push(migrationFileFromPath(migrationPath, cwd, entry.name))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (entry.isFile() && entry.name.endsWith(".sql")) {
|
|
217
|
+
migrations.push(migrationFileFromPath(entryPath, cwd, entry.name.replace(/\.sql$/u, "")))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return migrations.sort((left, right) => left.id.localeCompare(right.id))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function collectFlatSqlFiles(directory: string, cwd: string) {
|
|
225
|
+
const entries = await readDirectoryOrEmpty(directory)
|
|
226
|
+
const migrations = entries
|
|
227
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
|
228
|
+
.map((entry) =>
|
|
229
|
+
migrationFileFromPath(join(directory, entry.name), cwd, entry.name.replace(/\.sql$/u, "")),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return migrations.sort((left, right) => left.id.localeCompare(right.id))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function readDirectoryOrEmpty(directory: string) {
|
|
236
|
+
try {
|
|
237
|
+
return await readdir(directory, { withFileTypes: true })
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
240
|
+
return []
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw error
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function fileExists(path: string) {
|
|
248
|
+
try {
|
|
249
|
+
await access(path)
|
|
250
|
+
return true
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
throw error
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function migrationFileFromPath(
|
|
261
|
+
path: string,
|
|
262
|
+
cwd: string,
|
|
263
|
+
id = basename(path, ".sql"),
|
|
264
|
+
): MigrationFile {
|
|
265
|
+
return {
|
|
266
|
+
id,
|
|
267
|
+
path,
|
|
268
|
+
relativePath: relative(cwd, path),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function maskSql(sql: string) {
|
|
273
|
+
return sql
|
|
274
|
+
.replace(/--[^\n\r]*/gu, blank)
|
|
275
|
+
.replace(/\/\*[\s\S]*?\*\//gu, blank)
|
|
276
|
+
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\1\$/gu, blank)
|
|
277
|
+
.replace(/'(?:''|[^'])*'/gu, blank)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function blank(match: string) {
|
|
281
|
+
return match.replace(/[^\n\r]/gu, " ")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeIdentifier(identifier: string | undefined) {
|
|
285
|
+
return (
|
|
286
|
+
(identifier ?? "")
|
|
287
|
+
.split(".")
|
|
288
|
+
.map((part) => part.trim().replace(/^"|"$/gu, "").toLowerCase())
|
|
289
|
+
.at(-1) ?? ""
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function riskForMatch(
|
|
294
|
+
sql: string,
|
|
295
|
+
match: RegExpMatchArray,
|
|
296
|
+
code: string,
|
|
297
|
+
reason: string,
|
|
298
|
+
): MigrationRisk {
|
|
299
|
+
return {
|
|
300
|
+
code,
|
|
301
|
+
line: lineForIndex(sql, match.index ?? 0),
|
|
302
|
+
match: sql.slice(match.index ?? 0, (match.index ?? 0) + match[0].length),
|
|
303
|
+
reason,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function lineForIndex(value: string, index: number) {
|
|
308
|
+
let line = 1
|
|
309
|
+
|
|
310
|
+
for (let offset = 0; offset < index; offset += 1) {
|
|
311
|
+
if (value[offset] === "\n") {
|
|
312
|
+
line += 1
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return line
|
|
317
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import type { CoreframeDatabaseClient, CoreframeDatabaseDialect } from "../db/client.ts"
|
|
4
|
+
import type { MigrationFile } from "./migrations.ts"
|
|
5
|
+
import {
|
|
6
|
+
appliedMigrationsQuery,
|
|
7
|
+
filterPendingMigrations,
|
|
8
|
+
readAppliedMigrationNames,
|
|
9
|
+
} from "./pending-migrations.ts"
|
|
10
|
+
|
|
11
|
+
describe("filterPendingMigrations", () => {
|
|
12
|
+
it("keeps local migrations that are absent from the database journal", () => {
|
|
13
|
+
const migrations: MigrationFile[] = [
|
|
14
|
+
{
|
|
15
|
+
id: "20260703120000_first",
|
|
16
|
+
path: "/repo/drizzle/20260703120000_first/migration.sql",
|
|
17
|
+
relativePath: "drizzle/20260703120000_first/migration.sql",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "20260703130000_second",
|
|
21
|
+
path: "/repo/drizzle/20260703130000_second/migration.sql",
|
|
22
|
+
relativePath: "drizzle/20260703130000_second/migration.sql",
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
expect(filterPendingMigrations(migrations, ["20260703120000_first"])).toEqual([migrations[1]])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("readAppliedMigrationNames", () => {
|
|
31
|
+
it("reads the Postgres Drizzle journal", async () => {
|
|
32
|
+
const database = createTestDatabase("postgres", [{ name: "20260703120000_first" }])
|
|
33
|
+
|
|
34
|
+
await expect(readAppliedMigrationNames(database)).resolves.toEqual(
|
|
35
|
+
new Set(["20260703120000_first"]),
|
|
36
|
+
)
|
|
37
|
+
expect(database.statements).toEqual([
|
|
38
|
+
"select name from drizzle.__drizzle_migrations where name is not null",
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("reads the SQLite Drizzle journal", async () => {
|
|
43
|
+
const database = createTestDatabase("sqlite", [{ name: "20260703120000_first" }])
|
|
44
|
+
|
|
45
|
+
await expect(readAppliedMigrationNames(database)).resolves.toEqual(
|
|
46
|
+
new Set(["20260703120000_first"]),
|
|
47
|
+
)
|
|
48
|
+
expect(database.statements).toEqual([
|
|
49
|
+
"select name from __drizzle_migrations where name is not null",
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("treats a missing journal table as no applied migrations", async () => {
|
|
54
|
+
const database = createTestDatabase("sqlite", [], new Error("SQLITE_ERROR: no such table"))
|
|
55
|
+
|
|
56
|
+
await expect(readAppliedMigrationNames(database)).resolves.toEqual(new Set())
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("appliedMigrationsQuery", () => {
|
|
61
|
+
it("uses dialect-specific Drizzle journal tables", () => {
|
|
62
|
+
expect(appliedMigrationsQuery("postgres")).toBe(
|
|
63
|
+
"select name from drizzle.__drizzle_migrations where name is not null",
|
|
64
|
+
)
|
|
65
|
+
expect(appliedMigrationsQuery("sqlite")).toBe(
|
|
66
|
+
"select name from __drizzle_migrations where name is not null",
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
function createTestDatabase(
|
|
72
|
+
dialect: CoreframeDatabaseDialect,
|
|
73
|
+
rows: Array<{ name: string | null }>,
|
|
74
|
+
error?: Error,
|
|
75
|
+
): CoreframeDatabaseClient & { statements: string[] } {
|
|
76
|
+
const statements: string[] = []
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
dialect,
|
|
80
|
+
statements,
|
|
81
|
+
async execute<Row extends Record<string, unknown> = Record<string, unknown>>(
|
|
82
|
+
statement: string,
|
|
83
|
+
) {
|
|
84
|
+
statements.push(statement)
|
|
85
|
+
|
|
86
|
+
if (error) {
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { rows: rows as unknown as Row[] }
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CoreframeDatabaseClient, CoreframeDatabaseDialect } from "../db/client.ts"
|
|
2
|
+
import type { MigrationFile } from "./migrations.ts"
|
|
3
|
+
|
|
4
|
+
export function filterPendingMigrations(
|
|
5
|
+
migrations: MigrationFile[],
|
|
6
|
+
appliedNames: Iterable<string>,
|
|
7
|
+
) {
|
|
8
|
+
const applied = new Set(appliedNames)
|
|
9
|
+
|
|
10
|
+
return migrations.filter((migration) => !applied.has(migration.id))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readAppliedMigrationNames(database: CoreframeDatabaseClient) {
|
|
14
|
+
try {
|
|
15
|
+
const result = await database.execute<{ name: string | null }>(
|
|
16
|
+
appliedMigrationsQuery(database.dialect),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return new Set(result.rows.flatMap((row) => (row.name ? [row.name] : [])))
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (isMissingMigrationTableError(error)) {
|
|
22
|
+
return new Set<string>()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function appliedMigrationsQuery(dialect: CoreframeDatabaseDialect) {
|
|
30
|
+
switch (dialect) {
|
|
31
|
+
case "postgres":
|
|
32
|
+
return "select name from drizzle.__drizzle_migrations where name is not null"
|
|
33
|
+
case "sqlite":
|
|
34
|
+
return "select name from __drizzle_migrations where name is not null"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isMissingMigrationTableError(error: unknown) {
|
|
39
|
+
return (
|
|
40
|
+
error instanceof Error &&
|
|
41
|
+
(("code" in error && (error.code === "42P01" || error.code === "3F000")) ||
|
|
42
|
+
error.message.includes("no such table") ||
|
|
43
|
+
error.message.includes("no such schema"))
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createTauriReleaseUpload,
|
|
9
|
+
createTauriUpdateManifest,
|
|
10
|
+
resolveTauriReleasePlan,
|
|
11
|
+
restoreTauriReleaseMetadata,
|
|
12
|
+
writeTauriReleaseMetadata,
|
|
13
|
+
} from "./tauri-release.ts"
|
|
14
|
+
|
|
15
|
+
describe("resolveTauriReleasePlan", () => {
|
|
16
|
+
it("disables all Tauri platforms by default", () => {
|
|
17
|
+
const plan = resolveTauriReleasePlan({
|
|
18
|
+
args: {
|
|
19
|
+
tauriLocalPlatforms: [],
|
|
20
|
+
tauriWorkflowPlatforms: [],
|
|
21
|
+
},
|
|
22
|
+
env: {},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
expect(plan.enabled).toBe(false)
|
|
26
|
+
expect(plan.localPlatforms).toEqual([])
|
|
27
|
+
expect(plan.workflowPlatforms).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("combines CLI and env platform selections", () => {
|
|
31
|
+
const plan = resolveTauriReleasePlan({
|
|
32
|
+
args: {
|
|
33
|
+
tauriLocalPlatforms: ["macos"],
|
|
34
|
+
tauriWorkflowPlatforms: ["windows"],
|
|
35
|
+
},
|
|
36
|
+
config: {
|
|
37
|
+
localPlatforms: ["android"],
|
|
38
|
+
workflowPlatforms: ["linux"],
|
|
39
|
+
},
|
|
40
|
+
env: {
|
|
41
|
+
B2_APPLICATION_KEY: "key",
|
|
42
|
+
B2_APPLICATION_KEY_ID: "key-id",
|
|
43
|
+
B2_BUCKET: "bucket",
|
|
44
|
+
TAURI_RELEASE_LOCAL_PLATFORMS: "ios",
|
|
45
|
+
TAURI_RELEASE_PUBLIC_BASE_URL: "https://downloads.example.com",
|
|
46
|
+
TAURI_SIGNING_PRIVATE_KEY: "private-key",
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(plan.enabled).toBe(true)
|
|
51
|
+
expect(plan.localPlatforms).toEqual(["android", "ios", "macos"])
|
|
52
|
+
expect(plan.workflowPlatforms).toEqual(["linux", "windows"])
|
|
53
|
+
expect(plan.publicBaseUrl).toBe("https://downloads.example.com")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("rejects selecting the same platform locally and through workflow", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
resolveTauriReleasePlan({
|
|
59
|
+
args: {
|
|
60
|
+
tauriLocalPlatforms: ["linux"],
|
|
61
|
+
tauriWorkflowPlatforms: ["linux"],
|
|
62
|
+
},
|
|
63
|
+
env: {},
|
|
64
|
+
}),
|
|
65
|
+
).toThrow("either locally or through the workflow")
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("writeTauriReleaseMetadata", () => {
|
|
70
|
+
it("updates and restores Tauri release metadata", async () => {
|
|
71
|
+
const root = await mkdtemp(join(tmpdir(), "coreframe-tauri-metadata-"))
|
|
72
|
+
await mkdir(join(root, "tauri/src-tauri"), { recursive: true })
|
|
73
|
+
const tauriConfigPath = join(root, "tauri/src-tauri/tauri.conf.json")
|
|
74
|
+
const cargoManifestPath = join(root, "tauri/src-tauri/Cargo.toml")
|
|
75
|
+
const cargoLockPath = join(root, "tauri/src-tauri/Cargo.lock")
|
|
76
|
+
|
|
77
|
+
await writeFile(
|
|
78
|
+
tauriConfigPath,
|
|
79
|
+
`${JSON.stringify({ bundle: { active: true }, version: "0.0.0" }, null, 2)}\n`,
|
|
80
|
+
)
|
|
81
|
+
await writeFile(
|
|
82
|
+
cargoManifestPath,
|
|
83
|
+
'[package]\nname = "coreframe-tauri"\nversion = "0.0.0"\nedition = "2024"\n',
|
|
84
|
+
)
|
|
85
|
+
await writeFile(cargoLockPath, '[[package]]\nname = "coreframe-tauri"\nversion = "0.0.0"\n')
|
|
86
|
+
|
|
87
|
+
const plan = resolveTauriReleasePlan({
|
|
88
|
+
args: {
|
|
89
|
+
tauriLocalPlatforms: ["macos"],
|
|
90
|
+
tauriWorkflowPlatforms: [],
|
|
91
|
+
},
|
|
92
|
+
env: {},
|
|
93
|
+
})
|
|
94
|
+
const snapshot = await writeTauriReleaseMetadata(plan, "0.20260705.0", root)
|
|
95
|
+
|
|
96
|
+
expect(JSON.parse(await readFile(tauriConfigPath, "utf8"))).toMatchObject({
|
|
97
|
+
bundle: { active: true, createUpdaterArtifacts: true },
|
|
98
|
+
version: "0.20260705.0",
|
|
99
|
+
})
|
|
100
|
+
expect(await readFile(cargoManifestPath, "utf8")).toContain('version = "0.20260705.0"')
|
|
101
|
+
expect(await readFile(cargoLockPath, "utf8")).toContain('version = "0.20260705.0"')
|
|
102
|
+
|
|
103
|
+
await restoreTauriReleaseMetadata(snapshot)
|
|
104
|
+
|
|
105
|
+
expect(JSON.parse(await readFile(tauriConfigPath, "utf8")).version).toBe("0.0.0")
|
|
106
|
+
expect(await readFile(cargoManifestPath, "utf8")).toContain('version = "0.0.0"')
|
|
107
|
+
expect(await readFile(cargoLockPath, "utf8")).toContain('version = "0.0.0"')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe("createTauriReleaseUpload", () => {
|
|
112
|
+
it("creates a latest manifest and upload tree from updater artifact pairs", async () => {
|
|
113
|
+
const root = await mkdtemp(join(tmpdir(), "coreframe-tauri-upload-"))
|
|
114
|
+
const originalCwd = process.cwd()
|
|
115
|
+
const artifactDir = join(
|
|
116
|
+
root,
|
|
117
|
+
"tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await mkdir(artifactDir, { recursive: true })
|
|
121
|
+
await writeFile(join(artifactDir, "coreframe_0.20260705.0.AppImage.tar.gz"), "artifact")
|
|
122
|
+
await writeFile(join(artifactDir, "coreframe_0.20260705.0.AppImage.tar.gz.sig"), "signature\n")
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
process.chdir(root)
|
|
126
|
+
const plan = resolveTauriReleasePlan({
|
|
127
|
+
args: {
|
|
128
|
+
tauriLocalPlatforms: ["linux"],
|
|
129
|
+
tauriWorkflowPlatforms: [],
|
|
130
|
+
},
|
|
131
|
+
env: {
|
|
132
|
+
B2_APPLICATION_KEY: "key",
|
|
133
|
+
B2_APPLICATION_KEY_ID: "key-id",
|
|
134
|
+
B2_BUCKET: "bucket",
|
|
135
|
+
TAURI_RELEASE_PUBLIC_BASE_URL: "https://downloads.example.com",
|
|
136
|
+
TAURI_SIGNING_PRIVATE_KEY: "private-key",
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
const release = await createTauriReleaseUpload({ plan, version: "0.20260705.0" })
|
|
140
|
+
const manifest = JSON.parse(
|
|
141
|
+
await readFile(join(release.uploadDir, release.manifestPath), "utf8"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
expect(release.manifestPath).toBe("latest.json")
|
|
145
|
+
expect(manifest).toMatchObject({
|
|
146
|
+
platforms: {
|
|
147
|
+
"linux-x86_64": {
|
|
148
|
+
signature: "signature",
|
|
149
|
+
url: "https://downloads.example.com/releases/0.20260705.0/linux-x86_64/coreframe_0.20260705.0.AppImage.tar.gz",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
version: "0.20260705.0",
|
|
153
|
+
})
|
|
154
|
+
await expect(
|
|
155
|
+
readFile(
|
|
156
|
+
join(
|
|
157
|
+
release.uploadDir,
|
|
158
|
+
"releases/0.20260705.0/linux-x86_64/coreframe_0.20260705.0.AppImage.tar.gz",
|
|
159
|
+
),
|
|
160
|
+
"utf8",
|
|
161
|
+
),
|
|
162
|
+
).resolves.toBe("artifact")
|
|
163
|
+
} finally {
|
|
164
|
+
process.chdir(originalCwd)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe("createTauriUpdateManifest", () => {
|
|
170
|
+
it("uses Tauri static updater manifest keys", () => {
|
|
171
|
+
expect(
|
|
172
|
+
createTauriUpdateManifest({
|
|
173
|
+
artifacts: [
|
|
174
|
+
{
|
|
175
|
+
signature: "signed",
|
|
176
|
+
updatePlatform: "darwin-aarch64",
|
|
177
|
+
url: "https://downloads.example.com/app.tar.gz",
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
notes: "Release notes",
|
|
181
|
+
pubDate: new Date("2026-07-05T12:00:00.000Z"),
|
|
182
|
+
version: "0.20260705.0",
|
|
183
|
+
}),
|
|
184
|
+
).toEqual({
|
|
185
|
+
notes: "Release notes",
|
|
186
|
+
platforms: {
|
|
187
|
+
"darwin-aarch64": {
|
|
188
|
+
signature: "signed",
|
|
189
|
+
url: "https://downloads.example.com/app.tar.gz",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
pub_date: "2026-07-05T12:00:00.000Z",
|
|
193
|
+
version: "0.20260705.0",
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
})
|