# MIGRATION

How to move your site from another CMS into the Generic CMS Module. Operator-focused.

## When to migrate

You're a fit if your current CMS is:

- **Firestore / Firebase** — you want single-admin, local-first
- **Sanity / Contentful** — you want to ditch the SaaS bill
- **Airtable as a CMS** — you want a proper schema + auth
- **A custom CMS** — you want a FWV-compliant replacement
- **Markdown files in a repo** — you want operator-editable content

## Step 1 — Inventory your content types

For each content type in your current CMS, list:

1. Name (e.g. `book`, `lesson`, `author`)
2. Fields (name + type + required + constraints)
3. Storage key (the path in your current system)
4. Sample records (3-5 of each, with realistic data)

Map your fields to the FWV schema DSL. The 13 field types are: `string`, `textarea`, `markdown`, `url`, `integer`, `number`, `boolean`, `array`, `enum`, `references`, `image`, `datetime`, `color`. See `SCHEMAS.md` for the full list.

Common mappings:

| Source field | CMS field type | Notes |
|---|---|---|
| `title` (short text) | `string` | maxLength 200 |
| `description` (long text) | `textarea` | maxLength 1000 |
| `body` (rich text) | `markdown` | sanitized on render |
| `imageUrl` | `image` | URL allowlist enforced |
| `externalUrl` | `url` | URL allowlist enforced |
| `count` (whole number) | `integer` | min, max |
| `price` (decimal) | `number` | min, max |
| `isPublished` | `boolean` | default false |
| `tags` (list of strings) | `array` with `itemType: 'string'` | |
| `category` (one of N) | `enum` | enum: [...] |
| `bookId` (FK) | `references` | references: { storageKey } |
| `publishedAt` (ISO date) | `datetime` | ISO 8601 |
| `accentColor` (hex) | `color` | hex format |

## Step 2 — Define the schemas

For each content type, create `cms/schemas/{name}.js`:

```js
import { Schema } from '../schema.js';

export const bookSchema = new Schema({
  id: 'book',
  label: 'Book',
  storageKey: 'cms:admin:book',
  fields: {
    id: { type: 'string', required: true, pattern: '^[a-z0-9_]+$' },
    title: { type: 'string', required: true, maxLength: 200 },
    author: { type: 'string', required: false, maxLength: 200 },
    blurb: { type: 'textarea', required: false, maxLength: 1000 },
    published: { type: 'boolean', required: false, default: false },
  },
  defaults: { published: false },
  identityKey: 'id', // pressure-test A.L4
});
```

Ship a starter `schemas/book.js` and `schemas/siteSettings.js` are already there.

## Step 3 — Export your data

Each source CMS has its own export format. The destination format is a `BackupEnvelope`:

```json
{
  "formatVersion": 1,
  "exportedAt": 1234567890,
  "exportedBy": "operator",
  "sourcePrefix": "cms",
  "schemas": { "book": { "version": 1, "fields": [...] } },
  "data": {
    "cms:admin:book": [ { "id": "b1", "title": "..." }, ... ]
  }
}
```

**Auth is never in the envelope.** Don't include `{prefix}:admin:auth` in your export — the parser will reject it.

### From Sanity / Contentful

```js
// sanity-export.js
import { exportBackup } from './cms/index.js';
import { registerSchemas } from './cms/index.js';
import { bookSchema } from './cms/schemas/book.js';

registerSchemas({ book: bookSchema });

// Pull from Sanity API, write to storage key
import { getStorageAdapter } from './cms/index.js';
const adapter = await getStorageAdapter();
const sanityBooks = await fetch('https://your-project.sanity.io/...').then(r => r.json());
await adapter.set('cms:admin:book', sanityBooks.map(toBookRecord));

const env = await exportBackup({ exportedBy: 'migration' });
console.log(JSON.stringify(env, null, 2));
```

### From Firestore

```js
import { getStorageAdapter } from './cms/index.js';
const adapter = await getStorageAdapter();
const snap = await firebase.firestore().collection('books').get();
const books = snap.docs.map(d => ({ id: d.id, ...d.data() }));
await adapter.set('cms:admin:book', books);
```

## Step 4 — Test the import

Use the new site's preview flow:

```js
import { parseBackupJson, previewImport, applyImport } from './cms/index.js';

const env = parseBackupJson(fs.readFileSync('backup.json', 'utf8'));
const diff = await previewImport(env);
console.log(diff);
// → { wouldAdd: 10, wouldUpdate: 0, wouldDelete: 0, schemaWarnings: [], keyBreakdown: [...] }

// If the diff looks right, apply
const result = await applyImport(env, { mode: 'merge' });
console.log(result);
```

`mode: 'merge'` (default) keeps records not in the envelope. `mode: 'replace'` wipes the key first.

## Step 5 — Verify in the admin

Open `/admin` on the new site. Log in. Walk through:

- [ ] Each content type shows up in the nav
- [ ] Each record is editable
- [ ] Validation rejects malformed input
- [ ] Activity log shows the import
- [ ] Search finds expected records
- [ ] Backup export → re-import round-trips cleanly

## Step 6 — Deploy

1. Add a `_redirects` file (Cloudflare Pages) or equivalent:

   ```
   /admin      /admin.html  200
   /admin/*    /admin.html  200
   ```

2. Ship your schemas + admin shell + the rest of the site.

3. Run `setupAdmin` once on the deployed site. Save the 12-word recovery phrase offline.

## Limitations (v1.0)

- **Single admin only.** No roles, no team. Each device has its own admin.
- **Local-only storage.** v2.0 adds IndexedDB and remote adapters.
- **No real-time updates.** Manual reload.
- **No workflow approvals.** Direct edit, direct publish.
- **Single-language content.** No `lang` field. v2.0.
- **No multi-user audit log sharing.** Activity log is per-device.

For full GDPR posture, retention policy, and i18n limitations, see
[`docs/PRIVACY-AND-I18N.md`](./docs/PRIVACY-AND-I18N.md).

## See also

- `docs/PRIVACY-AND-I18N.md` — GDPR posture, retention, i18n limitation
- `USAGE.md` — adding content types, editors, bulk import
- `SCHEMAS.md` — built-in + example schemas
- `SECURITY.md` — what to check before going live
- `cms-doctrine.md` — module rules
- `docs/FAQ.md` — common questions
- `docs/TROUBLESHOOTING.md` — common errors + fixes
