Migrations
Kide is code-first: an import is a script that reads source data and writes through the Local API:
node --import tsx scripts/import.tsThis works for Node and Cloudflare. Cloudflare scripts use the same local D1 and R2 as astro dev, so you can inspect the import before deploying.
Import script
Section titled “Import script”import { cms } from "@/cms/.generated/api";import { closeDb } from "@kidecms/core";
const data = await loadYourSource(); // CSV, JSON, an API, a SQL dump…
for (const item of data) { await cms.posts.create({ title: item.title, slug: item.slug, });}
await closeDb(); // release the DB / local proxy so the script exitsImporting the generated API initializes the runtime. Always finish with await closeDb().
Writing documents
Section titled “Writing documents”| Method | Use it for |
|---|---|
create(data) |
Insert one document |
createMany(items) |
Insert many (runs the full per-doc path — hooks, versions, search) |
upsert(data) |
Insert, or update if data._id already exists |
update(id, data) |
Patch an existing document |
upsertTranslation(id, locale, data) |
Add/replace a non-default-locale translation |
Deterministic IDs and status
Section titled “Deterministic IDs and status”create() and upsert() accept a caller-supplied _id, plus _status for draft-enabled collections. A source-derived ID makes relationships simple and imports idempotent:
await cms.posts.upsert({ _id: `wp-${row.ID}`, // stable id derived from the source _status: row.status === "publish" ? "published" : "draft", title: row.title, slug: row.slug,});upsert updates an existing ID, so you can re-run without wiping first.
Wipe and re-import
Section titled “Wipe and re-import”To start clean instead, deleteMany(filter?, ctx) removes matching documents plus their translation, version, and search rows — an empty filter clears the whole collection:
await cms.posts.deleteMany({}, { _system: true });Combined with deterministic _ids, re-runs replace content rather than duplicate it.
Converting HTML → rich text
Section titled “Converting HTML → rich text”htmlToRichText() turns source HTML into the rich text AST. It tolerates malformed markup, unwraps unsupported tags, and falls back to plain text.
import { htmlToRichText, createRichTextFromPlainText } from "@kidecms/core";
await cms.posts.create({ title: row.title, body: htmlToRichText(row.html), // <p>, <h1–6>, <ul>/<ol>, <blockquote>, <img>, <strong>, <em>, <a>… summary: createRichTextFromPlainText(row.plainText), // for non-HTML sources});It supports paragraphs, headings, lists, blockquotes, images, and inline bold, italic, and link.
References
Section titled “References”- Relations store the related document’s
_idas a string (hasMany→ an array of ids). With deterministic ids you can write them directly:author: \wp-${row.author_id}``. - Image fields store an asset’s
storagePath(e.g./uploads/abc123.jpg), whichassets.upload()returns.
Two-pass imports
Section titled “Two-pass imports”For parents, related content, or in-body links, create every record first and wire references in a second pass. Deterministic IDs remove the need for a lookup map:
// Pass 1 — create every document (no relations yet)for (const row of rows) { await cms.pages.upsert({ _id: `wp-${row.ID}`, title: row.title, slug: row.slug });}
// Pass 2 — set relations now that every target existsfor (const row of rows.filter((r) => r.parent)) { await cms.pages.update(`wp-${row.ID}`, { parent: `wp-${row.parent}` });}Upload binaries with assets.upload() and store its storagePath in image fields:
import { assets } from "@kidecms/core";import { readFile } from "node:fs/promises";
const bytes = await readFile(localPath);const asset = await assets.upload(new File([bytes], "photo.jpg", { type: "image/jpeg" }), { alt: "A descriptive alt text", dedupe: true,});
await cms.posts.update(`wp-${row.ID}`, { featuredImage: asset.storagePath });dedupe: true content-hashes the bytes and returns the existing asset on a re-run, so
repeated imports don’t re-upload identical files.
On the Cloudflare target this writes to your local R2 bucket; the same images then serve
through astro dev. See Assets for the full API.
Translations
Section titled “Translations”Create the document in your default locale, then add other locales with upsertTranslation,
passing only the translatable fields:
const id = `wp-${group.en.ID}`;await cms.posts.create({ _id: id, title: group.en.title, slug: group.en.slug, body: htmlToRichText(group.en.html) });await cms.posts.upsertTranslation(id, "fi", { title: group.fi.title, slug: group.fi.slug, body: htmlToRichText(group.fi.html),});Default-locale values live on the main row; each upsertTranslation replaces one locale’s overrides. Read with { locale } to overlay them.
- Watch slug uniqueness.
fields.slugdefaults tounique: true, and uniqueness is global — not scoped by parent or locale. Imports of hierarchical or bilingual content that reuse slugs usually needfields.slug({ unique: false }). - Quiet the logs. Bulk writes emit a structured audit line per document. Set
CMS_LOG_LEVEL=warn(orsilent) to silence them:CMS_LOG_LEVEL=warn node --import tsx scripts/import.ts. - Go sequential under contention. If
astro devis running against the same local database, heavy parallel writes can contend on SQLite/D1.createManyruns sequentially for this reason; preferawaitin a loop overPromise.allfor large batches. - Local first, then production. Imports run against your local D1/R2. To populate a
deployed site, push the schema (
wrangler d1 migrations apply <db> --remote) and run the import against the remote bindings, or re-export and re-import there. See Deploy. - Exclude what you shouldn’t import. Decide up front which source data is in scope (e.g.
editorial content only, excluding personal data) and skip it in the script — the importer is
ordinary code, so filtering is just an
if.