cosmolo 0.3.9 → 0.4.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/README.md +66 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/migrate/drizzle-setup.d.ts +2 -0
- package/dist/cli/migrate/drizzle-setup.js +263 -0
- package/dist/cli/migrate/schema.d.ts +12 -0
- package/dist/cli/migrate/schema.js +56 -0
- package/dist/cli/migrate/sql-execute.d.ts +2 -0
- package/dist/cli/migrate/sql-execute.js +67 -0
- package/dist/cli/migrate/sql-export.d.ts +4 -0
- package/dist/cli/migrate/sql-export.js +76 -0
- package/dist/cli/migrate.d.ts +1 -0
- package/dist/cli/migrate.js +54 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ a canonical "just add Markdown and go" story without asking you to leave.
|
|
|
24
24
|
| Component in Markdown | Yes (.svx) | Yes (.mdx) | Yes | No |
|
|
25
25
|
| Config-driven categories | Yes | No | No | No |
|
|
26
26
|
| Headless CMS (JSON API) | Yes | Manual | Manual | Manual |
|
|
27
|
+
| DB migration path | `migrate:db` | No | No | Manual |
|
|
27
28
|
| Learning curve | SvelteKit only | Astro concepts | Vue + Nuxt | SvelteKit only |
|
|
28
29
|
|
|
29
30
|
**Core principles:**
|
|
@@ -272,6 +273,71 @@ Prompts for key (slug), label, and description. Appends the new entry to `config
|
|
|
272
273
|
|
|
273
274
|
---
|
|
274
275
|
|
|
276
|
+
## Database Migration
|
|
277
|
+
|
|
278
|
+
When a file-based Cosmolo site outgrows Markdown — multiple writers, mobile editing,
|
|
279
|
+
a growing team — `migrate:db` converts your content to a database without rewriting
|
|
280
|
+
your application code.
|
|
281
|
+
|
|
282
|
+
DB support is optional. File-based sites continue to work exactly as before.
|
|
283
|
+
Migration is a one-time operation when you're ready to scale.
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
bunx cosmolo migrate:db
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The command is interactive and offers three paths:
|
|
290
|
+
|
|
291
|
+
| Option | Description |
|
|
292
|
+
|---|---|
|
|
293
|
+
| **1 — Export SQL files** | Generates `cosmolo-migration/*.sql` (CREATE TABLE + INSERT for all articles and categories). Works with any relational database. |
|
|
294
|
+
| **2 — Execute directly** | Executes the same SQL against a local SQLite database. Set `DATABASE_URL=./mysite.db` before running. |
|
|
295
|
+
| **3 — Drizzle + Cloudflare D1** | Full setup: generates Drizzle schema, CRUD helpers, `wrangler.toml` D1 binding, and `drizzle.config.ts`. Runs preflight checks before writing anything. |
|
|
296
|
+
|
|
297
|
+
### What gets migrated
|
|
298
|
+
|
|
299
|
+
- **Articles** — all frontmatter fields plus the raw Markdown body. Subdirectory-organized files (e.g. `articles/2024/post.md`) are handled automatically; the slug becomes `2024/post`.
|
|
300
|
+
- **Categories** — from `config/categories.json`
|
|
301
|
+
- **Draft articles** — included in the DB with `draft = 1`; the generated `getArticles()` helper filters them out automatically
|
|
302
|
+
|
|
303
|
+
### Option 3 — Drizzle + Cloudflare D1
|
|
304
|
+
|
|
305
|
+
After a preflight check (drizzle installed, wrangler.toml, table conflicts), the following files are generated:
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
drizzle/schema.ts ← Drizzle schema for articles and categories tables
|
|
309
|
+
src/lib/db/articles.ts ← getArticles, getArticle, createArticle, updateArticle, deleteArticle
|
|
310
|
+
src/lib/db/categories.ts ← getCategories, getCategory, createCategory, updateCategory, deleteCategory
|
|
311
|
+
wrangler.toml ← [[d1_databases]] binding added (merged if file exists)
|
|
312
|
+
drizzle.config.ts ← drizzle-kit config (dialect: sqlite)
|
|
313
|
+
.dev.vars.example ← Cloudflare environment variable reference
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The command prints step-by-step instructions after generation:
|
|
317
|
+
1. `bunx wrangler d1 create <db-name>` and copy the `database_id` into `wrangler.toml`
|
|
318
|
+
2. `bunx drizzle-kit generate` to create SQL migration files
|
|
319
|
+
3. `bunx wrangler d1 migrations apply <db-name> --local` to apply locally
|
|
320
|
+
4. Run Option 1 to export seed SQL, then `wrangler d1 execute` to import your articles
|
|
321
|
+
5. `bun add -d @cloudflare/workers-types` for TypeScript support
|
|
322
|
+
6. Add `interface Platform { env: { DB: D1Database } }` to `src/app.d.ts`
|
|
323
|
+
|
|
324
|
+
### SSR requirement
|
|
325
|
+
|
|
326
|
+
DB-backed content requires a server-capable adapter. Content in D1 is resolved at
|
|
327
|
+
request time, so `adapter-static` (SSG) is not compatible.
|
|
328
|
+
|
|
329
|
+
Switch to `adapter-cloudflare` for Cloudflare Pages, or `adapter-node` for a
|
|
330
|
+
self-hosted server:
|
|
331
|
+
|
|
332
|
+
```diff
|
|
333
|
+
- import adapter from '@sveltejs/adapter-static';
|
|
334
|
+
+ import adapter from '@sveltejs/adapter-cloudflare';
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The upside: content edits take effect immediately — no rebuild or redeploy needed.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
275
341
|
## Headless CMS
|
|
276
342
|
|
|
277
343
|
Cosmolo can expose your content as static JSON endpoints, making it usable as a
|
package/dist/cli/index.js
CHANGED
|
@@ -7,6 +7,9 @@ switch (cmd) {
|
|
|
7
7
|
case 'generate':
|
|
8
8
|
await (await import('./generate.js')).main();
|
|
9
9
|
break;
|
|
10
|
+
case 'migrate:db':
|
|
11
|
+
await (await import('./migrate.js')).main();
|
|
12
|
+
break;
|
|
10
13
|
default: {
|
|
11
14
|
const isUnknown = Boolean(cmd);
|
|
12
15
|
if (isUnknown)
|
|
@@ -15,6 +18,7 @@ switch (cmd) {
|
|
|
15
18
|
console.log('Commands:');
|
|
16
19
|
console.log(' init Scaffold routes into an existing SvelteKit project');
|
|
17
20
|
console.log(' generate [article|page|category] Create content files');
|
|
21
|
+
console.log(' migrate:db Migrate content to a database');
|
|
18
22
|
process.exit(isUnknown ? 1 : 0);
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { ARTICLE_COLUMNS, CATEGORY_COLUMNS } from './schema.js';
|
|
6
|
+
// ─── readline helpers ─────────────────────────────────────────────────────────
|
|
7
|
+
function ask(rl, question, fallback = '') {
|
|
8
|
+
const hint = fallback ? ` [${fallback}]` : '';
|
|
9
|
+
return new Promise((resolve) => rl.question(` ${question}${hint}: `, (ans) => resolve(ans.trim() || fallback)));
|
|
10
|
+
}
|
|
11
|
+
function confirm(rl, question) {
|
|
12
|
+
return new Promise((resolve) => rl.question(` ${question} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y')));
|
|
13
|
+
}
|
|
14
|
+
// ─── Drizzle schema generation ────────────────────────────────────────────────
|
|
15
|
+
function drizzleColumnDef(col) {
|
|
16
|
+
const fn = col.type === 'TEXT' ? 'text' : 'integer';
|
|
17
|
+
let chain = ` ${col.name}: ${fn}('${col.name}')`;
|
|
18
|
+
if (col.primaryKey) {
|
|
19
|
+
chain += '.primaryKey()';
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
if (col.notNull)
|
|
23
|
+
chain += '.notNull()';
|
|
24
|
+
if (col.defaultValue !== undefined) {
|
|
25
|
+
const val = typeof col.defaultValue === 'string' ? `'${col.defaultValue}'` : col.defaultValue;
|
|
26
|
+
chain += `.default(${val})`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return chain + ',';
|
|
30
|
+
}
|
|
31
|
+
function generateDrizzleSchema() {
|
|
32
|
+
const catCols = CATEGORY_COLUMNS.map(drizzleColumnDef).join('\n');
|
|
33
|
+
const artCols = ARTICLE_COLUMNS.map(drizzleColumnDef).join('\n');
|
|
34
|
+
return (`import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';\n\n` +
|
|
35
|
+
`export const categories = sqliteTable('categories', {\n${catCols}\n});\n\n` +
|
|
36
|
+
`export const articles = sqliteTable('articles', {\n${artCols}\n});\n`);
|
|
37
|
+
}
|
|
38
|
+
// ─── CRUD file generation ─────────────────────────────────────────────────────
|
|
39
|
+
function generateArticlesCrud() {
|
|
40
|
+
return `import { drizzle } from 'drizzle-orm/d1';
|
|
41
|
+
import { desc, eq } from 'drizzle-orm';
|
|
42
|
+
import { articles } from '../../drizzle/schema';
|
|
43
|
+
|
|
44
|
+
export function createDb(d1: D1Database) {
|
|
45
|
+
return drizzle(d1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function getArticles(d1: D1Database) {
|
|
49
|
+
return createDb(d1)
|
|
50
|
+
.select()
|
|
51
|
+
.from(articles)
|
|
52
|
+
.where(eq(articles.draft, 0))
|
|
53
|
+
.orderBy(desc(articles.sort));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function getArticle(d1: D1Database, slug: string) {
|
|
57
|
+
const [row] = await createDb(d1).select().from(articles).where(eq(articles.slug, slug));
|
|
58
|
+
return row ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function createArticle(d1: D1Database, data: typeof articles.$inferInsert) {
|
|
62
|
+
return createDb(d1).insert(articles).values(data).returning();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function updateArticle(
|
|
66
|
+
d1: D1Database,
|
|
67
|
+
slug: string,
|
|
68
|
+
data: Partial<typeof articles.$inferInsert>
|
|
69
|
+
) {
|
|
70
|
+
return createDb(d1).update(articles).set(data).where(eq(articles.slug, slug)).returning();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function deleteArticle(d1: D1Database, slug: string) {
|
|
74
|
+
return createDb(d1).delete(articles).where(eq(articles.slug, slug));
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
function generateCategoriesCrud() {
|
|
79
|
+
return `import { drizzle } from 'drizzle-orm/d1';
|
|
80
|
+
import { eq } from 'drizzle-orm';
|
|
81
|
+
import { categories } from '../../drizzle/schema';
|
|
82
|
+
|
|
83
|
+
export function createDb(d1: D1Database) {
|
|
84
|
+
return drizzle(d1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getCategories(d1: D1Database) {
|
|
88
|
+
return createDb(d1).select().from(categories);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function getCategory(d1: D1Database, key: string) {
|
|
92
|
+
const [row] = await createDb(d1).select().from(categories).where(eq(categories.key, key));
|
|
93
|
+
return row ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function createCategory(d1: D1Database, data: typeof categories.$inferInsert) {
|
|
97
|
+
return createDb(d1).insert(categories).values(data).returning();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function updateCategory(
|
|
101
|
+
d1: D1Database,
|
|
102
|
+
key: string,
|
|
103
|
+
data: Partial<typeof categories.$inferInsert>
|
|
104
|
+
) {
|
|
105
|
+
return createDb(d1).update(categories).set(data).where(eq(categories.key, key)).returning();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function deleteCategory(d1: D1Database, key: string) {
|
|
109
|
+
return createDb(d1).delete(categories).where(eq(categories.key, key));
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
function generateDrizzleConfig() {
|
|
114
|
+
return `import type { Config } from 'drizzle-kit';
|
|
115
|
+
|
|
116
|
+
export default {
|
|
117
|
+
schema: './drizzle/schema.ts',
|
|
118
|
+
out: './drizzle/migrations',
|
|
119
|
+
dialect: 'sqlite',
|
|
120
|
+
} satisfies Config;
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
function generateDevVarsExample() {
|
|
124
|
+
return `CLOUDFLARE_ACCOUNT_ID=
|
|
125
|
+
CLOUDFLARE_DATABASE_ID=
|
|
126
|
+
CLOUDFLARE_D1_TOKEN=
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
// ─── wrangler.toml helpers ────────────────────────────────────────────────────
|
|
130
|
+
function d1Section(dbName) {
|
|
131
|
+
return (`\n[[d1_databases]]\n` +
|
|
132
|
+
`binding = "DB"\n` +
|
|
133
|
+
`database_name = "${dbName}"\n` +
|
|
134
|
+
`database_id = "REPLACE_WITH_YOUR_DATABASE_ID"\n`);
|
|
135
|
+
}
|
|
136
|
+
function updateWranglerToml(root, dbName) {
|
|
137
|
+
const wranglerPath = path.join(root, 'wrangler.toml');
|
|
138
|
+
if (fs.existsSync(wranglerPath)) {
|
|
139
|
+
const existing = fs.readFileSync(wranglerPath, 'utf-8');
|
|
140
|
+
fs.writeFileSync(wranglerPath, existing + d1Section(dbName));
|
|
141
|
+
return 'appended';
|
|
142
|
+
}
|
|
143
|
+
const minimal = `name = "cosmolo-site"\n` +
|
|
144
|
+
`compatibility_date = "2025-01-01"\n` +
|
|
145
|
+
d1Section(dbName);
|
|
146
|
+
fs.writeFileSync(wranglerPath, minimal);
|
|
147
|
+
return 'created';
|
|
148
|
+
}
|
|
149
|
+
// ─── preflight checks ─────────────────────────────────────────────────────────
|
|
150
|
+
async function preflight(root, rl) {
|
|
151
|
+
console.log('\n Checking environment...\n');
|
|
152
|
+
// drizzle-orm installed?
|
|
153
|
+
const drizzleInstalled = fs.existsSync(path.join(root, 'node_modules', 'drizzle-orm'));
|
|
154
|
+
if (!drizzleInstalled) {
|
|
155
|
+
console.log(' drizzle-orm is not installed.');
|
|
156
|
+
const install = await confirm(rl, 'Install drizzle-orm and drizzle-kit now?');
|
|
157
|
+
if (install) {
|
|
158
|
+
console.log(' Running: bun add drizzle-orm drizzle-kit ...');
|
|
159
|
+
execSync('bun add drizzle-orm drizzle-kit', { stdio: 'inherit', cwd: root });
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
console.log(' Install drizzle-orm and drizzle-kit manually, then re-run migrate:db.');
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// wrangler.toml: existing D1 binding?
|
|
167
|
+
const wranglerPath = path.join(root, 'wrangler.toml');
|
|
168
|
+
if (fs.existsSync(wranglerPath)) {
|
|
169
|
+
const content = fs.readFileSync(wranglerPath, 'utf-8');
|
|
170
|
+
if (content.includes('[[d1_databases]]')) {
|
|
171
|
+
console.log(' Warning: wrangler.toml already contains [[d1_databases]].');
|
|
172
|
+
const proceed = await confirm(rl, 'Add another D1 binding anyway?');
|
|
173
|
+
if (!proceed)
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// drizzle/schema.ts exists?
|
|
178
|
+
const schemaPath = path.join(root, 'drizzle', 'schema.ts');
|
|
179
|
+
if (fs.existsSync(schemaPath)) {
|
|
180
|
+
const ok = await confirm(rl, 'drizzle/schema.ts already exists. Overwrite?');
|
|
181
|
+
if (!ok)
|
|
182
|
+
return null;
|
|
183
|
+
// table name conflict check
|
|
184
|
+
const existing = fs.readFileSync(schemaPath, 'utf-8');
|
|
185
|
+
const conflicts = ['articles', 'categories'].filter((t) => existing.includes(`sqliteTable('${t}'`));
|
|
186
|
+
if (conflicts.length > 0) {
|
|
187
|
+
console.log(` Warning: table(s) already defined in schema: ${conflicts.join(', ')}`);
|
|
188
|
+
const ok2 = await confirm(rl, 'Continue and overwrite?');
|
|
189
|
+
if (!ok2)
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// drizzle.config.ts exists?
|
|
194
|
+
const drizzleConfigPath = path.join(root, 'drizzle.config.ts');
|
|
195
|
+
if (fs.existsSync(drizzleConfigPath)) {
|
|
196
|
+
const ok = await confirm(rl, 'drizzle.config.ts already exists. Overwrite?');
|
|
197
|
+
if (!ok)
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
// src/lib/db/ CRUD files exist?
|
|
201
|
+
const dbDir = path.join(root, 'src', 'lib', 'db');
|
|
202
|
+
const crudFiles = ['articles.ts', 'categories.ts'].filter((f) => fs.existsSync(path.join(dbDir, f)));
|
|
203
|
+
if (crudFiles.length > 0) {
|
|
204
|
+
console.log(` Warning: src/lib/db/${crudFiles.join(', ')} already exist.`);
|
|
205
|
+
const ok = await confirm(rl, 'Overwrite CRUD files?');
|
|
206
|
+
if (!ok)
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
// D1 database name
|
|
210
|
+
const dbName = await ask(rl, 'D1 database name', 'cosmolo');
|
|
211
|
+
return { dbName };
|
|
212
|
+
}
|
|
213
|
+
// ─── main ─────────────────────────────────────────────────────────────────────
|
|
214
|
+
export async function drizzleSetup(_config) {
|
|
215
|
+
const root = process.cwd();
|
|
216
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
217
|
+
const result = await preflight(root, rl);
|
|
218
|
+
rl.close();
|
|
219
|
+
if (!result)
|
|
220
|
+
return;
|
|
221
|
+
const { dbName } = result;
|
|
222
|
+
// Generate drizzle/schema.ts
|
|
223
|
+
const drizzleDir = path.join(root, 'drizzle');
|
|
224
|
+
fs.mkdirSync(drizzleDir, { recursive: true });
|
|
225
|
+
fs.writeFileSync(path.join(drizzleDir, 'schema.ts'), generateDrizzleSchema());
|
|
226
|
+
// Generate src/lib/db/articles.ts and categories.ts
|
|
227
|
+
const dbDir = path.join(root, 'src', 'lib', 'db');
|
|
228
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
229
|
+
fs.writeFileSync(path.join(dbDir, 'articles.ts'), generateArticlesCrud());
|
|
230
|
+
fs.writeFileSync(path.join(dbDir, 'categories.ts'), generateCategoriesCrud());
|
|
231
|
+
// drizzle.config.ts
|
|
232
|
+
fs.writeFileSync(path.join(root, 'drizzle.config.ts'), generateDrizzleConfig());
|
|
233
|
+
// .dev.vars.example
|
|
234
|
+
if (!fs.existsSync(path.join(root, '.dev.vars.example'))) {
|
|
235
|
+
fs.writeFileSync(path.join(root, '.dev.vars.example'), generateDevVarsExample());
|
|
236
|
+
}
|
|
237
|
+
// wrangler.toml
|
|
238
|
+
const wranglerAction = updateWranglerToml(root, dbName);
|
|
239
|
+
console.log('\n✓ Files generated:');
|
|
240
|
+
console.log(' drizzle/schema.ts');
|
|
241
|
+
console.log(' src/lib/db/articles.ts');
|
|
242
|
+
console.log(' src/lib/db/categories.ts');
|
|
243
|
+
console.log(' drizzle.config.ts');
|
|
244
|
+
console.log(' .dev.vars.example');
|
|
245
|
+
console.log(` wrangler.toml (${wranglerAction})`);
|
|
246
|
+
console.log('\nNext steps:\n');
|
|
247
|
+
console.log(` 1. Create the D1 database (if not done yet):`);
|
|
248
|
+
console.log(` bunx wrangler d1 create ${dbName}`);
|
|
249
|
+
console.log(` Copy the database_id into wrangler.toml.\n`);
|
|
250
|
+
console.log(` 2. Generate migration files:`);
|
|
251
|
+
console.log(` bunx drizzle-kit generate\n`);
|
|
252
|
+
console.log(` 3. Apply migrations locally:`);
|
|
253
|
+
console.log(` bunx wrangler d1 migrations apply ${dbName} --local\n`);
|
|
254
|
+
console.log(` 4. Seed content from Markdown files:`);
|
|
255
|
+
console.log(` bunx cosmolo migrate:db → choose Option 1 to export SQL`);
|
|
256
|
+
console.log(` bunx wrangler d1 execute ${dbName} --local --file=cosmolo-migration/002_seed_categories.sql`);
|
|
257
|
+
console.log(` bunx wrangler d1 execute ${dbName} --local --file=cosmolo-migration/003_seed_articles.sql\n`);
|
|
258
|
+
console.log(` 5. Install Cloudflare Workers types for TypeScript:`);
|
|
259
|
+
console.log(` bun add -d @cloudflare/workers-types\n`);
|
|
260
|
+
console.log(` 6. Add D1Database to src/app.d.ts:`);
|
|
261
|
+
console.log(` interface Platform { env: { DB: D1Database } }`);
|
|
262
|
+
console.log(`\n See docs/DB_MIGRATION.md for full details.`);
|
|
263
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SqlColumn {
|
|
2
|
+
name: string;
|
|
3
|
+
type: 'TEXT' | 'INTEGER';
|
|
4
|
+
notNull: boolean;
|
|
5
|
+
defaultValue?: string | number;
|
|
6
|
+
primaryKey?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const ARTICLE_COLUMNS: SqlColumn[];
|
|
9
|
+
export declare const CATEGORY_COLUMNS: SqlColumn[];
|
|
10
|
+
export declare function createTableSql(tableName: string, columns: SqlColumn[]): string;
|
|
11
|
+
export declare function escapeSql(val: string): string;
|
|
12
|
+
export declare function toSqlLiteral(val: unknown): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Derived from articleFrontmatterSchema in articles.ts.
|
|
2
|
+
// Arrays are stored as JSON text. Booleans as INTEGER (0/1, SQLite convention).
|
|
3
|
+
export const ARTICLE_COLUMNS = [
|
|
4
|
+
{ name: 'slug', type: 'TEXT', notNull: true, primaryKey: true },
|
|
5
|
+
{ name: 'title', type: 'TEXT', notNull: true },
|
|
6
|
+
{ name: 'category', type: 'TEXT', notNull: true },
|
|
7
|
+
{ name: 'excerpt', type: 'TEXT', notNull: true },
|
|
8
|
+
{ name: 'sort', type: 'INTEGER', notNull: true, defaultValue: 0 },
|
|
9
|
+
{ name: 'date', type: 'TEXT', notNull: true, defaultValue: '' },
|
|
10
|
+
{ name: 'tags', type: 'TEXT', notNull: true, defaultValue: '[]' },
|
|
11
|
+
{ name: 'series', type: 'TEXT', notNull: false },
|
|
12
|
+
{ name: 'series_order', type: 'INTEGER', notNull: false },
|
|
13
|
+
{ name: 'draft', type: 'INTEGER', notNull: true, defaultValue: 0 },
|
|
14
|
+
{ name: 'related', type: 'TEXT', notNull: true, defaultValue: '[]' },
|
|
15
|
+
{ name: 'body', type: 'TEXT', notNull: true },
|
|
16
|
+
];
|
|
17
|
+
export const CATEGORY_COLUMNS = [
|
|
18
|
+
{ name: 'key', type: 'TEXT', notNull: true, primaryKey: true },
|
|
19
|
+
{ name: 'label', type: 'TEXT', notNull: true },
|
|
20
|
+
{ name: 'description', type: 'TEXT', notNull: true, defaultValue: '' },
|
|
21
|
+
];
|
|
22
|
+
function columnDef(col) {
|
|
23
|
+
let def = ` ${col.name} ${col.type}`;
|
|
24
|
+
if (col.primaryKey) {
|
|
25
|
+
def += ' PRIMARY KEY';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (col.notNull)
|
|
29
|
+
def += ' NOT NULL';
|
|
30
|
+
if (col.defaultValue !== undefined) {
|
|
31
|
+
const val = typeof col.defaultValue === 'string' ? `'${col.defaultValue}'` : col.defaultValue;
|
|
32
|
+
def += ` DEFAULT ${val}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return def;
|
|
36
|
+
}
|
|
37
|
+
export function createTableSql(tableName, columns) {
|
|
38
|
+
const defs = columns.map(columnDef).join(',\n');
|
|
39
|
+
return `CREATE TABLE IF NOT EXISTS ${tableName} (\n${defs}\n);`;
|
|
40
|
+
}
|
|
41
|
+
export function escapeSql(val) {
|
|
42
|
+
return val.replace(/'/g, "''");
|
|
43
|
+
}
|
|
44
|
+
export function toSqlLiteral(val) {
|
|
45
|
+
if (val === null || val === undefined)
|
|
46
|
+
return 'NULL';
|
|
47
|
+
if (typeof val === 'boolean')
|
|
48
|
+
return val ? '1' : '0';
|
|
49
|
+
if (typeof val === 'number')
|
|
50
|
+
return String(val);
|
|
51
|
+
if (val instanceof Date)
|
|
52
|
+
return `'${escapeSql(val.toISOString().split('T')[0])}'`;
|
|
53
|
+
if (Array.isArray(val))
|
|
54
|
+
return `'${escapeSql(JSON.stringify(val))}'`;
|
|
55
|
+
return `'${escapeSql(String(val))}'`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
import { ARTICLE_COLUMNS, CATEGORY_COLUMNS, createTableSql } from './schema.js';
|
|
4
|
+
import { buildCategoriesInserts, buildArticlesInserts } from './sql-export.js';
|
|
5
|
+
function confirm(question) {
|
|
6
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
7
|
+
return new Promise((resolve) => rl.question(`${question} [y/N] `, (ans) => {
|
|
8
|
+
rl.close();
|
|
9
|
+
resolve(ans.trim().toLowerCase() === 'y');
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
function resolveDbPath(url) {
|
|
13
|
+
// Reject known non-SQLite URL schemes
|
|
14
|
+
if (/^(postgres|postgresql|mysql|mariadb|mongodb):\/\//i.test(url))
|
|
15
|
+
return null;
|
|
16
|
+
return url.replace(/^(sqlite:|file:)/, '');
|
|
17
|
+
}
|
|
18
|
+
export async function executeSqlDirect(config) {
|
|
19
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
20
|
+
if (!dbUrl) {
|
|
21
|
+
console.error('\n DATABASE_URL is not set.');
|
|
22
|
+
console.error(' Example: DATABASE_URL=./mysite.db bunx cosmolo migrate:db');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const dbPath = resolveDbPath(dbUrl);
|
|
26
|
+
if (!dbPath) {
|
|
27
|
+
console.error('\n Only SQLite databases are supported for direct execution.');
|
|
28
|
+
console.error(' Use Option 1 to export SQL files for other database engines.');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const root = process.cwd();
|
|
32
|
+
const catInserts = buildCategoriesInserts(path.join(root, config.categoriesConfigPath));
|
|
33
|
+
const artInserts = buildArticlesInserts(path.join(root, config.articlesDir));
|
|
34
|
+
console.log(`\n Target: ${dbPath}`);
|
|
35
|
+
console.log(` Categories: ${catInserts.length} rows`);
|
|
36
|
+
console.log(` Articles: ${artInserts.length} rows`);
|
|
37
|
+
const ok = await confirm('\n Proceed?');
|
|
38
|
+
if (!ok) {
|
|
39
|
+
console.log(' Aborted.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// bun:sqlite is a bun built-in; the 'bun:' scheme is not a standard ESM URL
|
|
43
|
+
// so we suppress the TypeScript module resolution error here.
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
const { Database } = await import('bun:sqlite');
|
|
47
|
+
const db = new Database(dbPath);
|
|
48
|
+
try {
|
|
49
|
+
db.run('BEGIN TRANSACTION');
|
|
50
|
+
db.run(createTableSql('categories', CATEGORY_COLUMNS));
|
|
51
|
+
db.run(createTableSql('articles', ARTICLE_COLUMNS));
|
|
52
|
+
for (const sql of catInserts)
|
|
53
|
+
db.run(sql);
|
|
54
|
+
for (const sql of artInserts)
|
|
55
|
+
db.run(sql);
|
|
56
|
+
db.run('COMMIT');
|
|
57
|
+
console.log('\n ✓ Migration complete.');
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
db.run('ROLLBACK');
|
|
61
|
+
console.error('\n Migration failed:', err);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ResolvedCosmoloConfig } from '../../types.js';
|
|
2
|
+
export declare function buildCategoriesInserts(categoriesPath: string): string[];
|
|
3
|
+
export declare function buildArticlesInserts(articlesDir: string): string[];
|
|
4
|
+
export declare function exportSqlFiles(config: ResolvedCosmoloConfig): Promise<void>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { ARTICLE_COLUMNS, CATEGORY_COLUMNS, createTableSql, escapeSql, toSqlLiteral, } from './schema.js';
|
|
5
|
+
export function buildCategoriesInserts(categoriesPath) {
|
|
6
|
+
if (!fs.existsSync(categoriesPath))
|
|
7
|
+
return [];
|
|
8
|
+
const raw = JSON.parse(fs.readFileSync(categoriesPath, 'utf-8'));
|
|
9
|
+
return Object.entries(raw).map(([key, entry]) => {
|
|
10
|
+
const label = escapeSql(entry.label ?? '');
|
|
11
|
+
const description = escapeSql(entry.description ?? '');
|
|
12
|
+
return `INSERT INTO categories (key, label, description) VALUES ('${escapeSql(key)}', '${label}', '${description}');`;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function collectArticleFiles(dir, baseDir) {
|
|
16
|
+
const results = [];
|
|
17
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const fullPath = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
results.push(...collectArticleFiles(fullPath, baseDir));
|
|
21
|
+
}
|
|
22
|
+
else if (/\.(md|svx)$/.test(entry.name)) {
|
|
23
|
+
const rel = path.relative(baseDir, fullPath).replace(/\.(md|svx)$/, '');
|
|
24
|
+
// Normalize path separators to forward slashes for slug
|
|
25
|
+
results.push({ filePath: fullPath, slug: rel.replace(/\\/g, '/') });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
export function buildArticlesInserts(articlesDir) {
|
|
31
|
+
if (!fs.existsSync(articlesDir))
|
|
32
|
+
return [];
|
|
33
|
+
return collectArticleFiles(articlesDir, articlesDir).map(({ filePath, slug }) => {
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
const { data, content } = matter(raw);
|
|
36
|
+
const dateVal = data.date instanceof Date
|
|
37
|
+
? data.date.toISOString().split('T')[0]
|
|
38
|
+
: (data.date ?? '');
|
|
39
|
+
const values = [
|
|
40
|
+
toSqlLiteral(slug),
|
|
41
|
+
toSqlLiteral(data.title ?? ''),
|
|
42
|
+
toSqlLiteral(data.category ?? ''),
|
|
43
|
+
toSqlLiteral(data.excerpt ?? ''),
|
|
44
|
+
toSqlLiteral(data.sort ?? 0),
|
|
45
|
+
toSqlLiteral(dateVal),
|
|
46
|
+
toSqlLiteral(data.tags ?? []),
|
|
47
|
+
toSqlLiteral(data.series ?? null),
|
|
48
|
+
toSqlLiteral(data.seriesOrder ?? null),
|
|
49
|
+
toSqlLiteral(data.draft ?? false),
|
|
50
|
+
toSqlLiteral(data.related ?? []),
|
|
51
|
+
toSqlLiteral(content),
|
|
52
|
+
].join(', ');
|
|
53
|
+
const cols = 'slug, title, category, excerpt, sort, date, tags, series, series_order, draft, related, body';
|
|
54
|
+
return `INSERT INTO articles (${cols}) VALUES (${values});`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function exportSqlFiles(config) {
|
|
58
|
+
const root = process.cwd();
|
|
59
|
+
const outputDir = path.join(root, 'cosmolo-migration');
|
|
60
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
61
|
+
const createSql = createTableSql('categories', CATEGORY_COLUMNS) +
|
|
62
|
+
'\n\n' +
|
|
63
|
+
createTableSql('articles', ARTICLE_COLUMNS);
|
|
64
|
+
fs.writeFileSync(path.join(outputDir, '001_create_tables.sql'), createSql + '\n');
|
|
65
|
+
const categoriesPath = path.join(root, config.categoriesConfigPath);
|
|
66
|
+
const catInserts = buildCategoriesInserts(categoriesPath);
|
|
67
|
+
fs.writeFileSync(path.join(outputDir, '002_seed_categories.sql'), catInserts.join('\n') + '\n');
|
|
68
|
+
const articlesDir = path.join(root, config.articlesDir);
|
|
69
|
+
const artInserts = buildArticlesInserts(articlesDir);
|
|
70
|
+
fs.writeFileSync(path.join(outputDir, '003_seed_articles.sql'), artInserts.join('\n') + '\n');
|
|
71
|
+
console.log(`\n✓ SQL files written to cosmolo-migration/`);
|
|
72
|
+
console.log(' 001_create_tables.sql');
|
|
73
|
+
console.log(` 002_seed_categories.sql (${catInserts.length} rows)`);
|
|
74
|
+
console.log(` 003_seed_articles.sql (${artInserts.length} rows)`);
|
|
75
|
+
console.log('\nReview the files and run them against your database.');
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { DEFAULT_CONFIG } from '../config.js';
|
|
5
|
+
async function loadConfig() {
|
|
6
|
+
const root = process.cwd();
|
|
7
|
+
for (const name of ['cosmolo.config.ts', 'cosmolo.config.js']) {
|
|
8
|
+
const p = path.join(root, name);
|
|
9
|
+
if (fs.existsSync(p)) {
|
|
10
|
+
try {
|
|
11
|
+
const mod = await import(p);
|
|
12
|
+
return mod.default;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// fall through to defaults
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return DEFAULT_CONFIG;
|
|
20
|
+
}
|
|
21
|
+
function ask(rl, question) {
|
|
22
|
+
return new Promise((resolve) => rl.question(question, (ans) => resolve(ans.trim())));
|
|
23
|
+
}
|
|
24
|
+
export async function main() {
|
|
25
|
+
const config = await loadConfig();
|
|
26
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
27
|
+
console.log('\ncosmolo migrate:db\n');
|
|
28
|
+
console.log('Select migration type:');
|
|
29
|
+
console.log(' 1. Export as SQL files');
|
|
30
|
+
console.log(' 2. Execute SQL directly');
|
|
31
|
+
console.log(' 3. Full setup with Drizzle ORM + Cloudflare D1');
|
|
32
|
+
const choice = await ask(rl, '\n> ');
|
|
33
|
+
rl.close();
|
|
34
|
+
switch (choice) {
|
|
35
|
+
case '1': {
|
|
36
|
+
const { exportSqlFiles } = await import('./migrate/sql-export.js');
|
|
37
|
+
await exportSqlFiles(config);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case '2': {
|
|
41
|
+
const { executeSqlDirect } = await import('./migrate/sql-execute.js');
|
|
42
|
+
await executeSqlDirect(config);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case '3': {
|
|
46
|
+
const { drizzleSetup } = await import('./migrate/drizzle-setup.js');
|
|
47
|
+
await drizzleSetup(config);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
console.error(`\nInvalid choice: "${choice}". Enter 1, 2, or 3.`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|