Base de données

Chaque application dispose de sa propre base SQLite, accessible par sdk.db. La base est durable et limitée à votre application : une autre application ne peut ni la lire ni y écrire (voir le client createSdk dans la Présentation du SDK). Il n'y a rien à provisionner : la base est créée au premier appel.

API

import { createSdk } from "@bext-stack/framework/sdk";

const sdk = createSdk("<app-id>");

const posts = await sdk.db.all<{ id: number; title: string }>(
  "SELECT id, title FROM posts WHERE status = ? ORDER BY id DESC",
  ["published"],
);
const post = await sdk.db.get("SELECT * FROM posts WHERE id = ?", [42]);
const { changes, lastInsertId } = await sdk.db.exec(
  "INSERT INTO posts (title, status) VALUES (?, ?)",
  ["Bonjour", "draft"],
);
Méthode Description
all<T>(sql, params?) Exécuter une requête et retourner toutes les lignes, sous forme d'objets { colonne: valeur }.
get<T>(sql, params?) Comme all, mais ne retourne que la première ligne, ou null si aucune ne correspond.
exec(sql, params?) Exécuter une écriture (CREATE, INSERT, UPDATE, DELETE…). Retourne { changes, lastInsertId }.

changes est le nombre de lignes modifiées, lastInsertId le rowid de la dernière insertion. Appelez sdk.db côté serveur : loader, action, route.ts ou tâche (dans une tâche, le même client est disponible sous ctx.db, voir Tâches et planificateur).

En cas d'échec, chaque méthode lève une Error qui porte le message de SQLite (table inconnue, contrainte violée, erreur de syntaxe…). Une requête qui ne trouve rien n'est pas une erreur : all retourne [] et get retourne null.

Les valeurs lues arrivent avec leur type SQLite : un entier ou un réel devient un number, un texte une string, NULL devient null. Un BLOB est retourné encodé en base64.

Paramètres

Écrivez des ? dans le texte SQL et passez les valeurs dans le tableau params, dans l'ordre. Une valeur passée ainsi est liée comme une donnée : elle n'est jamais concaténée au SQL. Ne construisez pas la requête par concaténation de chaînes avec des valeurs venues de l'utilisateur.

Attention

Le client envoie chaque paramètre sous forme de chaîne :

  • un nombre devient sa représentation texte (42 devient "42") ;
  • null et undefined deviennent une chaîne vide, pas NULL ;
  • un booléen devient "true" ou "false", un objet "[object Object]".

En pratique :

  • Déclarez le type des colonnes (INTEGER, REAL, TEXT). Une colonne INTEGER convertit "42" en entier à l'écriture comme à la comparaison, donc WHERE id = ? avec [42] fonctionne. Une colonne sans type garde la chaîne telle quelle, et WHERE n = 42 ne la retrouve plus.
  • Pour une valeur nulle, écrivez NULL dans le SQL plutôt que de passer null : UPDATE posts SET published_at = NULL WHERE id = ?.
  • Stockez les booléens en 1 / 0, et les objets avec JSON.stringify dans une colonne TEXT.

Une instruction par appel

Chaque appel exécute une seule instruction SQL. Si le texte en contient plusieurs, séparées par ;, seule la première est exécutée. Créez vos tables et index avec un appel exec chacun.

Il n'y a pas de transaction qui couvre plusieurs appels : chaque appel est indépendant, et un BEGIN laissé ouvert est annulé à la fin de l'appel. Pour écrire plusieurs lignes de façon atomique, faites-le en une seule instruction :

await sdk.db.exec(
  "INSERT INTO tags (post_id, name) VALUES (?, ?), (?, ?), (?, ?)",
  [postId, "prism", postId, "sqlite", postId, "sdk"],
);

Une écriture qui échoue en cours de route (contrainte, délai dépassé) ne laisse aucune ligne partiellement écrite.

Écritures avec RETURNING

exec refuse une instruction qui retourne des lignes : un INSERT … RETURNING y échoue avant d'écrire quoi que ce soit. Passez-la à all (ou get), qui l'exécute dans une transaction et retourne les lignes :

const created = await sdk.db.get<{ id: number }>(
  "INSERT INTO posts (title, status) VALUES (?, ?) RETURNING id",
  ["Bonjour", "draft"],
);

Limites

Limite Valeur
Durée d'exécution d'un appel côté base 5 secondes par défaut, puis l'appel échoue (database query deadline exceeded)
Lignes retournées par un appel 10 000 au plus
Taille d'un résultat 16 Mio au plus
Délai du client timeoutMs de createSdk, 30 secondes par défaut

Un résultat qui dépasse une limite fait échouer tout l'appel : il n'est pas tronqué. get lit toutes les lignes avant de garder la première : ajoutez LIMIT 1 à une requête qui peut en retourner beaucoup. Paginez les lectures larges avec LIMIT ? OFFSET ? ou, mieux, avec une condition sur la clé (WHERE id < ? ORDER BY id DESC LIMIT ?).

Mettre en place le schéma

sdk.db n'a pas d'outil de migration : le schéma se crée depuis votre code, avec des instructions idempotentes. Le motif courant est une fonction ensureSchema mémorisée sous forme de promesse : elle s'exécute une fois par isolat, et les requêtes simultanées partagent la même initialisation. Chaque isolat a son propre état de module, donc les instructions doivent pouvoir être rejouées sans effet :

// src/lib/db.ts
import { createSdk } from "@bext-stack/framework/sdk";

export const sdk = createSdk("<app-id>");

let schema: Promise<void> | null = null;

export function ensureSchema(): Promise<void> {
  schema ??= (async () => {
    await sdk.db.exec(
      "CREATE TABLE IF NOT EXISTS reservations (id INTEGER PRIMARY KEY, email TEXT NOT NULL, day TEXT NOT NULL, people INTEGER NOT NULL, created_at TEXT NOT NULL)",
    );
    await sdk.db.exec(
      "CREATE INDEX IF NOT EXISTS reservations_day ON reservations (day)",
    );
    // Added later: ALTER TABLE fails if the column already exists.
    try {
      await sdk.db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'");
    } catch {}
  })();
  return schema;
}
// src/app/api/reservations/route.ts
import { ensureSchema, sdk } from "../../../lib/db";

export async function POST(request: Request) {
  await ensureSchema();
  const { email, day, people } = await request.json();
  const { lastInsertId } = await sdk.db.exec(
    "INSERT INTO reservations (email, day, people, created_at) VALUES (?, ?, ?, ?)",
    [email, day, people, new Date().toISOString()],
  );
  return Response.json({ id: lastInsertId });
}

Ajoutez les évolutions à la fin de ensureSchema, sans modifier les instructions déjà en place. ALTER TABLE … ADD COLUMN lève une erreur quand la colonne existe déjà : entourez-le d'un try/catch, comme ci-dessus, ou vérifiez d'abord les colonnes avec SELECT name FROM pragma_table_info('reservations').

sdk.db et les entités

sdk.db sert l'état propre à votre application : réservations, brouillons, journaux, compteurs. Les données métier des tenants (contacts, factures, articles…) restent dans la Platform API, que les écrans générés par defineEntity utilisent (voir Entités).

Le tagged template sql du framework, présenté sur la page Entités, peut aussi préparer une requête pour sdk.db : sa méthode compile() retourne { text, params } avec des paramètres ?.

import { sql } from "@bext-stack/framework";

const q = sql`SELECT id, title FROM posts WHERE id IN (${ids}) ORDER BY id`.compile();
const rows = await sdk.db.all(q.text, q.params);

Les paramètres suivent alors les mêmes règles que plus haut : ils partent sous forme de chaînes.

Et ensuite

Page Ce qu'elle couvre
Entités defineEntity et le tagged template sql
Store KV Un petit état clé/valeur, sans schéma
Loaders & Actions Où s'exécutent les lectures et écritures
Présentation du SDK Le client createSdk et l'interface BextSdk