Aller au contenu principal

Persistance SQL

ausus/persistence-sql (couche L3) est le driver de persistance de la v0.1.0. Il est basé sur SQLite, dérive son schéma du graphe de métadonnées, et applique l'isolation des tenants et la concurrence optimiste.

Dériver le schéma

SchemaDeriver transforme un graphe compilé en instructions CREATE TABLE — une table par entité, plus la table du journal d'audit :

use Ausus\Persistence\Sql\SchemaDeriver;

foreach (SchemaDeriver::deriveAll($graph) as $stmt) {
$pdo->exec($stmt);
}
  • Le nom de la table est le FQN de l'entité avec les points remplacés par des underscores — billing.invoicebilling_invoice.
  • Les types de colonnes correspondent aux types de champs : string/datetimeTEXT, integerINTEGER, moneyNUMERIC, identity/versionTEXT NOT NULL.
  • id est la clé primaire. Les champs non nullables reçoivent NOT NULL ; les valeurs par défaut des champs deviennent les DEFAULT des colonnes.

Le driver

SqlitePersistenceDriver implémente le contrat PersistenceDriver du kernel :

use Ausus\Persistence\Sql\SqlitePersistenceDriver;

$driver = new SqlitePersistenceDriver($pdo, $graph);

$tx = $driver->beginTransaction($tenant);
$ctx = $driver->context($tenant, $tx);
$repo = $ctx->repository('billing.invoice');
// ... use the repository ...
$driver->commit($tx);

Un PersistenceContext est toujours lié à un Tenant ; demander un contexte avec un tenant qui ne correspond pas au handle de transaction lève TenantBoundaryViolation.

Le repository

SqliteRepository est l'API de données par entité. La v0.1.0 a trois opérations :

MéthodeComportement
find(Reference $ref): ?Entitylit une ligne par id, limitée au tenant
create(array $payload, ?string $identity = null): Entityinsère une ligne, en générant un id ULID et _version
update(Reference $ref, array $patch, Version $expected): Entitymet à jour une ligne si $expected correspond à la _version courante
$entity = $repo->find($ref);
$entity = $repo->create(['number' => 'INV-1', /* ... */]);
$entity = $repo->update($ref, ['customer_name' => 'New'], $entity->version);

Isolation des tenants

Chaque table possède une colonne tenant_id. Chaque requête est filtrée par elle, et une Reference dont le tenantId ne correspond pas au tenant actif est rejetée avec TenantBoundaryViolation avant l'exécution de tout SQL. Le cantonnement par tenant est appliqué dans le driver, et non laissé à l'appelant.

Concurrence optimiste

Chaque ligne porte une colonne _version — un ULID régénéré à chaque écriture. update() inclut _version = :expected dans sa clause WHERE :

  • Si la ligne est mise à jour, la version correspondait.
  • Si zéro ligne est affectée, le driver vérifie si la ligne existe : absente → NotFound ; présente mais avec une version différente → ConcurrencyConflict.

C'est ainsi qu'une écriture obsolète est détectée — il n'y a pas de verrouillage de ligne.

Le journal d'audit

SchemaDeriver émet aussi une table kernel_audit_log. DatabaseAuditSink implémente le contrat AuditSink du kernel et écrit une ligne par action réussie — acteur, tenant, FQN de l'action, sujet, entrées, sorties, horodatage, identifiant de corrélation et numéro de séquence. L'écriture a lieu à l'intérieur de la transaction de l'action, de sorte que l'entrée d'audit et le changement de données sont validés ou annulés ensemble. Voir Le Runtime.

Limites actuelles de la v0.1.0

  • SQLite uniquement. Le driver cible SQLite via PDO. MySQL et PostgreSQL sont un objectif de conception mais ne sont ni implémentés ni validés dans la v0.1.0.
  • Le repository n'a ni findMany, ni API de requête/filtre, ni delete. Le rendu de liste lit les lignes via un chemin de requête direct (voir Projections).
  • Il n'y a pas de migrations — SchemaDeriver utilise CREATE TABLE IF NOT EXISTS. Changer les champs d'une entité ne modifie pas une table existante.
  • _version est régénéré comme un ULID ; c'est un jeton de changement, pas un compteur.