Skip to content

Database

By default, the module uses an SQLite database to store data. However, it can be configured to use PostgreSQL, or Cloudflare D1.

This is the default database type. The DB file is located in a db folder under the current working directory (process.cwd()) by default. To override this, please define a custom path:

index.js
configuration.dbFolder = import.meta.dirname + '/custom_folder';
index.js
configuration.postgreConfig = {
host: 'localhost',
user: 'postgres',
password: 'password',
database: 'test'
};

To migrate from SQLite to PostgreSQL, add the migrateToPostgreFromSQLite option to your configuration object:

index.js
configuration.migrateToPostgreFromSQLite = true;

If the migration fails, check your logs for error details. To roll back to SQLite:

  • Remove the postgreConfig option
  • Set migrateToPostgreFromSQLite option to false

Cloudflare D1 is a serverless SQL database for Workers deployments. Database tables are created automatically on first request (lazy migrations).

index.js
configuration.d1Config = {
database, // D1 database binding from Cloudflare Workers environment
};

Setup: Create database with npx wrangler d1 create my-app-db, add binding to wrangler.toml, and configure as shown above.

Besides its own database, the app gets a Crowdin-hosted key-value store via crowdinApp.crowdinStorage(crowdinId). The records live on the Crowdin side and are shared across the whole installation - there is nothing to provision: no tables, no migrations, no infrastructure. It fits settings, user preferences, coordination flags, tokens, and other small values, and the data follows the installation - uninstalling the app deletes every record, and removing or renaming a module in the configuration deletes that module’s records the same way.

Storage access requires the application.storage scope and the crowdin_app authentication type (the default) - the storage endpoints accept only the application’s own token, so every other authentication type is rejected:

index.js
import crowdinModule from '@crowdin/app-project-module';
const configuration = {
// ...
scopes: [crowdinModule.Scope.APPLICATION_STORAGE],
};
const crowdinApp = crowdinModule.createApp(configuration);
const { kv } = await crowdinApp.crowdinStorage(crowdinId);

crowdinId identifies the installation: the organization domain on Crowdin Enterprise (jwtPayload.domain) or the numeric organization id on Crowdin (jwtPayload.context.organization_id) - the same value the module uses to store the installation credentials.

await kv.set('report:settings', { includeDrafts: true });
const settings = await kv.get('report:settings'); // undefined when the key does not exist
const entry = await kv.getWithMetadata('report:settings'); // value + secret, createdAt, updatedAt, expiresAt
await kv.delete('report:settings'); // idempotent

A value is any JSON value except null - up to 240 KiB serialized. Keys are 1-500 characters of letters, digits, :, ., _, and -, case-sensitive, with : as the conventional namespace separator; a key is immutable - to rename a record, delete it and create a new one. The user: and module: prefixes are reserved (see below); any other key is a plain shared record.

By default set overwrites an existing key. Because the store is shared, it also works as a cross-instance lock - pass keyPolicy: 'FAIL_IF_EXISTS' to fail instead of overwriting:

index.js
import { KeyExistsError } from '@crowdin/app-project-module';
try {
await kv.set('sync:lock', Date.now(), { keyPolicy: 'FAIL_IF_EXISTS', ttl: 300 });
} catch (error) {
if (error instanceof KeyExistsError) {
// another instance is already syncing
}
}

list returns records 25 per page by default (500 max) in a stable insertion order (oldest first); orderBy accepts key, createdAt, updatedAt, or expiresAt, each optionally followed by desc, comma-separated. withFetchAll collects every page:

const page = await kv.list({ prefix: 'report:', limit: 100 });
const freshest = await kv.list({ orderBy: 'updatedAt desc' });
const everything = await kv.withFetchAll().list();

kv.user.* mirrors the whole surface under a private user:{id}: space. On the server the app acts as the user who installed it, so kv.user.* writes to that person’s private space - no other caller can reach it, though the installer themselves can also read these records from the app’s browser surfaces. That still makes it the tightest home for backend secrets, together with secret: true (encrypts the value at rest; the flag is fixed at creation):

await kv.user.set('gh-token', token, { secret: true });

The prefix, not the flag, decides who can reach a secret: secret: true only encrypts at rest, and anyone who can read the record receives the value decrypted. A credential the app alone uses lives in kv.user.*; a credential a whole team manages - an integration token configured by managers - lives under the module: prefix of a module only that team can use (see below); a plain shared key is never the place for one.

A key under module:{moduleKey}: (the module key from your configuration) scopes the record to that module’s surface inside Crowdin: for viewers it exists only where the app runs as that module, so a restricted module’s prefix is the right scope for a team-shared secret. The app’s own calls through this module are not narrowed by module prefixes - the application token sees shared records and every module’s records, while other users’ user: records stay private even from it. Pass ttl in seconds (60 up to 31536000 - one year) to make any record expire; an expired record behaves as deleted:

await kv.set('module:reports:cache', data, { ttl: 3600 });
await kv.set('module:org-settings:cms-token', token, { secret: true });

Shared records are not private to the server: everyone who can use the app inside Crowdin can read them, and every signed-in user among them can also write - with guest-visible modules in public projects the readers include anonymous visitors. Keep anything sensitive in kv.user.* or under a restricted module’s module: prefix. The store is the kv surface of @crowdin/apps-storage - the package README documents the full semantics and limits.

The SDK provides the metadataStore to store metadata. Metadata is a key-value pair that can be used to store any data that is not directly related to the main data model. For example, you can store settings, user preferences, etc.

index.js
import crowdinModule from '@crowdin/app-project-module';
const configuration = {
// ...
};
const crowdinApp = crowdinModule.createApp(configuration);
// ...
crowdinApp.saveMetadata({ id: 'key', metadata: 'metadata', crowdinId: 'crowdinId' });
crowdinApp.getMetadata('key');

Used to save some metadata to be used in other parts of the application. It might be associated with an organization, project, etc.

  • id - string - The id of the metadata.
  • metadata - any - The metadata to be saved.
  • crowdinId - string - The organization ID or domain of the Crowdin project. This parameter is required and must be a valid Crowdin ID. If not provided, the function will throw an error. The ID can be obtained from the JWT payload jwtPayload.domain for Enterprise and jwtPayload.context.organization_id for Crowdin.

Used to get metadata.

  • id - string - The id of the metadata.

Used to delete metadata. Usually useful in the onUninstall hook.

  • id - string - The id of the metadata.

Used to get settings that users manage in the integration module.

  • clientId - string - The id of the user.

The module also allows you to define custom tables, manage migrations, and use Drizzle ORM for CRUD operations.

Under the hood, the module uses Drizzle ORM, giving you access to its full capabilities.

As a prerequisite, install these dependencies:

Terminal window
npm install drizzle-orm drizzle-kit

Create a file schema.js:

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const customTable = sqliteTable('custom_tbl', {
id: text('id').primaryKey(),
text: text('custom_text'),
number: integer('custom_number'),
});

Create a config file config.js:

export default defineConfig({
schema: './schema.js',
out: './migrations',
dialect: 'sqlite',
});

Then run:

Terminal window
npx drizzle-kit generate --config ./config.js

This command syncs your schema with migrations and creates any missing migration files.

For Cloudflare D1, declare migrations manually:

const migrations = [
{
name: 'create_custom_table',
run: async (db) => {
const sql = 'CREATE TABLE custom_tbl (id text PRIMARY KEY NOT NULL, custom_text text, custom_number integer)';
await db.$client.prepare(sql).run();
},
},
];
index.js
import * as schema from './schema.js';
configuration.customTables = {
schema,
// for SQLite or PostgreSQL
migrationFolder: './migrations',
// for D1
d1Migrations: migrations,
};

For type safety, you also need a TypeScript file types.ts with the type declaration:

import * as schema from './schema.js';
export type DB = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
index.js
import { eq } from 'drizzle-orm';
/** @type {import('./types').DB} */
// @ts-ignore
const db = crowdinApp.storage.db;
const result = await db.query.customTable.findMany({
where: eq(schema.customTable.text, 'some value'),
});
await db.insert(schema.customTable).values({
text: 'text',
number: 123,
});
await db.update(schema.customTable)
.set({ text: 'text' })
.where(eq(schema.customTable.number, 123));
await db.delete(schema.customTable).where(eq(schema.customTable.number, 123));

For more examples please check Drizzle documentation