nca-ai-cms-astro-plugin 1.1.2 → 1.1.3
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/docs/superpowers/plans/2026-03-24-json-db-import-export.md +613 -0
- package/package.json +1 -1
- package/src/api/db/export.ts +23 -0
- package/src/api/db/import.ts +46 -0
- package/src/api/db/upload.ts +17 -39
- package/src/services/DbTransferService.test.ts +121 -0
- package/src/services/DbTransferService.ts +229 -0
- package/src/services/index.ts +9 -0
- package/src/utils/dbUploadUtils.test.ts +180 -0
- package/src/utils/dbUploadUtils.ts +60 -0
- package/src/utils/index.ts +8 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
# JSON Database Import/Export Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Replace the SQLite file-swap upload (which breaks the live DB connection and causes infinite redirect loops) with JSON-based export/import that uses the live `astro:db` connection for normal delete-and-insert operations.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two new API endpoints (`/api/db/export` GET, `/api/db/import` POST) that serialize/deserialize the three content tables (SiteSettings, Prompts, ScheduledPosts) as JSON. Import uses replace semantics: delete all existing rows in each table, then insert the imported rows — matching the old file-swap behavior but through the live `astro:db` connection. No file replacement, no connection disruption, no process restart. Sessions are excluded (they're per-instance auth state, so the user stays logged in).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Astro API routes, `astro:db` (existing), `db.batch()` for atomic operations, `node:test` for tests, no new dependencies.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
| Action | File | Responsibility |
|
|
16
|
+
|--------|------|----------------|
|
|
17
|
+
| Create | `src/services/DbTransferService.ts` | Export all content tables to JSON, import JSON back via delete+insert |
|
|
18
|
+
| Create | `src/services/DbTransferService.test.ts` | Unit tests for validation logic (pure functions, no DB dependency) |
|
|
19
|
+
| Create | `src/api/db/export.ts` | GET endpoint — returns JSON dump |
|
|
20
|
+
| Create | `src/api/db/import.ts` | POST endpoint — accepts JSON, replaces data via DbTransferService |
|
|
21
|
+
|
|
22
|
+
**Tables included in export/import:**
|
|
23
|
+
- `SiteSettings` (key, value, updatedAt)
|
|
24
|
+
- `Prompts` (id, name, category, promptText, updatedAt)
|
|
25
|
+
- `ScheduledPosts` (id, input, inputType, scheduledDate, status, generatedTitle, generatedDescription, generatedContent, generatedTags, generatedImageData, generatedImageAlt, publishedPath, createdAt)
|
|
26
|
+
|
|
27
|
+
**Tables excluded:**
|
|
28
|
+
- `Sessions` — per-instance auth tokens, must not be transferred between environments
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
### Task 1: DbTransferService — Validation Logic and Tests
|
|
33
|
+
|
|
34
|
+
**Files:**
|
|
35
|
+
- Create: `src/services/DbTransferService.ts`
|
|
36
|
+
- Create: `src/services/DbTransferService.test.ts`
|
|
37
|
+
|
|
38
|
+
- [ ] **Step 1: Write failing tests for validation**
|
|
39
|
+
|
|
40
|
+
In `src/services/DbTransferService.test.ts`:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { describe, it } from 'node:test';
|
|
44
|
+
import assert from 'node:assert/strict';
|
|
45
|
+
import { validateImportPayload, type DbTransferPayload } from './DbTransferService.js';
|
|
46
|
+
|
|
47
|
+
describe('validateImportPayload', () => {
|
|
48
|
+
it('accepts a valid payload with all three tables', () => {
|
|
49
|
+
const payload: DbTransferPayload = {
|
|
50
|
+
version: 1,
|
|
51
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
52
|
+
tables: {
|
|
53
|
+
siteSettings: [{ key: 'content.branche', value: 'Tech', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
54
|
+
prompts: [],
|
|
55
|
+
scheduledPosts: [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const result = validateImportPayload(payload);
|
|
59
|
+
assert.equal(result.valid, true);
|
|
60
|
+
assert.equal(result.errors.length, 0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects payload without version field', () => {
|
|
64
|
+
const result = validateImportPayload({ tables: {} } as any);
|
|
65
|
+
assert.equal(result.valid, false);
|
|
66
|
+
assert.ok(result.errors.some(e => e.includes('version')));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects payload with unsupported version', () => {
|
|
70
|
+
const result = validateImportPayload({
|
|
71
|
+
version: 99,
|
|
72
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
73
|
+
tables: { siteSettings: [], prompts: [], scheduledPosts: [] },
|
|
74
|
+
});
|
|
75
|
+
assert.equal(result.valid, false);
|
|
76
|
+
assert.ok(result.errors.some(e => e.includes('version')));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects payload without tables field', () => {
|
|
80
|
+
const result = validateImportPayload({ version: 1 } as any);
|
|
81
|
+
assert.equal(result.valid, false);
|
|
82
|
+
assert.ok(result.errors.some(e => e.includes('tables')));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects payload with missing table keys', () => {
|
|
86
|
+
const result = validateImportPayload({
|
|
87
|
+
version: 1,
|
|
88
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
89
|
+
tables: { siteSettings: [] },
|
|
90
|
+
} as any);
|
|
91
|
+
assert.equal(result.valid, false);
|
|
92
|
+
assert.ok(result.errors.some(e => e.includes('prompts')));
|
|
93
|
+
assert.ok(result.errors.some(e => e.includes('scheduledPosts')));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects siteSettings row missing required key field', () => {
|
|
97
|
+
const payload: DbTransferPayload = {
|
|
98
|
+
version: 1,
|
|
99
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
100
|
+
tables: {
|
|
101
|
+
siteSettings: [{ value: 'Tech', updatedAt: '2026-03-24T12:00:00.000Z' } as any],
|
|
102
|
+
prompts: [],
|
|
103
|
+
scheduledPosts: [],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const result = validateImportPayload(payload);
|
|
107
|
+
assert.equal(result.valid, false);
|
|
108
|
+
assert.ok(result.errors.some(e => e.includes('siteSettings')));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects prompts row missing required id field', () => {
|
|
112
|
+
const payload: DbTransferPayload = {
|
|
113
|
+
version: 1,
|
|
114
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
115
|
+
tables: {
|
|
116
|
+
siteSettings: [],
|
|
117
|
+
prompts: [{ name: 'test', category: 'c', promptText: 'p', updatedAt: '2026-03-24T12:00:00.000Z' } as any],
|
|
118
|
+
scheduledPosts: [],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const result = validateImportPayload(payload);
|
|
122
|
+
assert.equal(result.valid, false);
|
|
123
|
+
assert.ok(result.errors.some(e => e.includes('prompts')));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects scheduledPosts row missing required scheduledDate', () => {
|
|
127
|
+
const payload: DbTransferPayload = {
|
|
128
|
+
version: 1,
|
|
129
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
130
|
+
tables: {
|
|
131
|
+
siteSettings: [],
|
|
132
|
+
prompts: [],
|
|
133
|
+
scheduledPosts: [{
|
|
134
|
+
id: '1', input: 'x', inputType: 'text', status: 'pending', createdAt: '2026-03-24T12:00:00.000Z',
|
|
135
|
+
} as any],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const result = validateImportPayload(payload);
|
|
139
|
+
assert.equal(result.valid, false);
|
|
140
|
+
assert.ok(result.errors.some(e => e.includes('scheduledPosts')));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('accepts payload with empty tables (clears all data)', () => {
|
|
144
|
+
const payload: DbTransferPayload = {
|
|
145
|
+
version: 1,
|
|
146
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
147
|
+
tables: {
|
|
148
|
+
siteSettings: [],
|
|
149
|
+
prompts: [],
|
|
150
|
+
scheduledPosts: [],
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const result = validateImportPayload(payload);
|
|
154
|
+
assert.equal(result.valid, true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
160
|
+
|
|
161
|
+
Run: `npx tsx --test src/services/DbTransferService.test.ts`
|
|
162
|
+
Expected: FAIL — `validateImportPayload` does not exist
|
|
163
|
+
|
|
164
|
+
- [ ] **Step 3: Implement types and validation**
|
|
165
|
+
|
|
166
|
+
In `src/services/DbTransferService.ts`:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// @ts-ignore - resolved by Astro build pipeline
|
|
170
|
+
import { db, SiteSettings, Prompts, ScheduledPosts } from 'astro:db';
|
|
171
|
+
|
|
172
|
+
export interface SiteSettingRow {
|
|
173
|
+
key: string;
|
|
174
|
+
value: string;
|
|
175
|
+
updatedAt: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PromptRow {
|
|
179
|
+
id: string;
|
|
180
|
+
name: string;
|
|
181
|
+
category: string;
|
|
182
|
+
promptText: string;
|
|
183
|
+
updatedAt: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ScheduledPostRow {
|
|
187
|
+
id: string;
|
|
188
|
+
input: string;
|
|
189
|
+
inputType: string;
|
|
190
|
+
scheduledDate: string;
|
|
191
|
+
status: string;
|
|
192
|
+
generatedTitle?: string | null;
|
|
193
|
+
generatedDescription?: string | null;
|
|
194
|
+
generatedContent?: string | null;
|
|
195
|
+
generatedTags?: string | null;
|
|
196
|
+
generatedImageData?: string | null;
|
|
197
|
+
generatedImageAlt?: string | null;
|
|
198
|
+
publishedPath?: string | null;
|
|
199
|
+
createdAt: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface DbTransferPayload {
|
|
203
|
+
version: number;
|
|
204
|
+
exportedAt: string;
|
|
205
|
+
tables: {
|
|
206
|
+
siteSettings: SiteSettingRow[];
|
|
207
|
+
prompts: PromptRow[];
|
|
208
|
+
scheduledPosts: ScheduledPostRow[];
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ValidationResult {
|
|
213
|
+
valid: boolean;
|
|
214
|
+
errors: string[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const SUPPORTED_VERSIONS = [1];
|
|
218
|
+
|
|
219
|
+
export function validateImportPayload(payload: unknown): ValidationResult {
|
|
220
|
+
const errors: string[] = [];
|
|
221
|
+
const data = payload as Record<string, unknown>;
|
|
222
|
+
|
|
223
|
+
if (!data || typeof data !== 'object') {
|
|
224
|
+
return { valid: false, errors: ['Payload must be a JSON object'] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (typeof data.version !== 'number' || !SUPPORTED_VERSIONS.includes(data.version)) {
|
|
228
|
+
errors.push(`Missing or unsupported "version" field (supported: ${SUPPORTED_VERSIONS.join(', ')})`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!data.tables || typeof data.tables !== 'object') {
|
|
232
|
+
errors.push('Missing or invalid "tables" field');
|
|
233
|
+
return { valid: false, errors };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const tables = data.tables as Record<string, unknown>;
|
|
237
|
+
|
|
238
|
+
// Require all three table keys to be present as arrays
|
|
239
|
+
if (!Array.isArray(tables.siteSettings)) {
|
|
240
|
+
errors.push('Missing or invalid "tables.siteSettings" (must be an array)');
|
|
241
|
+
}
|
|
242
|
+
if (!Array.isArray(tables.prompts)) {
|
|
243
|
+
errors.push('Missing or invalid "tables.prompts" (must be an array)');
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(tables.scheduledPosts)) {
|
|
246
|
+
errors.push('Missing or invalid "tables.scheduledPosts" (must be an array)');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// If any table key is missing, return early — row validation won't work
|
|
250
|
+
if (errors.length > 0) {
|
|
251
|
+
return { valid: false, errors };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Validate siteSettings rows
|
|
255
|
+
for (let i = 0; i < (tables.siteSettings as any[]).length; i++) {
|
|
256
|
+
const row = (tables.siteSettings as any[])[i] as Record<string, unknown>;
|
|
257
|
+
if (!row || typeof row.key !== 'string' || typeof row.value !== 'string') {
|
|
258
|
+
errors.push(`siteSettings[${i}]: missing required fields "key" and "value"`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Validate prompts rows
|
|
263
|
+
for (let i = 0; i < (tables.prompts as any[]).length; i++) {
|
|
264
|
+
const row = (tables.prompts as any[])[i] as Record<string, unknown>;
|
|
265
|
+
if (!row || typeof row.id !== 'string' || typeof row.name !== 'string' ||
|
|
266
|
+
typeof row.category !== 'string' || typeof row.promptText !== 'string') {
|
|
267
|
+
errors.push(`prompts[${i}]: missing required fields "id", "name", "category", "promptText"`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Validate scheduledPosts rows (all non-optional columns from schema)
|
|
272
|
+
for (let i = 0; i < (tables.scheduledPosts as any[]).length; i++) {
|
|
273
|
+
const row = (tables.scheduledPosts as any[])[i] as Record<string, unknown>;
|
|
274
|
+
if (!row || typeof row.id !== 'string' || typeof row.input !== 'string' ||
|
|
275
|
+
typeof row.inputType !== 'string' || typeof row.scheduledDate !== 'string' ||
|
|
276
|
+
typeof row.status !== 'string' || typeof row.createdAt !== 'string') {
|
|
277
|
+
errors.push(`scheduledPosts[${i}]: missing required fields "id", "input", "inputType", "scheduledDate", "status", "createdAt"`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { valid: errors.length === 0, errors };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export class DbTransferService {
|
|
285
|
+
async exportAll(): Promise<DbTransferPayload> {
|
|
286
|
+
const [siteSettings, prompts, scheduledPosts] = await Promise.all([
|
|
287
|
+
db.select().from(SiteSettings),
|
|
288
|
+
db.select().from(Prompts),
|
|
289
|
+
db.select().from(ScheduledPosts),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
version: 1,
|
|
294
|
+
exportedAt: new Date().toISOString(),
|
|
295
|
+
tables: {
|
|
296
|
+
siteSettings: siteSettings.map((row: any) => ({
|
|
297
|
+
key: row.key,
|
|
298
|
+
value: row.value,
|
|
299
|
+
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
|
300
|
+
})),
|
|
301
|
+
prompts: prompts.map((row: any) => ({
|
|
302
|
+
id: row.id,
|
|
303
|
+
name: row.name,
|
|
304
|
+
category: row.category,
|
|
305
|
+
promptText: row.promptText,
|
|
306
|
+
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
|
307
|
+
})),
|
|
308
|
+
scheduledPosts: scheduledPosts.map((row: any) => ({
|
|
309
|
+
id: row.id,
|
|
310
|
+
input: row.input,
|
|
311
|
+
inputType: row.inputType,
|
|
312
|
+
scheduledDate: row.scheduledDate instanceof Date ? row.scheduledDate.toISOString() : String(row.scheduledDate),
|
|
313
|
+
status: row.status,
|
|
314
|
+
generatedTitle: row.generatedTitle ?? null,
|
|
315
|
+
generatedDescription: row.generatedDescription ?? null,
|
|
316
|
+
generatedContent: row.generatedContent ?? null,
|
|
317
|
+
generatedTags: row.generatedTags ?? null,
|
|
318
|
+
generatedImageData: row.generatedImageData ?? null,
|
|
319
|
+
generatedImageAlt: row.generatedImageAlt ?? null,
|
|
320
|
+
publishedPath: row.publishedPath ?? null,
|
|
321
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
322
|
+
})),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Replace all content data atomically using db.batch().
|
|
329
|
+
* Deletes all existing rows in each table, then inserts the imported rows.
|
|
330
|
+
* Sessions table is untouched — user stays logged in.
|
|
331
|
+
*/
|
|
332
|
+
async importAll(payload: DbTransferPayload): Promise<{ imported: Record<string, number> }> {
|
|
333
|
+
const { siteSettings, prompts, scheduledPosts } = payload.tables;
|
|
334
|
+
|
|
335
|
+
// Build all statements for atomic batch execution
|
|
336
|
+
const statements: any[] = [];
|
|
337
|
+
|
|
338
|
+
// Delete all existing rows from content tables
|
|
339
|
+
statements.push(db.delete(SiteSettings));
|
|
340
|
+
statements.push(db.delete(Prompts));
|
|
341
|
+
statements.push(db.delete(ScheduledPosts));
|
|
342
|
+
|
|
343
|
+
// Insert new SiteSettings
|
|
344
|
+
for (const row of siteSettings) {
|
|
345
|
+
statements.push(
|
|
346
|
+
db.insert(SiteSettings).values({
|
|
347
|
+
key: row.key,
|
|
348
|
+
value: row.value,
|
|
349
|
+
updatedAt: new Date(row.updatedAt),
|
|
350
|
+
})
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Insert new Prompts
|
|
355
|
+
for (const row of prompts) {
|
|
356
|
+
statements.push(
|
|
357
|
+
db.insert(Prompts).values({
|
|
358
|
+
id: row.id,
|
|
359
|
+
name: row.name,
|
|
360
|
+
category: row.category,
|
|
361
|
+
promptText: row.promptText,
|
|
362
|
+
updatedAt: new Date(row.updatedAt),
|
|
363
|
+
})
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Insert new ScheduledPosts
|
|
368
|
+
for (const row of scheduledPosts) {
|
|
369
|
+
statements.push(
|
|
370
|
+
db.insert(ScheduledPosts).values({
|
|
371
|
+
id: row.id,
|
|
372
|
+
input: row.input,
|
|
373
|
+
inputType: row.inputType,
|
|
374
|
+
scheduledDate: new Date(row.scheduledDate),
|
|
375
|
+
status: row.status,
|
|
376
|
+
generatedTitle: row.generatedTitle ?? undefined,
|
|
377
|
+
generatedDescription: row.generatedDescription ?? undefined,
|
|
378
|
+
generatedContent: row.generatedContent ?? undefined,
|
|
379
|
+
generatedTags: row.generatedTags ?? undefined,
|
|
380
|
+
generatedImageData: row.generatedImageData ?? undefined,
|
|
381
|
+
generatedImageAlt: row.generatedImageAlt ?? undefined,
|
|
382
|
+
publishedPath: row.publishedPath ?? undefined,
|
|
383
|
+
createdAt: new Date(row.createdAt),
|
|
384
|
+
})
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Execute all statements atomically
|
|
389
|
+
await db.batch(statements);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
imported: {
|
|
393
|
+
siteSettings: siteSettings.length,
|
|
394
|
+
prompts: prompts.length,
|
|
395
|
+
scheduledPosts: scheduledPosts.length,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- [ ] **Step 4: Run tests to verify validation passes**
|
|
403
|
+
|
|
404
|
+
Run: `npx tsx --test src/services/DbTransferService.test.ts`
|
|
405
|
+
Expected: PASS — all 9 validation tests green
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 5: Commit**
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
git add src/services/DbTransferService.ts src/services/DbTransferService.test.ts
|
|
411
|
+
git commit -m "feat: add DbTransferService with JSON export/import and validation"
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
### Task 2: JSON Export Endpoint
|
|
417
|
+
|
|
418
|
+
**Files:**
|
|
419
|
+
- Create: `src/api/db/export.ts`
|
|
420
|
+
|
|
421
|
+
- [ ] **Step 1: Write the export endpoint**
|
|
422
|
+
|
|
423
|
+
In `src/api/db/export.ts`:
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
import type { APIRoute } from 'astro';
|
|
427
|
+
import { jsonError } from '../_utils';
|
|
428
|
+
import { DbTransferService } from '../../services/DbTransferService.js';
|
|
429
|
+
|
|
430
|
+
export const GET: APIRoute = async () => {
|
|
431
|
+
try {
|
|
432
|
+
const service = new DbTransferService();
|
|
433
|
+
const payload = await service.exportAll();
|
|
434
|
+
const json = JSON.stringify(payload, null, 2);
|
|
435
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
436
|
+
|
|
437
|
+
return new Response(json, {
|
|
438
|
+
status: 200,
|
|
439
|
+
headers: {
|
|
440
|
+
'Content-Type': 'application/json',
|
|
441
|
+
'Content-Disposition': `attachment; filename="content-${timestamp}.json"`,
|
|
442
|
+
'Content-Length': String(Buffer.byteLength(json)),
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
} catch {
|
|
446
|
+
return jsonError('Database export failed', 500);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
- [ ] **Step 2: Commit**
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
git add src/api/db/export.ts
|
|
455
|
+
git commit -m "feat: add GET /api/db/export endpoint for JSON data export"
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
### Task 3: JSON Import Endpoint
|
|
461
|
+
|
|
462
|
+
**Files:**
|
|
463
|
+
- Create: `src/api/db/import.ts`
|
|
464
|
+
|
|
465
|
+
- [ ] **Step 1: Write the import endpoint**
|
|
466
|
+
|
|
467
|
+
In `src/api/db/import.ts`:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
import type { APIRoute } from 'astro';
|
|
471
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
472
|
+
import { DbTransferService, validateImportPayload } from '../../services/DbTransferService.js';
|
|
473
|
+
|
|
474
|
+
const MAX_IMPORT_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
475
|
+
|
|
476
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
477
|
+
try {
|
|
478
|
+
const contentType = request.headers.get('content-type') || '';
|
|
479
|
+
if (!contentType.includes('application/json')) {
|
|
480
|
+
return jsonError('Invalid content type. Use application/json.', 400);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const contentLength = Number(request.headers.get('content-length') || 0);
|
|
484
|
+
if (contentLength > MAX_IMPORT_SIZE) {
|
|
485
|
+
return jsonError('Payload too large. Maximum 50 MB.', 413);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const text = await request.text();
|
|
489
|
+
if (text.length > MAX_IMPORT_SIZE) {
|
|
490
|
+
return jsonError('Payload too large. Maximum 50 MB.', 413);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let payload: unknown;
|
|
494
|
+
try {
|
|
495
|
+
payload = JSON.parse(text);
|
|
496
|
+
} catch {
|
|
497
|
+
return jsonError('Invalid JSON.', 400);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const validation = validateImportPayload(payload);
|
|
501
|
+
if (!validation.valid) {
|
|
502
|
+
return jsonError(`Validation failed: ${validation.errors.join('; ')}`, 400);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const service = new DbTransferService();
|
|
506
|
+
const result = await service.importAll(payload as any);
|
|
507
|
+
|
|
508
|
+
return jsonResponse({
|
|
509
|
+
success: true,
|
|
510
|
+
imported: result.imported,
|
|
511
|
+
});
|
|
512
|
+
} catch {
|
|
513
|
+
return jsonError('Database import failed', 500);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
- [ ] **Step 2: Commit**
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
git add src/api/db/import.ts
|
|
522
|
+
git commit -m "feat: add POST /api/db/import endpoint for JSON data import"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
### Task 4: Manual Integration Test
|
|
528
|
+
|
|
529
|
+
**Files:**
|
|
530
|
+
- No new files — testing against running dev server
|
|
531
|
+
|
|
532
|
+
- [ ] **Step 1: Start dev server and test the full round-trip**
|
|
533
|
+
|
|
534
|
+
```bash
|
|
535
|
+
# Terminal 1: start dev server
|
|
536
|
+
npm run dev
|
|
537
|
+
|
|
538
|
+
# Terminal 2: export current data
|
|
539
|
+
curl -s http://localhost:4321/api/db/export | jq '.tables | keys'
|
|
540
|
+
# Expected: ["prompts", "scheduledPosts", "siteSettings"]
|
|
541
|
+
|
|
542
|
+
# Save export to file
|
|
543
|
+
curl -s http://localhost:4321/api/db/export > /tmp/db-export.json
|
|
544
|
+
|
|
545
|
+
# Import it back (should replace without errors)
|
|
546
|
+
curl -X POST http://localhost:4321/api/db/import \
|
|
547
|
+
-H 'Content-Type: application/json' \
|
|
548
|
+
-d @/tmp/db-export.json
|
|
549
|
+
# Expected: {"success": true, "imported": {"siteSettings": N, "prompts": N, "scheduledPosts": N}}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
- [ ] **Step 2: Verify no redirect loop — session stays valid**
|
|
553
|
+
|
|
554
|
+
After import, navigate to `/editor` in browser. Confirm:
|
|
555
|
+
- No redirect loop
|
|
556
|
+
- Settings are present and correct
|
|
557
|
+
- Session is still valid (you're still logged in)
|
|
558
|
+
|
|
559
|
+
- [ ] **Step 3: Test validation errors**
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
# Missing version
|
|
563
|
+
curl -X POST http://localhost:4321/api/db/import \
|
|
564
|
+
-H 'Content-Type: application/json' \
|
|
565
|
+
-d '{"tables": {}}'
|
|
566
|
+
# Expected: 400 with validation error about "version"
|
|
567
|
+
|
|
568
|
+
# Missing table keys
|
|
569
|
+
curl -X POST http://localhost:4321/api/db/import \
|
|
570
|
+
-H 'Content-Type: application/json' \
|
|
571
|
+
-d '{"version": 1, "tables": {"siteSettings": []}}'
|
|
572
|
+
# Expected: 400 with validation error about missing "prompts" and "scheduledPosts"
|
|
573
|
+
|
|
574
|
+
# Invalid JSON
|
|
575
|
+
curl -X POST http://localhost:4321/api/db/import \
|
|
576
|
+
-H 'Content-Type: application/json' \
|
|
577
|
+
-d 'not json'
|
|
578
|
+
# Expected: 400 "Invalid JSON"
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
- [ ] **Step 4: Commit any fixes from integration testing**
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
git add -A
|
|
585
|
+
git commit -m "fix: adjustments from integration testing"
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
### Task 5: Deprecate Old SQLite Upload
|
|
591
|
+
|
|
592
|
+
**Files:**
|
|
593
|
+
- Modify: `src/api/db/upload.ts:56-60` — add deprecation notice in response
|
|
594
|
+
|
|
595
|
+
- [ ] **Step 1: Add deprecation message to the SQLite upload response**
|
|
596
|
+
|
|
597
|
+
In `src/api/db/upload.ts`, update the success response:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
return jsonResponse({
|
|
601
|
+
success: true,
|
|
602
|
+
size: dbBuffer.length,
|
|
603
|
+
message: 'Database uploaded. Server restarting...',
|
|
604
|
+
deprecated: 'Use POST /api/db/import with JSON payload instead. The SQLite file upload causes connection issues and will be removed in a future version.',
|
|
605
|
+
});
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
- [ ] **Step 2: Commit**
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
git add src/api/db/upload.ts
|
|
612
|
+
git commit -m "chore: add deprecation notice to SQLite file upload endpoint"
|
|
613
|
+
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { jsonError } from '../_utils';
|
|
3
|
+
import { DbTransferService } from '../../services/DbTransferService.js';
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = async () => {
|
|
6
|
+
try {
|
|
7
|
+
const service = new DbTransferService();
|
|
8
|
+
const payload = await service.exportAll();
|
|
9
|
+
const json = JSON.stringify(payload, null, 2);
|
|
10
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
11
|
+
|
|
12
|
+
return new Response(json, {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
'Content-Disposition': `attachment; filename="content-${timestamp}.json"`,
|
|
17
|
+
'Content-Length': String(Buffer.byteLength(json)),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
} catch {
|
|
21
|
+
return jsonError('Database export failed', 500);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { jsonResponse, jsonError } from '../_utils';
|
|
3
|
+
import { DbTransferService, validateImportPayload } from '../../services/DbTransferService.js';
|
|
4
|
+
|
|
5
|
+
const MAX_IMPORT_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
6
|
+
|
|
7
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
8
|
+
try {
|
|
9
|
+
const contentType = request.headers.get('content-type') || '';
|
|
10
|
+
if (!contentType.includes('application/json')) {
|
|
11
|
+
return jsonError('Invalid content type. Use application/json.', 400);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const contentLength = Number(request.headers.get('content-length') || 0);
|
|
15
|
+
if (contentLength > MAX_IMPORT_SIZE) {
|
|
16
|
+
return jsonError('Payload too large. Maximum 50 MB.', 413);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const text = await request.text();
|
|
20
|
+
if (text.length > MAX_IMPORT_SIZE) {
|
|
21
|
+
return jsonError('Payload too large. Maximum 50 MB.', 413);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let payload: unknown;
|
|
25
|
+
try {
|
|
26
|
+
payload = JSON.parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
return jsonError('Invalid JSON.', 400);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const validation = validateImportPayload(payload);
|
|
32
|
+
if (!validation.valid) {
|
|
33
|
+
return jsonError(`Validation failed: ${validation.errors.join('; ')}`, 400);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const service = new DbTransferService();
|
|
37
|
+
const result = await service.importAll(payload as any);
|
|
38
|
+
|
|
39
|
+
return jsonResponse({
|
|
40
|
+
success: true,
|
|
41
|
+
imported: result.imported,
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
return jsonError('Database import failed', 500);
|
|
45
|
+
}
|
|
46
|
+
};
|