Skip to content

Migrations

Kide is code-first: an import is a script that reads source data and writes through the Local API:

Terminal window
node --import tsx scripts/import.ts

This 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.

scripts/import.ts
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 exits

Importing the generated API initializes the runtime. Always finish with await closeDb().

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

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.

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.

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.

  • Relations store the related document’s _id as 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), which assets.upload() returns.

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 exists
for (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.

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.slug defaults to unique: true, and uniqueness is global — not scoped by parent or locale. Imports of hierarchical or bilingual content that reuse slugs usually need fields.slug({ unique: false }).
  • Quiet the logs. Bulk writes emit a structured audit line per document. Set CMS_LOG_LEVEL=warn (or silent) to silence them: CMS_LOG_LEVEL=warn node --import tsx scripts/import.ts.
  • Go sequential under contention. If astro dev is running against the same local database, heavy parallel writes can contend on SQLite/D1. createMany runs sequentially for this reason; prefer await in a loop over Promise.all for 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.