koguma 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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Schema migration — detects drift between site.config.ts and the D1 database,
3
+ * then generates safe ALTER TABLE SQL to bring the DB in sync.
4
+ *
5
+ * Design:
6
+ * - Only ADD COLUMN is safe in SQLite — removals/renames are warned, never applied.
7
+ * - Type changes are warned but not applied (SQLite doesn't support ALTER COLUMN).
8
+ * - System columns (id, created_at, updated_at, status, publishAt) are ignored.
9
+ */
10
+ import type { ContentTypeConfig } from '../config/define.ts';
11
+ import type { FieldMeta } from '../config/field.ts';
12
+
13
+ // ── Types ───────────────────────────────────────────────────────────
14
+
15
+ export interface ColumnInfo {
16
+ name: string;
17
+ type: string;
18
+ }
19
+
20
+ export interface DriftResult {
21
+ table: string;
22
+ added: { name: string; sqlType: string; meta: FieldMeta }[];
23
+ removed: string[];
24
+ typeChanged: { name: string; expected: string; actual: string }[];
25
+ }
26
+
27
+ export interface MigrationResult {
28
+ drift: DriftResult[];
29
+ sql: string[];
30
+ warnings: string[];
31
+ }
32
+
33
+ // ── System columns (skip during drift detection) ────────────────────
34
+
35
+ const SYSTEM_COLUMNS = new Set([
36
+ 'id',
37
+ 'created_at',
38
+ 'updated_at',
39
+ 'status',
40
+ 'publishAt'
41
+ ]);
42
+
43
+ // ── SQL type mapping (same as schema.ts) ────────────────────────────
44
+
45
+ function sqlType(fieldType: string): string {
46
+ switch (fieldType) {
47
+ case 'text':
48
+ case 'longText':
49
+ case 'richText':
50
+ case 'url':
51
+ case 'image':
52
+ case 'reference':
53
+ case 'date':
54
+ case 'select':
55
+ return 'TEXT';
56
+ case 'number':
57
+ return 'REAL';
58
+ case 'boolean':
59
+ return 'INTEGER';
60
+ case 'references':
61
+ return '__JOIN__';
62
+ default:
63
+ return 'TEXT';
64
+ }
65
+ }
66
+
67
+ // ── Drift detection ─────────────────────────────────────────────────
68
+
69
+ export function detectDrift(
70
+ contentTypes: ContentTypeConfig[],
71
+ existingColumns: Record<string, ColumnInfo[]>
72
+ ): MigrationResult {
73
+ const drift: DriftResult[] = [];
74
+ const sql: string[] = [];
75
+ const warnings: string[] = [];
76
+
77
+ for (const ct of contentTypes) {
78
+ const tableCols = existingColumns[ct.id];
79
+ if (!tableCols) {
80
+ // Table doesn't exist at all — schema.ts will create it
81
+ warnings.push(
82
+ `Table '${ct.id}' does not exist yet — run 'koguma seed' to create it.`
83
+ );
84
+ continue;
85
+ }
86
+
87
+ const existing = new Map(
88
+ tableCols.map(c => [c.name, c.type.toUpperCase()])
89
+ );
90
+ const result: DriftResult = {
91
+ table: ct.id,
92
+ added: [],
93
+ removed: [],
94
+ typeChanged: []
95
+ };
96
+
97
+ // Check for new fields in config not in DB
98
+ for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
99
+ const st = sqlType(meta.fieldType);
100
+ if (st === '__JOIN__') continue; // join tables handled separately
101
+
102
+ if (!existing.has(fieldId)) {
103
+ result.added.push({ name: fieldId, sqlType: st, meta });
104
+ const notNull = meta.required ? " NOT NULL DEFAULT ''" : '';
105
+ sql.push(`ALTER TABLE ${ct.id} ADD COLUMN ${fieldId} ${st}${notNull};`);
106
+ } else {
107
+ // Check type match
108
+ const actualType = existing.get(fieldId)!;
109
+ if (actualType !== st) {
110
+ result.typeChanged.push({
111
+ name: fieldId,
112
+ expected: st,
113
+ actual: actualType
114
+ });
115
+ warnings.push(
116
+ `Column '${ct.id}.${fieldId}' type mismatch: config says ${st}, DB has ${actualType}. ` +
117
+ `SQLite does not support ALTER COLUMN — manual migration needed.`
118
+ );
119
+ }
120
+ }
121
+ }
122
+
123
+ // Check for DB columns not in config (removed fields)
124
+ for (const [colName] of existing) {
125
+ if (SYSTEM_COLUMNS.has(colName)) continue;
126
+ const configHasField = colName in ct.fieldMeta;
127
+ if (!configHasField) {
128
+ result.removed.push(colName);
129
+ warnings.push(
130
+ `Column '${ct.id}.${colName}' exists in DB but not in config. ` +
131
+ `Koguma will NOT drop this column (data safety). Remove it manually if needed.`
132
+ );
133
+ }
134
+ }
135
+
136
+ if (
137
+ result.added.length ||
138
+ result.removed.length ||
139
+ result.typeChanged.length
140
+ ) {
141
+ drift.push(result);
142
+ }
143
+ }
144
+
145
+ return { drift, sql, warnings };
146
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Generic CRUD queries — parameterized by the content type config.
3
+ *
4
+ * These work with any D1 database generated by schema.ts.
5
+ * All queries use parameterized SQL to prevent injection.
6
+ */
7
+ import type { ContentTypeConfig } from '../config/define.ts';
8
+ import type { FieldMeta } from '../config/field.ts';
9
+
10
+ // ── D1 database interface (Cloudflare Workers binding) ──────────────
11
+
12
+ interface D1Database {
13
+ prepare(query: string): D1PreparedStatement;
14
+ batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
15
+ }
16
+
17
+ interface D1PreparedStatement {
18
+ bind(...values: unknown[]): D1PreparedStatement;
19
+ first<T = Record<string, unknown>>(): Promise<T | null>;
20
+ all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
21
+ run(): Promise<D1Result>;
22
+ }
23
+
24
+ interface D1Result<T = unknown> {
25
+ results?: T[];
26
+ success: boolean;
27
+ meta: Record<string, unknown>;
28
+ }
29
+
30
+ // ── Helpers ──────────────────────────────────────────────────────────
31
+
32
+ function scalarFields(ct: ContentTypeConfig): [string, FieldMeta][] {
33
+ return Object.entries(ct.fieldMeta).filter(
34
+ ([_, meta]) => meta.fieldType !== 'references'
35
+ );
36
+ }
37
+
38
+ function refArrayFields(ct: ContentTypeConfig): [string, FieldMeta][] {
39
+ return Object.entries(ct.fieldMeta).filter(
40
+ ([_, meta]) => meta.fieldType === 'references'
41
+ );
42
+ }
43
+
44
+ // ── GET ONE ─────────────────────────────────────────────────────────
45
+
46
+ export async function getEntry(
47
+ db: D1Database,
48
+ ct: ContentTypeConfig,
49
+ id: string
50
+ ): Promise<Record<string, unknown> | null> {
51
+ const row = await db
52
+ .prepare(`SELECT * FROM ${ct.id} WHERE id = ?`)
53
+ .bind(id)
54
+ .first();
55
+
56
+ if (!row) return null;
57
+
58
+ // Parse JSON fields (richText stored as JSON string)
59
+ const entry = { ...row } as Record<string, unknown>;
60
+ for (const [fieldId, meta] of scalarFields(ct)) {
61
+ if (meta.fieldType === 'richText' && typeof entry[fieldId] === 'string') {
62
+ try {
63
+ entry[fieldId] = JSON.parse(entry[fieldId] as string);
64
+ } catch {
65
+ // leave as string if not valid JSON
66
+ }
67
+ }
68
+ }
69
+
70
+ // Load references arrays
71
+ for (const [fieldId] of refArrayFields(ct)) {
72
+ const joinTable = `${ct.id}__${fieldId}`;
73
+ const refs = await db
74
+ .prepare(
75
+ `SELECT target_id FROM ${joinTable} WHERE source_id = ? ORDER BY sort_order`
76
+ )
77
+ .bind(id)
78
+ .all();
79
+ entry[fieldId] =
80
+ refs.results?.map((r: Record<string, unknown>) => r.target_id) ?? [];
81
+ }
82
+
83
+ return entry;
84
+ }
85
+
86
+ // ── GET ALL ─────────────────────────────────────────────────────────
87
+
88
+ export async function getEntries(
89
+ db: D1Database,
90
+ ct: ContentTypeConfig,
91
+ opts?: { publishedOnly?: boolean }
92
+ ): Promise<Record<string, unknown>[]> {
93
+ const whereClause = opts?.publishedOnly
94
+ ? " WHERE status = 'published' AND (publishAt IS NULL OR publishAt <= datetime('now'))"
95
+ : '';
96
+ const result = await db
97
+ .prepare(`SELECT * FROM ${ct.id}${whereClause} ORDER BY created_at DESC`)
98
+ .all();
99
+
100
+ const rows = (result.results ?? []) as Record<string, unknown>[];
101
+
102
+ // Parse JSON + load refs for each row
103
+ const entries: Record<string, unknown>[] = [];
104
+ for (const row of rows) {
105
+ const entry = { ...row };
106
+ for (const [fieldId, meta] of scalarFields(ct)) {
107
+ if (meta.fieldType === 'richText' && typeof entry[fieldId] === 'string') {
108
+ try {
109
+ entry[fieldId] = JSON.parse(entry[fieldId] as string);
110
+ } catch {
111
+ /* skip */
112
+ }
113
+ }
114
+ }
115
+
116
+ for (const [fieldId] of refArrayFields(ct)) {
117
+ const joinTable = `${ct.id}__${fieldId}`;
118
+ const refs = await db
119
+ .prepare(
120
+ `SELECT target_id FROM ${joinTable} WHERE source_id = ? ORDER BY sort_order`
121
+ )
122
+ .bind(entry.id)
123
+ .all();
124
+ entry[fieldId] =
125
+ refs.results?.map((r: Record<string, unknown>) => r.target_id) ?? [];
126
+ }
127
+
128
+ entries.push(entry);
129
+ }
130
+
131
+ return entries;
132
+ }
133
+
134
+ // ── CREATE ──────────────────────────────────────────────────────────
135
+
136
+ export async function createEntry(
137
+ db: D1Database,
138
+ ct: ContentTypeConfig,
139
+ data: Record<string, unknown>
140
+ ): Promise<Record<string, unknown>> {
141
+ const id = (data.id as string) ?? crypto.randomUUID();
142
+ const fields = scalarFields(ct);
143
+ const refFields = refArrayFields(ct);
144
+
145
+ const columns = ['id', 'status', 'publishAt', ...fields.map(([f]) => f)];
146
+ const placeholders = columns.map(() => '?').join(', ');
147
+ const values = [
148
+ id,
149
+ (data.status as string) ?? 'draft',
150
+ (data.publishAt as string) ?? null,
151
+ ...fields.map(([f, meta]) => {
152
+ const val = data[f];
153
+ if (
154
+ meta.fieldType === 'richText' &&
155
+ val != null &&
156
+ typeof val === 'object'
157
+ ) {
158
+ return JSON.stringify(val);
159
+ }
160
+ return val ?? null;
161
+ })
162
+ ];
163
+
164
+ const statements: D1PreparedStatement[] = [
165
+ db
166
+ .prepare(
167
+ `INSERT INTO ${ct.id} (${columns.join(', ')}) VALUES (${placeholders})`
168
+ )
169
+ .bind(...values)
170
+ ];
171
+
172
+ // Insert join table entries for refs
173
+ for (const [fieldId] of refFields) {
174
+ const refs = data[fieldId] as string[] | undefined;
175
+ if (refs?.length) {
176
+ const joinTable = `${ct.id}__${fieldId}`;
177
+ for (let i = 0; i < refs.length; i++) {
178
+ statements.push(
179
+ db
180
+ .prepare(
181
+ `INSERT INTO ${joinTable} (source_id, target_id, sort_order) VALUES (?, ?, ?)`
182
+ )
183
+ .bind(id, refs[i], i)
184
+ );
185
+ }
186
+ }
187
+ }
188
+
189
+ await db.batch(statements);
190
+ return (await getEntry(db, ct, id))!;
191
+ }
192
+
193
+ // ── UPDATE ──────────────────────────────────────────────────────────
194
+
195
+ export async function updateEntry(
196
+ db: D1Database,
197
+ ct: ContentTypeConfig,
198
+ id: string,
199
+ data: Record<string, unknown>
200
+ ): Promise<Record<string, unknown> | null> {
201
+ const fields = scalarFields(ct);
202
+ const refFields = refArrayFields(ct);
203
+
204
+ // Only update fields that are present in data
205
+ const updates: string[] = ["updated_at = datetime('now')"];
206
+ const values: unknown[] = [];
207
+
208
+ // Handle status updates
209
+ if ('status' in data) {
210
+ updates.push('status = ?');
211
+ values.push(data.status);
212
+ }
213
+
214
+ // Handle publishAt updates
215
+ if ('publishAt' in data) {
216
+ updates.push('publishAt = ?');
217
+ values.push(data.publishAt);
218
+ }
219
+
220
+ for (const [fieldId, meta] of fields) {
221
+ if (fieldId in data) {
222
+ updates.push(`${fieldId} = ?`);
223
+ const val = data[fieldId];
224
+ if (
225
+ meta.fieldType === 'richText' &&
226
+ val != null &&
227
+ typeof val === 'object'
228
+ ) {
229
+ values.push(JSON.stringify(val));
230
+ } else {
231
+ values.push(val ?? null);
232
+ }
233
+ }
234
+ }
235
+
236
+ values.push(id);
237
+
238
+ const statements: D1PreparedStatement[] = [
239
+ db
240
+ .prepare(`UPDATE ${ct.id} SET ${updates.join(', ')} WHERE id = ?`)
241
+ .bind(...values)
242
+ ];
243
+
244
+ // Replace ref arrays (delete + re-insert)
245
+ for (const [fieldId] of refFields) {
246
+ if (fieldId in data) {
247
+ const joinTable = `${ct.id}__${fieldId}`;
248
+ statements.push(
249
+ db.prepare(`DELETE FROM ${joinTable} WHERE source_id = ?`).bind(id)
250
+ );
251
+ const refs = data[fieldId] as string[] | undefined;
252
+ if (refs?.length) {
253
+ for (let i = 0; i < refs.length; i++) {
254
+ statements.push(
255
+ db
256
+ .prepare(
257
+ `INSERT INTO ${joinTable} (source_id, target_id, sort_order) VALUES (?, ?, ?)`
258
+ )
259
+ .bind(id, refs[i], i)
260
+ );
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ await db.batch(statements);
267
+ return getEntry(db, ct, id);
268
+ }
269
+
270
+ // ── DELETE ───────────────────────────────────────────────────────────
271
+
272
+ export async function deleteEntry(
273
+ db: D1Database,
274
+ ct: ContentTypeConfig,
275
+ id: string
276
+ ): Promise<boolean> {
277
+ const refFields = refArrayFields(ct);
278
+
279
+ const statements: D1PreparedStatement[] = [
280
+ db.prepare(`DELETE FROM ${ct.id} WHERE id = ?`).bind(id)
281
+ ];
282
+
283
+ // Clean up join tables
284
+ for (const [fieldId] of refFields) {
285
+ const joinTable = `${ct.id}__${fieldId}`;
286
+ statements.push(
287
+ db.prepare(`DELETE FROM ${joinTable} WHERE source_id = ?`).bind(id)
288
+ );
289
+ }
290
+
291
+ await db.batch(statements);
292
+ return true;
293
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Schema generator — reads a Koguma config and produces SQL DDL for D1.
3
+ *
4
+ * Design:
5
+ * - Each content type → one table
6
+ * - Scalar fields → columns on that table
7
+ * - `field.ref()` → TEXT column (foreign key to the target table's id)
8
+ * - `field.refs()` → join table: `{source}_{field}` with (source_id, target_id, sort_order)
9
+ * - `field.richText()` → TEXT column storing JSON
10
+ * - `field.image()` → TEXT column (FK to assets table id)
11
+ * - Every table gets `id TEXT PRIMARY KEY`, `created_at`, `updated_at`
12
+ */
13
+ import type { ContentTypeConfig } from '../config/define.ts';
14
+ import type { FieldMeta, FieldType } from '../config/field.ts';
15
+
16
+ // ── SQL type mapping ────────────────────────────────────────────────
17
+
18
+ function sqlType(fieldType: FieldType): string {
19
+ switch (fieldType) {
20
+ case 'text':
21
+ case 'longText':
22
+ case 'richText':
23
+ case 'url':
24
+ case 'image':
25
+ case 'reference':
26
+ case 'date':
27
+ case 'select':
28
+ return 'TEXT';
29
+ case 'number':
30
+ return 'REAL';
31
+ case 'boolean':
32
+ return 'INTEGER'; // 0/1
33
+ case 'references':
34
+ return '__JOIN__'; // handled separately
35
+ }
36
+ }
37
+
38
+ // ── Collect all fields from a content type (flat or grouped) ────────
39
+
40
+ function collectFields(ct: ContentTypeConfig): Record<string, FieldMeta> {
41
+ return ct.fieldMeta;
42
+ }
43
+
44
+ // ── Generate SQL ─────────────────────────────────────────────────────
45
+
46
+ export interface GeneratedSchema {
47
+ /** CREATE TABLE statements */
48
+ tables: string[];
49
+ /** CREATE INDEX statements */
50
+ indexes: string[];
51
+ /** All SQL as a single string */
52
+ sql: string;
53
+ }
54
+
55
+ export function generateSchema(
56
+ contentTypes: ContentTypeConfig[]
57
+ ): GeneratedSchema {
58
+ const tables: string[] = [];
59
+ const indexes: string[] = [];
60
+
61
+ // ── Assets table (always present) ────────────────────────────────
62
+ tables.push(`CREATE TABLE IF NOT EXISTS _assets (
63
+ id TEXT PRIMARY KEY,
64
+ title TEXT NOT NULL DEFAULT '',
65
+ description TEXT DEFAULT '',
66
+ url TEXT NOT NULL,
67
+ content_type TEXT DEFAULT '',
68
+ width INTEGER,
69
+ height INTEGER,
70
+ file_size INTEGER,
71
+ created_at TEXT DEFAULT (datetime('now')),
72
+ updated_at TEXT DEFAULT (datetime('now'))
73
+ );`);
74
+
75
+ // ── Content type tables ──────────────────────────────────────────
76
+ for (const ct of contentTypes) {
77
+ const fields = collectFields(ct);
78
+ const columns: string[] = [
79
+ 'id TEXT PRIMARY KEY',
80
+ "created_at TEXT DEFAULT (datetime('now'))",
81
+ "updated_at TEXT DEFAULT (datetime('now'))",
82
+ "status TEXT NOT NULL DEFAULT 'draft'",
83
+ 'publishAt TEXT'
84
+ ];
85
+
86
+ for (const [fieldId, meta] of Object.entries(fields)) {
87
+ const st = sqlType(meta.fieldType);
88
+
89
+ if (st === '__JOIN__') {
90
+ // Create a join table for `references` fields
91
+ const joinTable = `${ct.id}__${fieldId}`;
92
+ tables.push(`CREATE TABLE IF NOT EXISTS ${joinTable} (
93
+ source_id TEXT NOT NULL,
94
+ target_id TEXT NOT NULL,
95
+ sort_order INTEGER NOT NULL DEFAULT 0,
96
+ PRIMARY KEY (source_id, target_id)
97
+ );`);
98
+ indexes.push(
99
+ `CREATE INDEX IF NOT EXISTS idx_${joinTable}_source ON ${joinTable} (source_id);`
100
+ );
101
+ continue;
102
+ }
103
+
104
+ const notNull = meta.required ? ' NOT NULL' : '';
105
+ columns.push(`${fieldId} ${st}${notNull}`);
106
+ }
107
+
108
+ tables.push(
109
+ `CREATE TABLE IF NOT EXISTS ${ct.id} (\n ${columns.join(',\n ')}\n);`
110
+ );
111
+ }
112
+
113
+ const sql = [...tables, '', ...indexes].join('\n\n');
114
+ return { tables, indexes, sql };
115
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Media handlers — upload to and serve from R2.
3
+ */
4
+ import type { Context } from 'hono';
5
+
6
+ /** Serve a media file from R2 */
7
+ export async function serveMedia(c: Context): Promise<Response> {
8
+ const media = (c.env as Record<string, R2Bucket>).MEDIA;
9
+ const key = c.req.param('key');
10
+
11
+ const object = await media.get(key);
12
+ if (!object) return c.notFound();
13
+
14
+ return new Response(await object.arrayBuffer(), {
15
+ headers: {
16
+ 'Content-Type':
17
+ object.httpMetadata?.contentType ?? 'application/octet-stream',
18
+ 'Cache-Control': 'public, max-age=31536000, immutable'
19
+ }
20
+ });
21
+ }
22
+
23
+ /** Upload a media file to R2 + register in _assets table */
24
+ export async function uploadMedia(c: Context): Promise<Response> {
25
+ const media = (c.env as Record<string, R2Bucket>).MEDIA;
26
+ const db = (c.env as Record<string, D1Database>).DB;
27
+
28
+ const formData = await c.req.formData();
29
+ const file = formData.get('file') as File | null;
30
+ const title = (formData.get('title') as string) ?? '';
31
+
32
+ if (!file) {
33
+ return c.json({ error: 'No file provided' }, 400);
34
+ }
35
+
36
+ const id = crypto.randomUUID();
37
+ const ext = file.name.split('.').pop() ?? '';
38
+ const key = `${id}${ext ? '.' + ext : ''}`;
39
+
40
+ // Upload to R2
41
+ await media.put(key, await file.arrayBuffer(), {
42
+ httpMetadata: { contentType: file.type }
43
+ });
44
+
45
+ // Register in _assets table
46
+ const url = `/api/media/${key}`;
47
+ await db
48
+ .prepare(
49
+ `INSERT INTO _assets (id, title, url, content_type, file_size) VALUES (?, ?, ?, ?, ?)`
50
+ )
51
+ .bind(id, title || file.name, url, file.type, file.size)
52
+ .run();
53
+
54
+ return c.json({ id, url, title: title || file.name, contentType: file.type });
55
+ }
56
+
57
+ /** List all media assets */
58
+ export async function listMedia(c: Context): Promise<Response> {
59
+ const db = (c.env as Record<string, D1Database>).DB;
60
+ const result = await db
61
+ .prepare('SELECT * FROM _assets ORDER BY created_at DESC')
62
+ .all();
63
+
64
+ return c.json({ assets: result.results ?? [] });
65
+ }
66
+
67
+ /** Delete a media asset from R2 + _assets table */
68
+ export async function deleteMedia(c: Context): Promise<Response> {
69
+ const media = (c.env as Record<string, R2Bucket>).MEDIA;
70
+ const db = (c.env as Record<string, D1Database>).DB;
71
+ const id = c.req.param('id');
72
+
73
+ // Get the asset to find the R2 key
74
+ const asset = await db
75
+ .prepare('SELECT * FROM _assets WHERE id = ?')
76
+ .bind(id)
77
+ .first();
78
+
79
+ if (!asset) return c.notFound();
80
+
81
+ // Extract R2 key from URL (/api/media/{key})
82
+ const url = asset.url as string;
83
+ const key = url.replace('/api/media/', '');
84
+
85
+ await media.delete(key);
86
+ await db.prepare('DELETE FROM _assets WHERE id = ?').bind(id).run();
87
+
88
+ return c.json({ ok: true });
89
+ }