nca-ai-cms-astro-plugin 1.1.3 → 1.1.5
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 +3 -0
- package/docs/superpowers/plans/2026-03-24-settings-import-export.md +461 -0
- package/package.json +1 -1
- package/src/api/db/upload.test.ts +6 -166
- package/src/api/db/upload.ts +11 -63
- package/src/components/editor/SettingsTab.tsx +15 -12
- package/src/index.ts +10 -0
- package/src/services/DbTransferService.test.ts +51 -68
- package/src/services/DbTransferService.ts +88 -155
- package/src/services/index.ts +0 -1
- package/src/utils/index.ts +0 -8
- package/update.md +44 -0
- package/src/utils/dbUploadUtils.test.ts +0 -180
- package/src/utils/dbUploadUtils.ts +0 -60
package/README.md
CHANGED
|
@@ -85,6 +85,9 @@ ncaAiCms({
|
|
|
85
85
|
| `/api/prompts` | Manage prompt templates |
|
|
86
86
|
| `/api/scheduler` | Manage scheduled posts |
|
|
87
87
|
| `/api/articles/*` | Article operations |
|
|
88
|
+
| `/api/db/export` | Export settings + prompts as JSON |
|
|
89
|
+
| `/api/db/import` | Import settings + prompts (merge/upsert) |
|
|
90
|
+
| `/api/db/download` | Download raw SQLite database backup |
|
|
88
91
|
|
|
89
92
|
All `/api/*` and `/editor` routes are protected by cookie-based authentication.
|
|
90
93
|
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# Settings 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:** Refactor the database import/export into a community-friendly settings transfer system with merge (upsert) semantics and partial import support — no data loss, no connection issues, no restart needed.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Rework `DbTransferService` to export only shareable configuration (SiteSettings + Prompts, not ScheduledPosts). Flatten the payload (no `tables` wrapper). Import uses upsert semantics (merge into existing data) instead of delete-and-replace. Payload sections are optional so users can import just prompts or just settings. Clean up dead code from removed SQLite upload.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Astro API routes, `astro:db` (existing), vitest, no new dependencies.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
| Action | File | Responsibility |
|
|
16
|
+
|--------|------|----------------|
|
|
17
|
+
| Modify | `src/services/DbTransferService.ts` | Drop ScheduledPosts, flatten payload, upsert semantics, partial import |
|
|
18
|
+
| Modify | `src/services/DbTransferService.test.ts` | Validation tests for new flat schema with partial import |
|
|
19
|
+
| Modify | `src/services/index.ts` | Drop `ScheduledPostRow` export |
|
|
20
|
+
| Rewrite | `src/api/db/upload.test.ts` | Replace stale tests with single 410 stub test |
|
|
21
|
+
| Delete | `src/utils/dbUploadUtils.ts` | Dead code — upload is a 410 stub |
|
|
22
|
+
| Delete | `src/utils/dbUploadUtils.test.ts` | Tests for dead code |
|
|
23
|
+
| Modify | `src/utils/index.ts` | Remove dbUploadUtils exports |
|
|
24
|
+
| Keep | `src/api/db/export.ts` | No changes (calls `exportAll`) |
|
|
25
|
+
| Keep | `src/api/db/import.ts` | No changes (calls `importAll`) |
|
|
26
|
+
| Keep | `src/api/db/upload.ts` | Already a 410 stub |
|
|
27
|
+
| Keep | `src/api/db/download.ts` | Raw SQLite backup stays |
|
|
28
|
+
|
|
29
|
+
**What's exported/imported (flat payload):**
|
|
30
|
+
- `siteSettings` — site configuration (content settings, image settings, CTA config)
|
|
31
|
+
- `prompts` — AI prompt templates (community-shareable)
|
|
32
|
+
|
|
33
|
+
**What's removed:**
|
|
34
|
+
- `scheduledPosts` — instance-specific runtime state
|
|
35
|
+
- `tables` wrapper — payload is now flat (`{ version, siteSettings, prompts }`)
|
|
36
|
+
|
|
37
|
+
**Import semantics:**
|
|
38
|
+
- **Upsert/merge** — existing rows updated by key/id, new rows inserted, unmentioned rows untouched
|
|
39
|
+
- **Partial** — include only the sections you want to update
|
|
40
|
+
|
|
41
|
+
**Upsert pattern:** Uses `.get()` + update-or-insert (same pattern as `PromptService.updateSetting` at `src/services/PromptService.ts:124-143`). This is the established codebase convention — `.get()` is used across `SessionService`, `PromptService`, and `SchedulerDBAdapter`.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
### Task 1: Rewrite DbTransferService — Flat Payload, Upsert, Partial Import
|
|
46
|
+
|
|
47
|
+
**Files:**
|
|
48
|
+
- Modify: `src/services/DbTransferService.ts`
|
|
49
|
+
- Modify: `src/services/DbTransferService.test.ts`
|
|
50
|
+
|
|
51
|
+
- [ ] **Step 1: Update validation tests for new schema**
|
|
52
|
+
|
|
53
|
+
Replace the full contents of `src/services/DbTransferService.test.ts`:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
57
|
+
|
|
58
|
+
vi.mock('astro:db', () => ({
|
|
59
|
+
db: {},
|
|
60
|
+
SiteSettings: {},
|
|
61
|
+
Prompts: {},
|
|
62
|
+
eq: vi.fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
import { validateImportPayload, type DbTransferPayload } from './DbTransferService.js';
|
|
66
|
+
|
|
67
|
+
describe('validateImportPayload', () => {
|
|
68
|
+
it('accepts a valid payload with both sections', () => {
|
|
69
|
+
const payload: DbTransferPayload = {
|
|
70
|
+
version: 1,
|
|
71
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
72
|
+
siteSettings: [{ key: 'content.branche', value: 'Tech', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
73
|
+
prompts: [{ id: 'p1', name: 'Blog', category: 'content', promptText: 'Write...', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
74
|
+
};
|
|
75
|
+
const result = validateImportPayload(payload);
|
|
76
|
+
expect(result.valid).toBe(true);
|
|
77
|
+
expect(result.errors).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('accepts payload with only siteSettings (partial import)', () => {
|
|
81
|
+
const result = validateImportPayload({
|
|
82
|
+
version: 1,
|
|
83
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
84
|
+
siteSettings: [{ key: 'k', value: 'v', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
85
|
+
});
|
|
86
|
+
expect(result.valid).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('accepts payload with only prompts (partial import)', () => {
|
|
90
|
+
const result = validateImportPayload({
|
|
91
|
+
version: 1,
|
|
92
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
93
|
+
prompts: [{ id: 'p1', name: 'n', category: 'c', promptText: 't', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
94
|
+
});
|
|
95
|
+
expect(result.valid).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects payload with neither siteSettings nor prompts', () => {
|
|
99
|
+
const result = validateImportPayload({ version: 1, exportedAt: '2026-03-24T12:00:00.000Z' });
|
|
100
|
+
expect(result.valid).toBe(false);
|
|
101
|
+
expect(result.errors.some(e => e.includes('at least one'))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects payload without version field', () => {
|
|
105
|
+
const result = validateImportPayload({ siteSettings: [] });
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
expect(result.errors.some(e => e.includes('version'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects unsupported version', () => {
|
|
111
|
+
const result = validateImportPayload({
|
|
112
|
+
version: 99,
|
|
113
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
114
|
+
siteSettings: [],
|
|
115
|
+
});
|
|
116
|
+
expect(result.valid).toBe(false);
|
|
117
|
+
expect(result.errors.some(e => e.includes('version'))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects siteSettings row missing key', () => {
|
|
121
|
+
const result = validateImportPayload({
|
|
122
|
+
version: 1,
|
|
123
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
124
|
+
siteSettings: [{ value: 'v', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
125
|
+
});
|
|
126
|
+
expect(result.valid).toBe(false);
|
|
127
|
+
expect(result.errors.some(e => e.includes('siteSettings'))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects prompts row missing id', () => {
|
|
131
|
+
const result = validateImportPayload({
|
|
132
|
+
version: 1,
|
|
133
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
134
|
+
prompts: [{ name: 'n', category: 'c', promptText: 't', updatedAt: '2026-03-24T12:00:00.000Z' }],
|
|
135
|
+
});
|
|
136
|
+
expect(result.valid).toBe(false);
|
|
137
|
+
expect(result.errors.some(e => e.includes('prompts'))).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('accepts empty arrays (valid but no-op)', () => {
|
|
141
|
+
const result = validateImportPayload({
|
|
142
|
+
version: 1,
|
|
143
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
144
|
+
siteSettings: [],
|
|
145
|
+
prompts: [],
|
|
146
|
+
});
|
|
147
|
+
expect(result.valid).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects non-array siteSettings', () => {
|
|
151
|
+
const result = validateImportPayload({
|
|
152
|
+
version: 1,
|
|
153
|
+
exportedAt: '2026-03-24T12:00:00.000Z',
|
|
154
|
+
siteSettings: 'not an array',
|
|
155
|
+
});
|
|
156
|
+
expect(result.valid).toBe(false);
|
|
157
|
+
expect(result.errors.some(e => e.includes('siteSettings'))).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
163
|
+
|
|
164
|
+
Run: `npx vitest run src/services/DbTransferService.test.ts`
|
|
165
|
+
Expected: FAIL — old `DbTransferPayload` has `tables` property, not flat `siteSettings`/`prompts`
|
|
166
|
+
|
|
167
|
+
- [ ] **Step 3: Rewrite DbTransferService**
|
|
168
|
+
|
|
169
|
+
Replace `src/services/DbTransferService.ts` with:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// @ts-ignore - resolved by Astro build pipeline
|
|
173
|
+
import { db, SiteSettings, Prompts, eq } from 'astro:db';
|
|
174
|
+
|
|
175
|
+
export interface SiteSettingRow {
|
|
176
|
+
key: string;
|
|
177
|
+
value: string;
|
|
178
|
+
updatedAt: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface PromptRow {
|
|
182
|
+
id: string;
|
|
183
|
+
name: string;
|
|
184
|
+
category: string;
|
|
185
|
+
promptText: string;
|
|
186
|
+
updatedAt: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface DbTransferPayload {
|
|
190
|
+
version: number;
|
|
191
|
+
exportedAt: string;
|
|
192
|
+
siteSettings?: SiteSettingRow[];
|
|
193
|
+
prompts?: PromptRow[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ValidationResult {
|
|
197
|
+
valid: boolean;
|
|
198
|
+
errors: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const SUPPORTED_VERSIONS = [1];
|
|
202
|
+
|
|
203
|
+
export function validateImportPayload(payload: unknown): ValidationResult {
|
|
204
|
+
const errors: string[] = [];
|
|
205
|
+
const data = payload as Record<string, unknown>;
|
|
206
|
+
|
|
207
|
+
if (!data || typeof data !== 'object') {
|
|
208
|
+
return { valid: false, errors: ['Payload must be a JSON object'] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (typeof data.version !== 'number' || !SUPPORTED_VERSIONS.includes(data.version)) {
|
|
212
|
+
errors.push(`Missing or unsupported "version" field (supported: ${SUPPORTED_VERSIONS.join(', ')})`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const hasSiteSettings = 'siteSettings' in data;
|
|
216
|
+
const hasPrompts = 'prompts' in data;
|
|
217
|
+
|
|
218
|
+
if (!hasSiteSettings && !hasPrompts) {
|
|
219
|
+
errors.push('Payload must contain at least one of "siteSettings" or "prompts"');
|
|
220
|
+
return { valid: false, errors };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (hasSiteSettings) {
|
|
224
|
+
if (!Array.isArray(data.siteSettings)) {
|
|
225
|
+
errors.push('"siteSettings" must be an array');
|
|
226
|
+
} else {
|
|
227
|
+
for (let i = 0; i < data.siteSettings.length; i++) {
|
|
228
|
+
const row = data.siteSettings[i] as Record<string, unknown>;
|
|
229
|
+
if (!row || typeof row.key !== 'string' || typeof row.value !== 'string') {
|
|
230
|
+
errors.push(`siteSettings[${i}]: missing required fields "key" and "value"`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (hasPrompts) {
|
|
237
|
+
if (!Array.isArray(data.prompts)) {
|
|
238
|
+
errors.push('"prompts" must be an array');
|
|
239
|
+
} else {
|
|
240
|
+
for (let i = 0; i < data.prompts.length; i++) {
|
|
241
|
+
const row = data.prompts[i] as Record<string, unknown>;
|
|
242
|
+
if (!row || typeof row.id !== 'string' || typeof row.name !== 'string' ||
|
|
243
|
+
typeof row.category !== 'string' || typeof row.promptText !== 'string') {
|
|
244
|
+
errors.push(`prompts[${i}]: missing required fields "id", "name", "category", "promptText"`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { valid: errors.length === 0, errors };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export class DbTransferService {
|
|
254
|
+
async exportAll(): Promise<DbTransferPayload> {
|
|
255
|
+
const [siteSettings, prompts] = await Promise.all([
|
|
256
|
+
db.select().from(SiteSettings),
|
|
257
|
+
db.select().from(Prompts),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
version: 1,
|
|
262
|
+
exportedAt: new Date().toISOString(),
|
|
263
|
+
siteSettings: siteSettings.map((row: any) => ({
|
|
264
|
+
key: row.key,
|
|
265
|
+
value: row.value,
|
|
266
|
+
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
|
267
|
+
})),
|
|
268
|
+
prompts: prompts.map((row: any) => ({
|
|
269
|
+
id: row.id,
|
|
270
|
+
name: row.name,
|
|
271
|
+
category: row.category,
|
|
272
|
+
promptText: row.promptText,
|
|
273
|
+
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
|
274
|
+
})),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Merge imported data into the live database using upsert semantics.
|
|
280
|
+
* Only sections present in the payload are touched. Existing data not
|
|
281
|
+
* referenced in the payload is left untouched.
|
|
282
|
+
* Uses the same select-then-update-or-insert pattern as PromptService.updateSetting.
|
|
283
|
+
*/
|
|
284
|
+
async importAll(payload: DbTransferPayload): Promise<{ imported: Record<string, number> }> {
|
|
285
|
+
const counts: Record<string, number> = {};
|
|
286
|
+
|
|
287
|
+
if (payload.siteSettings) {
|
|
288
|
+
for (const row of payload.siteSettings) {
|
|
289
|
+
const existing = await db.select().from(SiteSettings)
|
|
290
|
+
.where(eq(SiteSettings.key, row.key)).get();
|
|
291
|
+
if (existing) {
|
|
292
|
+
await db.update(SiteSettings)
|
|
293
|
+
.set({ value: row.value, updatedAt: new Date(row.updatedAt) })
|
|
294
|
+
.where(eq(SiteSettings.key, row.key));
|
|
295
|
+
} else {
|
|
296
|
+
await db.insert(SiteSettings).values({
|
|
297
|
+
key: row.key,
|
|
298
|
+
value: row.value,
|
|
299
|
+
updatedAt: new Date(row.updatedAt),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
counts.siteSettings = payload.siteSettings.length;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (payload.prompts) {
|
|
307
|
+
for (const row of payload.prompts) {
|
|
308
|
+
const existing = await db.select().from(Prompts)
|
|
309
|
+
.where(eq(Prompts.id, row.id)).get();
|
|
310
|
+
if (existing) {
|
|
311
|
+
await db.update(Prompts)
|
|
312
|
+
.set({
|
|
313
|
+
name: row.name,
|
|
314
|
+
category: row.category,
|
|
315
|
+
promptText: row.promptText,
|
|
316
|
+
updatedAt: new Date(row.updatedAt),
|
|
317
|
+
})
|
|
318
|
+
.where(eq(Prompts.id, row.id));
|
|
319
|
+
} else {
|
|
320
|
+
await db.insert(Prompts).values({
|
|
321
|
+
id: row.id,
|
|
322
|
+
name: row.name,
|
|
323
|
+
category: row.category,
|
|
324
|
+
promptText: row.promptText,
|
|
325
|
+
updatedAt: new Date(row.updatedAt),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
counts.prompts = payload.prompts.length;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { imported: counts };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
338
|
+
|
|
339
|
+
Run: `npx vitest run src/services/DbTransferService.test.ts`
|
|
340
|
+
Expected: PASS — all 10 tests green
|
|
341
|
+
|
|
342
|
+
- [ ] **Step 5: Commit**
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
git add src/services/DbTransferService.ts src/services/DbTransferService.test.ts
|
|
346
|
+
git commit -m "refactor: settings-focused export/import with upsert and partial import"
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
### Task 2: Fix Stale Upload Test and Clean Up Dead Code
|
|
352
|
+
|
|
353
|
+
**Files:**
|
|
354
|
+
- Rewrite: `src/api/db/upload.test.ts`
|
|
355
|
+
- Delete: `src/utils/dbUploadUtils.ts`
|
|
356
|
+
- Delete: `src/utils/dbUploadUtils.test.ts`
|
|
357
|
+
- Modify: `src/utils/index.ts:8-11` — remove dbUploadUtils exports
|
|
358
|
+
- Modify: `src/services/index.ts:28-36` — remove `ScheduledPostRow`
|
|
359
|
+
|
|
360
|
+
- [ ] **Step 1: Replace the upload test**
|
|
361
|
+
|
|
362
|
+
Replace `src/api/db/upload.test.ts` with:
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { describe, it, expect } from 'vitest';
|
|
366
|
+
|
|
367
|
+
describe('DB Upload API (deprecated)', () => {
|
|
368
|
+
it('returns 410 Gone with migration instructions', async () => {
|
|
369
|
+
const { POST } = await import('./upload.js');
|
|
370
|
+
|
|
371
|
+
const response = await POST({ request: new Request('http://localhost/api/db/upload', { method: 'POST' }) } as any);
|
|
372
|
+
const data = await response.json();
|
|
373
|
+
|
|
374
|
+
expect(response.status).toBe(410);
|
|
375
|
+
expect(data.error).toContain('/api/db/import');
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- [ ] **Step 2: Delete dead dbUploadUtils files**
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
rm src/utils/dbUploadUtils.ts src/utils/dbUploadUtils.test.ts
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
- [ ] **Step 3: Remove dbUploadUtils exports from barrel**
|
|
387
|
+
|
|
388
|
+
In `src/utils/index.ts`, remove:
|
|
389
|
+
```typescript
|
|
390
|
+
export {
|
|
391
|
+
validateSqliteHeader,
|
|
392
|
+
validateFileSize,
|
|
393
|
+
} from "./dbUploadUtils.js";
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The file should become:
|
|
397
|
+
```typescript
|
|
398
|
+
export { renderMarkdown } from "./markdown.js";
|
|
399
|
+
export {
|
|
400
|
+
sanitizeMarkdownHtml,
|
|
401
|
+
escapeJsonLd,
|
|
402
|
+
escapeHtml,
|
|
403
|
+
} from "./sanitize.js";
|
|
404
|
+
export { getEnvVariable } from "./envUtils.js";
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 4: Update services barrel — remove ScheduledPostRow**
|
|
408
|
+
|
|
409
|
+
In `src/services/index.ts`, replace the DbTransferService export block with:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
export {
|
|
413
|
+
DbTransferService,
|
|
414
|
+
validateImportPayload,
|
|
415
|
+
type DbTransferPayload,
|
|
416
|
+
type ValidationResult,
|
|
417
|
+
type SiteSettingRow,
|
|
418
|
+
type PromptRow,
|
|
419
|
+
} from './DbTransferService';
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
- [ ] **Step 5: Run upload test**
|
|
423
|
+
|
|
424
|
+
Run: `npx vitest run src/api/db/upload.test.ts`
|
|
425
|
+
Expected: PASS — 1 test green
|
|
426
|
+
|
|
427
|
+
- [ ] **Step 6: Commit**
|
|
428
|
+
|
|
429
|
+
```bash
|
|
430
|
+
git add -A
|
|
431
|
+
git commit -m "chore: fix stale upload test, remove dead dbUploadUtils code"
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
### Task 3: Run Full Test Suite and Verify
|
|
437
|
+
|
|
438
|
+
**Files:**
|
|
439
|
+
- No new files — verification only
|
|
440
|
+
|
|
441
|
+
- [ ] **Step 1: Run all tests**
|
|
442
|
+
|
|
443
|
+
Run: `npx vitest run`
|
|
444
|
+
Expected: ALL tests pass, zero failures
|
|
445
|
+
|
|
446
|
+
- [ ] **Step 2: Check for stale references**
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
grep -r "ScheduledPostRow" src/ --include="*.ts" | grep -v node_modules
|
|
450
|
+
grep -r "dbUploadUtils" src/ --include="*.ts" | grep -v node_modules
|
|
451
|
+
grep -r "tables\." src/services/DbTransferService.ts
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Expected: No results for any of these (all old references removed)
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 3: Commit any fixes if needed**
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
git add -A
|
|
460
|
+
git commit -m "fix: adjustments from verification"
|
|
461
|
+
```
|
package/package.json
CHANGED
|
@@ -1,173 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import * as fs from 'fs/promises';
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
vi.mock('astro:db', () => ({
|
|
8
|
-
db: {
|
|
9
|
-
$client: {
|
|
10
|
-
close: mockClose,
|
|
11
|
-
reconnect: mockReconnect,
|
|
12
|
-
},
|
|
13
|
-
},
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
vi.mock('fs/promises', () => ({
|
|
17
|
-
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
18
|
-
copyFile: vi.fn().mockResolvedValue(undefined),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
// SQLite file header
|
|
22
|
-
const SQLITE_HEADER = Buffer.from('SQLite format 3\0');
|
|
23
|
-
|
|
24
|
-
function createSqliteBuffer(size = 4096): Buffer {
|
|
25
|
-
const buf = Buffer.alloc(size);
|
|
26
|
-
SQLITE_HEADER.copy(buf);
|
|
27
|
-
return buf;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function createFormDataRequest(file: Buffer, fieldName = 'database'): Request {
|
|
31
|
-
const formData = new FormData();
|
|
32
|
-
formData.append(fieldName, new Blob([file]), 'test.db');
|
|
33
|
-
return new Request('http://localhost/api/db/upload', {
|
|
34
|
-
method: 'POST',
|
|
35
|
-
body: formData,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function createOctetStreamRequest(file: Buffer): Request {
|
|
40
|
-
return new Request('http://localhost/api/db/upload', {
|
|
41
|
-
method: 'POST',
|
|
42
|
-
headers: { 'Content-Type': 'application/octet-stream' },
|
|
43
|
-
body: file,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe('DB Upload API', () => {
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
vi.clearAllMocks();
|
|
50
|
-
process.env.ASTRO_DATABASE_FILE = '.astro/content.db';
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('accepts a valid SQLite file via multipart/form-data', async () => {
|
|
54
|
-
const { POST } = await import('./upload.js');
|
|
55
|
-
const request = createFormDataRequest(createSqliteBuffer());
|
|
56
|
-
|
|
57
|
-
const response = await POST({ request } as any);
|
|
58
|
-
const data = await response.json();
|
|
59
|
-
|
|
60
|
-
expect(response.status).toBe(200);
|
|
61
|
-
expect(data.success).toBe(true);
|
|
62
|
-
expect(data.size).toBeGreaterThan(0);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('accepts a valid SQLite file via application/octet-stream', async () => {
|
|
66
|
-
const { POST } = await import('./upload.js');
|
|
67
|
-
const request = createOctetStreamRequest(createSqliteBuffer());
|
|
68
|
-
|
|
69
|
-
const response = await POST({ request } as any);
|
|
70
|
-
const data = await response.json();
|
|
71
|
-
|
|
72
|
-
expect(response.status).toBe(200);
|
|
73
|
-
expect(data.success).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('rejects non-SQLite files', async () => {
|
|
77
|
-
const { POST } = await import('./upload.js');
|
|
78
|
-
const badFile = Buffer.from('not a sqlite file');
|
|
79
|
-
const request = createFormDataRequest(badFile);
|
|
80
|
-
|
|
81
|
-
const response = await POST({ request } as any);
|
|
82
|
-
const data = await response.json();
|
|
83
|
-
|
|
84
|
-
expect(response.status).toBe(400);
|
|
85
|
-
expect(data.error).toContain('not a SQLite database');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('rejects missing database field', async () => {
|
|
89
|
-
const { POST } = await import('./upload.js');
|
|
90
|
-
const formData = new FormData();
|
|
91
|
-
formData.append('wrongfield', new Blob([createSqliteBuffer()]), 'test.db');
|
|
92
|
-
const request = new Request('http://localhost/api/db/upload', {
|
|
93
|
-
method: 'POST',
|
|
94
|
-
body: formData,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const response = await POST({ request } as any);
|
|
98
|
-
const data = await response.json();
|
|
99
|
-
|
|
100
|
-
expect(response.status).toBe(400);
|
|
101
|
-
expect(data.error).toContain('No database file');
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('rejects invalid content type', async () => {
|
|
105
|
-
const { POST } = await import('./upload.js');
|
|
106
|
-
const request = new Request('http://localhost/api/db/upload', {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: { 'Content-Type': 'text/plain' },
|
|
109
|
-
body: 'hello',
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
const response = await POST({ request } as any);
|
|
113
|
-
const data = await response.json();
|
|
114
|
-
|
|
115
|
-
expect(response.status).toBe(400);
|
|
116
|
-
expect(data.error).toContain('Invalid content type');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('creates a backup before writing', async () => {
|
|
120
|
-
const { POST } = await import('./upload.js');
|
|
121
|
-
const request = createFormDataRequest(createSqliteBuffer());
|
|
122
|
-
|
|
123
|
-
await POST({ request } as any);
|
|
124
|
-
|
|
125
|
-
expect(fs.copyFile).toHaveBeenCalled();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('reconnects the DB client after successful upload', async () => {
|
|
129
|
-
const { POST } = await import('./upload.js');
|
|
130
|
-
const request = createFormDataRequest(createSqliteBuffer());
|
|
131
|
-
|
|
132
|
-
await POST({ request } as any);
|
|
133
|
-
|
|
134
|
-
expect(mockClose).toHaveBeenCalledOnce();
|
|
135
|
-
expect(mockReconnect).toHaveBeenCalledOnce();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('rejects oversized file via multipart/form-data', async () => {
|
|
139
|
-
const { POST } = await import('./upload.js');
|
|
140
|
-
const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
|
|
141
|
-
const request = createFormDataRequest(oversized);
|
|
142
|
-
|
|
143
|
-
const response = await POST({ request } as any);
|
|
144
|
-
const data = await response.json();
|
|
145
|
-
|
|
146
|
-
expect(response.status).toBe(413);
|
|
147
|
-
expect(data.error).toContain('too large');
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('rejects oversized file via application/octet-stream', async () => {
|
|
151
|
-
const { POST } = await import('./upload.js');
|
|
152
|
-
const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
|
|
153
|
-
const request = createOctetStreamRequest(oversized);
|
|
154
|
-
|
|
155
|
-
const response = await POST({ request } as any);
|
|
156
|
-
const data = await response.json();
|
|
157
|
-
|
|
158
|
-
expect(response.status).toBe(413);
|
|
159
|
-
expect(data.error).toContain('too large');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('still succeeds if reconnect is not available', async () => {
|
|
3
|
+
describe('DB Upload API (deprecated)', () => {
|
|
4
|
+
it('returns 410 Gone with migration instructions', async () => {
|
|
163
5
|
const { POST } = await import('./upload.js');
|
|
164
|
-
mockClose.mockImplementation(() => { throw new Error('not available'); });
|
|
165
6
|
|
|
166
|
-
const
|
|
167
|
-
const response = await POST({ request } as any);
|
|
7
|
+
const response = await POST({ request: new Request('http://localhost/api/db/upload', { method: 'POST' }) } as any);
|
|
168
8
|
const data = await response.json();
|
|
169
9
|
|
|
170
|
-
expect(response.status).toBe(
|
|
171
|
-
expect(data.
|
|
10
|
+
expect(response.status).toBe(410);
|
|
11
|
+
expect(data.error).toContain('/api/db/import');
|
|
172
12
|
});
|
|
173
13
|
});
|