The CRUD API is what makes building i18n tools easy. Instead of parsing and writing translation files in different formats (JSON, XLIFF, YAML, etc.), you query and modify messages directly. Plugins handle the file format conversion at the boundary.

Inlang uses Kysely for type-safe database queries. Access it via project.db.

import { loadProjectFromDirectory } from "@inlang/sdk";
import fs from "node:fs";

const project = await loadProjectFromDirectory({
  path: "./project.inlang",
  fs: fs,
});

// project.db is a Kysely instance
const bundles = await project.db.selectFrom("bundle").selectAll().execute();

Saving changes: CRUD operations update the in-memory .inlang database. To save a packed .inlang file, call project.toBlob(). To save an unpacked project.inlang/ directory, saveProjectToDirectory() needs an import/export plugin; without one, bundles, messages, and variants have no file path to export to.

Create

Insert a bundle

await project.db
  .insertInto("bundle")
  .values({
    id: "greeting",
    declarations: [],
  })
  .execute();

Insert a message

await project.db
  .insertInto("message")
  .values({
    id: crypto.randomUUID(),
    bundleId: "greeting",
    locale: "en",
    selectors: [],
  })
  .execute();

Insert a variant

await project.db
  .insertInto("variant")
  .values({
    id: crypto.randomUUID(),
    messageId: messageId,
    matches: [],
    pattern: [{ type: "text", value: "Hello world!" }],
  })
  .execute();

Insert nested (bundle + messages + variants)

import { insertBundleNested } from "@inlang/sdk";

await insertBundleNested(project.db, {
  id: "greeting",
  declarations: [],
  messages: [
    {
      id: "greeting_en",
      bundleId: "greeting",
      locale: "en",
      selectors: [],
      variants: [
        {
          id: crypto.randomUUID(),
          messageId: "greeting_en",
          matches: [],
          pattern: [{ type: "text", value: "Hello!" }],
        },
      ],
    },
  ],
});

Read

Get all bundles

const bundles = await project.db.selectFrom("bundle").selectAll().execute();

Get bundle by ID

const bundle = await project.db
  .selectFrom("bundle")
  .selectAll()
  .where("id", "=", "greeting")
  .executeTakeFirst();

Get messages by locale

const messages = await project.db
  .selectFrom("message")
  .selectAll()
  .where("locale", "=", "en")
  .execute();

Get messages for a bundle

const messages = await project.db
  .selectFrom("message")
  .selectAll()
  .where("bundleId", "=", "greeting")
  .execute();

Get variants for a message

const variants = await project.db
  .selectFrom("variant")
  .selectAll()
  .where("messageId", "=", messageId)
  .execute();

Get nested (bundle with messages and variants)

import { selectBundleNested } from "@inlang/sdk";

const bundle = await selectBundleNested(project.db)
  .where("bundle.id", "=", "greeting")
  .executeTakeFirst();

// Returns:
// {
//   id: "greeting",
//   declarations: [],
//   messages: [
//     {
//       id: "...",
//       locale: "en",
//       variants: [{ id: "...", pattern: [...] }]
//     }
//   ]
// }

Join bundles and messages

const results = await project.db
  .selectFrom("bundle")
  .leftJoin("message", "message.bundleId", "bundle.id")
  .selectAll()
  .execute();

Find missing translations

const missingGerman = await project.db
  .selectFrom("bundle")
  .where((eb) =>
    eb.not(
      eb.exists(
        eb
          .selectFrom("message")
          .where("message.bundleId", "=", eb.ref("bundle.id"))
          .where("message.locale", "=", "de"),
      ),
    ),
  )
  .selectAll()
  .execute();

Update

Update a bundle

await project.db
  .updateTable("bundle")
  .set({
    declarations: [{ type: "input-variable", name: "count" }],
  })
  .where("id", "=", "greeting")
  .execute();

Update a variant's text

await project.db
  .updateTable("variant")
  .set({
    pattern: [{ type: "text", value: "Updated text" }],
  })
  .where("id", "=", variantId)
  .execute();

Update nested

import { updateBundleNested } from "@inlang/sdk";

await updateBundleNested(project.db, {
  id: "greeting",
  declarations: [],
  messages: [
    {
      id: messageId,
      locale: "en",
      selectors: [],
      variants: [
        {
          id: variantId,
          matches: [],
          pattern: [{ type: "text", value: "Updated!" }],
        },
      ],
    },
  ],
});

Delete

Delete a bundle

await project.db.deleteFrom("bundle").where("id", "=", "greeting").execute();

// Cascades: all messages and variants are deleted

Delete a message

await project.db.deleteFrom("message").where("id", "=", messageId).execute();

// Cascades: all variants are deleted

Delete a variant

await project.db.deleteFrom("variant").where("id", "=", variantId).execute();

Upsert

Insert or update based on whether the record exists.

Upsert a bundle

await project.db
  .insertInto("bundle")
  .values({
    id: "greeting",
    declarations: [],
  })
  .onConflict((oc) =>
    oc.column("id").doUpdateSet({
      declarations: [],
    }),
  )
  .execute();

Upsert nested

import { upsertBundleNested } from "@inlang/sdk";

await upsertBundleNested(project.db, {
  id: "greeting",
  declarations: [],
  messages: [...]
});

Next steps