Files
dashlibstorage/FEATURE_PLAN_EXPORT.md
2026-10-04 21:22:05 +02:00

29 KiB

FEATURE PLAN — Provider-neutraler Voll-Export / -Import

Status: Entwurf · Ziel-Namespace: store.transfer · Betrifft: src/transfer/** (neu), src/index.js, scripts/, Docs

1. Ziel

Eine API, mit der die komplette Datenbank unabhängig vom Provider (MSSQL, better-sqlite3, später weitere) exportiert und wieder importiert werden kann:

  • Export aus Provider A → Import in Provider B (MSSQL ⇄ SQLite, in beide Richtungen).
  • Das Exportformat kennt keine Dialektdetails (kein DATETIME2, kein VARBINARY, keine IDENTITY-Semantik).
  • Verlustfrei: IDs, Fremdschlüssel, Sortierung, Zeitstempel, Binärdaten und die Ledger-Hashkette bleiben bit-genau erhalten.
  • Streaming: Der Speicherbedarf hängt nicht von der DB-Größe ab (Bilder und Anhänge können GB groß sein).
  • Prüfbar: Ein Export kann ohne DB verifiziert werden. Nach dem Import läuft eine Integritätsprüfung.

Nicht-Ziele (Phase 1): inkrementelle Backups, Replikation, Live-Sync, Verschlüsselung, Teil-Export einzelner Firmen (siehe Phase 5).

2. Ausgangslage im Code (relevante Befunde)

Befund Quelle Folge für das Design
Provider-Vertrag: query, transaction, initialize, purge, dialect. Kein Export- oder Schema-Introspection-Hook. src/providers/provider.js Neuer, optionaler Adapter pro Provider (§6).
Store-Methoden sind mandantenbezogen (googleAuthId → selected_company). Es gibt keine Ebene „alle Firmen“. src/store/** Voll-Export läuft nicht über die bestehenden Store-Methoden, sondern über eigene Tabellen-Reader.
SQLite speichert große Binärdaten nicht in der DB, sondern im Dateisystem (storageDir/{content,thumbavif,fullbinary,thumbbinary}/<hash>.<ext>). file.content, file.thumbavif, pic.fullbinary, pic.thumbbinary existieren dort nicht als Spalten. sqlite3/schema.js, sqlite3/index.js, scripts/test-sqlite-storage.js Blobs brauchen eine eigene Abstraktion (BlobAccess), ein reines Spalten-Mapping reicht nicht.
MSSQL hält Blobs inline (VARBINARY(MAX)): pic.fullbinary/thumbbinary, file.content/thumbavif, google_auth.picturebinary, labeltemplate.thumb. SQLite hält google_auth.picturebinary und labeltemplate.thumb ebenfalls inline als BLOB. mssql/schema.js, sqlite3/schema.js Zwei Blob-Klassen: inline (beide DBs) und extern (SQLite-Dateien).
Zeitstempel: MSSQL DATETIME2, SQLite TEXT ISO-8601 (%Y-%m-%dT%H:%M:%fZ, ms-Präzision). Schemas Kanonisches Format: ISO-8601 UTC, Z, genau 3 Nachkommastellen.
Die Ledger-Hashkette (prev_hash) ist SHA-512 über JSON.stringify({id, manifestarticle_id, warehouse_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at}). Sie hängt von id und created_at (ms-genau) ab. src/store/ledger/add.js IDs und created_at müssen 1:1 erhalten bleiben. Import in aufsteigender id-Reihenfolge, Kette wird nach dem Import verifiziert.
MSSQL-Ledger ist APPEND_ONLY; purge hinterlässt MSSQL_DroppedLedgerTable_*. mssql/schema.js, README Import in MSSQL nur in eine frisch initialisierte Ledger-Tabelle. Kein Merge in vorhandene Ledger-Daten.
Zirkuläre FKs: company.poscustomergroup_id → customergroup und customergroup.company_id → company. Selbstreferenzen: category.parent_id, article.parentarticle_id. Außerdem company.root_category_id → category. Schemas Die Importreihenfolge ist nicht rein topologisch; die FK-Prüfung muss zeitweise aufgeschoben werden (dialektspezifisch).
SQLite hat entity.company_id (denormalisiert, per Trigger gepflegt). MSSQL löst das über die View v_entity_company_number. sqlite3/schema.js, mssql/schema.js entity.company_id ist eine abgeleitete Spalte: nicht exportieren, das Ziel setzt sie selbst.
SQLite-initialize seedet tax (0, 7, 19) und meta.version. sqlite3/schema.js Import-Modus „replace“ muss Seed-Zeilen entfernen oder überschreiben, sonst PK-/UQ-Konflikte.
Collation: SQLite-Spalten teils COLLATE NOCASE, MSSQL-Default ist case-insensitive. Nicht jede SQLite-UQ ist NOCASE (z. B. attributename(company_id, name)). Schemas SQLite → MSSQL kann an einer UQ scheitern (Foo vs. foo). Pre-Flight-Check (§8).
Der LibDashStore-Konstruktor ruft initialize() automatisch auf. src/index.js Die Zieldatenbank ist beim Import schon initialisiert: „leer, aber mit Schema“ ist der Normalfall.
Der Doku-Generator liest src/store/** und die Namespaces aus src/index.js. generate-api-doc.js Neue Methoden in src/store/transfer/*.js mit JSDoc, damit API.transfer.md automatisch entsteht.

3. Architektur

Drei strikt getrennte Schichten unterhalb von store.transfer:

  1. TableRegistry: beschreibt, was exportiert wird (Tabellen, Spalten, Typen, Reihenfolge, Blobs). Provider-neutral, eine Quelle der Wahrheit.
  2. Format: beschreibt, wie es gespeichert wird (Dateilayout, Serialisierung, Prüfsummen). Kennt weder SQL noch Dialekte.
  3. TransferAdapter (pro Provider): beschreibt, wie man mit einer konkreten DB liest und schreibt (Quoting, Paging, Insert, FK-Deferral, Blob-Zugriff).

Ein neuer Provider braucht nur einen neuen Adapter. Registry und Format bleiben unverändert.

4. Neutrales Datenmodell (TableRegistry)

Datei: src/transfer/registry.js.

4.1 Eintrag pro Tabelle

{
  name: 'article',
  pk: ['id'],              // stabile Sortierung + Keyset-Paging
  identity: true,          // id wird beim Import explizit gesetzt (§6.2)
  columns: [
    { name: 'id',          type: 'int' },
    { name: 'company_id',  type: 'int', fk: 'company' },
    { name: 'sku',         type: 'string', nullable: true },
    { name: 'track_stock', type: 'bool' },
    { name: 'unitamount',  type: 'float' },
    { name: 'created_at',  type: 'datetime' },
    // ...
  ],
  derived: [],             // nur lesen, nie schreiben (z. B. entity.company_id in SQLite)
  blobs: [],               // Blob-Spalten (§4.3)
  deferredFks: [],         // z. B. company.poscustomergroup_id
  secret: false,           // enthält Zugangsdaten (§9)
}

4.2 Neutrale Typen

Registry-Typ JSON-Darstellung MSSQL SQLite
int Zahl INT INTEGER
bigint String (Dezimal) BIGINT (Treiber liefert teils String) INTEGER
float Zahl FLOAT REAL
decimal String (normalisiert, z. B. "19") DECIMAL(18,4) NUMERIC
bool true / false BIT INTEGER 0/1
string, text String NVARCHAR TEXT
datetime YYYY-MM-DDTHH:mm:ss.SSSZ DATETIME2 TEXT
blob {"$blob":"sha256:<hex>"} oder null VARBINARY(MAX) BLOB / Datei

Regeln:

  • bigint und decimal werden als String exportiert, um Präzisionsverlust zu vermeiden (pic.size, file.size sind BIGINT).
  • float muss verlustfrei round-trippen (JSON.stringify eines JS-number genügt). NaN und Infinity werden als Fehler gemeldet.
  • datetime wird immer nach UTC/ms normalisiert. Die Normalisierung sitzt im Adapter (MSSQL liefert Date, SQLite einen String).

4.3 Blobs

Kategorie Tabelle.Spalte MSSQL-Quelle SQLite-Quelle
inline google_auth.picturebinary, labeltemplate.thumb Spalte Spalte
extern pic.fullbinary, pic.thumbbinary Spalte storageDir/fullbinary/<pic.hash>.avif, …/thumbbinary/<pic.hash>.avif
extern file.content, file.thumbavif Spalte storageDir/content/<file.hash><ext>, …/thumbavif/<file.hash>.avif

Im Export wird jeder Blob inhaltsadressiert unter blobs/<sha256[0:2]>/<sha256> abgelegt (Deduplizierung gratis) und in der Zeile als {"$blob":"sha256:…"} referenziert. Der SHA-256 wird über die Bytes berechnet. Die vorhandenen Spalten pic.hash und file.hash werden nicht als Inhaltshash angenommen, weil ihre Semantik (Original oder AVIF) nicht garantiert ist.

Fehlt ein SQLite-Blob im Dateisystem (DB-Zeile ohne Datei), erzeugt das eine Warnung im Export-Report und das Feld wird null. Bei NOT NULL-Spalten (pic.fullbinary) scheitert der Import nach MSSQL. Das ist gewollt, siehe Option strict.

4.4 Tabellenreihenfolge

Abgeleitet aus den Schemata (Eltern vor Kindern). Die Registry speichert sie explizit als ORDER und testet sie gegen die FK-Metadaten (§10). Gruppen:

  1. meta, language, tax
  2. category, pic, google_auth
  3. company, customergroup (zirkulär → deferredFks)
  4. entity, entityaddress, entitycontact, unit, unittext, brand
  5. propertyname, attributename, attributevalue, optionslist, articlelist
  6. article (Selbstreferenz), articletext, articleean, articlepic, articleproperty, articleattributevalue, articlelistarticle, optionslistarticle, brandpic, categoryarticle, categorypic, categorytext
  7. price, articlesupplier
  8. manifest, manifestarticle, file, manifestattachment, warehouse, ledger
  9. numbers, companyuser, articlehistory, entityhistory, syncpos, companysetting, posterminal, posterminalcategory, printer, labeltemplate

Selbstreferenzen (category.parent_id, article.parentarticle_id): Der Export sortiert nach id. Beim Import wird die FK-Prüfung zurückgestellt (§6.3), ein Zwei-Pass-Update ist nicht nötig.

5. Öffentliche API (store.transfer)

await store.transfer.export(target, options?)   // -> ExportReport
await store.transfer.import(source, options?)   // -> ImportReport
await store.transfer.verify(source, options?)   // -> VerifyReport (ohne DB)
await store.transfer.inspect(source)            // -> Manifest     (ohne DB)

target und source sind in Phase 1 Verzeichnispfade (String). Stream- und Archivziele folgen in Phase 4.

Das sind Administrator-Operationen ohne googleAuthId: Sie sehen alle Mandanten. Der Aufrufer (z. B. dashback) muss die Berechtigung selbst prüfen. Das steht im JSDoc und in API.transfer.md.

5.1 Optionen

export(target, {
  tables,                // Whitelist von Tabellennamen (Default: alle)
  exclude,               // z. B. ['articlehistory']
  includeSecrets: true,  // false = Tokens/PINs/Secrets schwärzen (§9)
  includeBlobs: true,    // false = nur Metadaten (Blobs -> null)
  compress: false,       // 'gzip' möglich
  pageSize: 1000,        // Zeilen pro Keyset-Page (Blob-Tabellen intern kleiner)
  strict: false,         // true: Warnungen (z. B. fehlende Blob-Datei) -> Fehler
  onProgress,            // ({ table, rows, total, bytes }) => void
  signal,                // AbortSignal
})

import(source, {
  mode: 'empty',         // 'empty' (Default) | 'replace'
  confirmDestroy: false, // bei 'replace' zwingend true
  tables, exclude,
  batchSize,             // Multi-Row-Insert, dialektabhängig gedeckelt
  verify: true,          // Post-Import-Prüfung (§8.1)
  dryRun: false,         // liest, validiert, Pre-Flight; schreibt nichts
  onProgress, signal,
})

5.2 Rückgabe (Auszug)

ExportReport = {
  formatVersion, schemaVersion, dialect,
  tables: { article: { rows, bytes, sha256 }, /* ... */ },
  blobs: { count, bytes, deduplicated },
  warnings: [{ table, id, column, message }],
  durationMs,
}

ImportReport = {
  tables: { article: { read, inserted }, /* ... */ },
  blobs: { written },
  preflight: { issues: [/* ... */] },
  verify: { ok, fkViolations: [], ledger: { ok, brokenAtId }, counts: {} },
  durationMs,
}

6. Provider-Adapter

Datei pro Provider: src/providers/<name>/transfer.js. Der Provider bekommt die optionale Methode transferAdapter(). provider.js dokumentiert den Vertrag als JSDoc-Typedef (passt zu TODO §3, „Document provider contract“).

/**
 * @typedef {Object} TransferAdapter
 * @property {string} dialect
 * @property {(identifier: string) => string} quote
 * @property {() => Promise<SchemaInfo>} introspect          // Tabellen, Spalten, FKs der Live-DB
 * @property {(table, columns, pk, pageSize) => AsyncIterable<Row[]>} readPages
 * @property {(table, columns, rows) => Promise<number>} insertRows
 * @property {(fn) => Promise<*>} withImportTransaction       // inkl. FK-Deferral und Identity-Insert
 * @property {BlobAccess} blobs
 * @property {() => Promise<void>} afterImport                // Identity-Stand prüfen, Constraints reaktivieren
 */

6.1 Lesen (readPages)

  • Keyset-Paging über pk (WHERE id > @after ORDER BY id, dazu LIMIT n bzw. TOP n). Kein OFFSET: stabil und linear.
  • Dialekt-SQL im Adapter, Spaltenlisten kommen aus der Registry.
  • Blob-Spalten werden im MSSQL-Adapter nicht in der Hauptabfrage mitgelesen. Sie kommen pro Zeile in einer zweiten Abfrage (oder mit sehr kleiner pageSize), um den Speicher zu begrenzen.
  • Konsistenter Snapshot: SQLite = ein Lese-BEGIN (WAL liefert einen stabilen Snapshot). MSSQL = SNAPSHOT-Isolation, falls ALLOW_SNAPSHOT_ISOLATION aktiv ist, sonst REPEATABLE READ mit Hinweis im Report.
  • Normalisierung im Adapter: Date → ISO-ms-Z, bit → boolean, bigint/decimal → String, Buffer bleibt Buffer.

6.2 Schreiben (insertRows)

  • Multi-Row-Insert mit benannten Parametern (passt zum @name-Konvent). Batchgröße = floor(Parameterlimit / Spaltenanzahl). MSSQL erlaubt ca. 2100 Parameter je Request, SQLite je nach Build deutlich mehr.
  • Explizite IDs:
    • MSSQL: SET IDENTITY_INSERT <table> ON / OFF je Tabelle.
    • SQLite: id INTEGER PRIMARY KEY nimmt explizite Werte direkt an.
  • Zeitstempel nicht als JS-Date binden. Der mssql-Treiber bindet Date standardmäßig nicht als DATETIME2(3). Das kann ms-genaue created_at-Werte verfälschen und damit die Ledger-Kette brechen. Der MSSQL-Adapter bindet datetime deshalb typisiert (sql.DateTime2(3)) oder über einen ISO-String mit expliziter Konvertierung. Das muss ein Spike in Phase 0 bestätigen. Dazu muss MssqlExecutor.query typisierte Parameter akzeptieren, was heute nicht der Fall ist (er ruft nur req.input(key, value) auf).
  • created_at/updated_at werden explizit gesetzt und überschreiben die Defaults. Ob im Ziel Trigger oder Defaults Werte ändern, prüft Phase 0.
  • derived-Spalten (entity.company_id in SQLite) werden nicht geschrieben; die Trigger des Ziels füllen sie.

6.3 FK-Deferral (withImportTransaction)

Phase 1 nutzt eine Transaktion (Atomarität vor Durchsatz):

  • SQLite: PRAGMA defer_foreign_keys = ON in der Transaktion, die FKs werden beim COMMIT geprüft. Vorher PRAGMA foreign_key_check für eine brauchbare Fehlermeldung (Tabelle, rowid, Parent).
  • MSSQL: ALTER TABLE ... NOCHECK CONSTRAINT für die FKs aus deferredFks, am Ende ALTER TABLE ... WITH CHECK CHECK CONSTRAINT, damit die Constraints wieder trusted sind. Der Zustand wird im finally immer wiederhergestellt. Im Verify-Schritt läuft DBCC CHECKCONSTRAINTS.
  • Risiko MSSQL: Bei großen Blob-Mengen wird das Transaktionslog groß. Phase 1 dokumentiert das Limit, Phase 4 führt blockweise Commits für Blob-Tabellen ein (resume-fähig).

6.4 BlobAccess

{
  read(table, column, row)            // -> Promise<Buffer|null>
  write(table, column, row, buffer)   // -> Promise<void>
}
  • SQLite nutzt die vorhandenen Helfer (readPicFull, writePicFull, readPicThumb, writePicThumb, readFileContent, writeFileContent, readThumbAvif, writeThumbAvif). Die Dateiendung bei file.content kommt aus extFromMimetype(mimetype).
  • Schreibreihenfolge SQLite: erst Datei, dann Zeile. Bei Abbruch oder Rollback räumt der Import alle in diesem Lauf geschriebenen Dateien auf (Liste im Import-Kontext).
  • MSSQL: Blobs als Buffer-Parameter im INSERT. Blob-Tabellen werden mit einer Zeile pro Statement geschrieben.

7. Exportformat

7.1 Layout (Verzeichnis, Phase 1)

export-2026-09-30T19-30-00Z/
  manifest.json
  tables/
    language.ndjson[.gz]
    tax.ndjson[.gz]
    company.ndjson[.gz]
    ...
  blobs/
    ab/ab12...ef          (Rohbytes, Dateiname = SHA-256)
  • NDJSON: eine Zeile = ein Objekt, Spaltenreihenfolge = Registry-Reihenfolge, Zeilen nach pk sortiert. Der Export ist damit deterministisch: gleicher DB-Inhalt → byte-gleiche Dateien.
  • Kanonische Serialisierung: feste Schlüsselreihenfolge, keine Leerzeichen, UTF-8, \n als Trenner.
  • compress: 'gzip' über node:zlib (keine neue Abhängigkeit). Die Prüfsumme wird über die unkomprimierten Bytes berechnet, damit die Kompression die Gleichheitsprüfung nicht beeinflusst.

7.2 manifest.json

{
  "format": "libdashstore-export",
  "formatVersion": 1,
  "createdAt": "2026-09-30T19:30:00.000Z",
  "source": { "dialect": "mssql", "schemaVersion": "1", "libVersion": "0.1.0" },
  "options": { "includeSecrets": true, "includeBlobs": true, "compress": false },
  "containsSecrets": true,
  "tables": {
    "article": { "file": "tables/article.ndjson", "rows": 1234, "sha256": "...", "columns": ["id", "company_id"] }
  },
  "blobs": { "count": 987, "bytes": 123456789 },
  "ledger": { "rows": 4321, "lastId": 4321, "headHash": "sha512 der letzten Zeile" }
}
  • formatVersion ist unabhängig von meta.version (DB-Schema). Der Import akzeptiert formatVersion ≤ der eigenen und lehnt neuere ab.
  • schemaVersion stammt aus meta.version. Bei Abweichung zum Ziel gibt es eine Warnung bzw. in Phase 4 einen Migrationspfad, nie stilles Weiterimportieren.
  • columns im Manifest erlauben den Import in neuere Schemas (neue Spalten bekommen den DB-Default) und liefern eine klare Fehlermeldung bei Spalten, die das Ziel nicht kennt.

7.3 Streaming

Alles läuft über AsyncIterable bzw. Node-Streams mit Backpressure: DB-Page → Serializer → (gzip) → Datei. Der Import läuft umgekehrt: Datei → (gunzip) → Zeilenparser → Batches → insertRows. Kein Schritt hält eine ganze Tabelle im Speicher.

8. Import-Ablauf

  1. Open + Verify-light: manifest.json lesen, formatVersion und schemaVersion prüfen, Dateien vorhanden?
  2. Pre-Flight (nur Lesen):
    • Zieltabellen leer (mode: 'empty')? Seed-Zeilen (tax, meta) zählen nicht als Daten und werden im Import ersetzt.
    • Spalten im Export ⊆ Spalten im Ziel (per introspect).
    • Case-Kollisionen: Für UQ-Indizes, die im Ziel case-insensitive sind (MSSQL), wird auf Duplikate bei lower(value) geprüft (attributename, propertyname, attributevalue, unit u. a.). Ergebnis ist eine Liste konkreter Zeilen statt eines kryptischen UQ-Fehlers mitten im Import.
    • CHECK-Constraints des Ziels (printer.type, labeltemplate.dpi, labeltemplate.type) gegen die Daten prüfen.
    • Bei mode: 'replace' ist confirmDestroy: true Pflicht.
  3. Transaktion + FK-Deferral (§6.3). Bei mode: 'replace': zuerst provider.purge() und provider.initialize(), danach die Seed-Zeilen löschen.
  4. Tabellen in ORDER: Zeilen lesen → Blobs auflösen und schreiben → insertRows.
  5. Nachbereitung: Identity-Stand prüfen (MSSQL: DBCC CHECKIDENT(..., NORESEED) mit MAX(id) vergleichen, bei Bedarf RESEED; SQLite: nichts nötig), Constraints wieder aktivieren (WITH CHECK).
  6. Commit, danach (außerhalb der Transaktion) Verify (§8.1).

8.1 Verify nach dem Import

Prüfung Beschreibung
Zeilenanzahl COUNT(*) je Tabelle == manifest.tables[t].rows
FK-Integrität SQLite: PRAGMA foreign_key_check; MSSQL: DBCC CHECKCONSTRAINTS WITH ALL_CONSTRAINTS
Ledger-Kette prev_hash[i] == sha512(canonical(row[i-1])), Start = INIT_HASH; meldet die erste defekte id; headHash == Manifest
Blob-Hashes Stichprobe, mit verify: 'full' vollständig: SHA-256 der gespeicherten Bytes == Manifest-Referenz
Round-Trip (nur Test, §10) Re-Export der Zieldatenbank liefert dieselben Tabellen-SHA-256 wie der Quell-Export

transfer.verify(source) arbeitet ohne DB und prüft nur die Dateiebene: Manifest, Tabellen-Prüfsummen, Blob-Existenz und -Hash, $blob-Referenzen, FK-Abschluss (jede *_id zeigt auf eine vorhandene Zeile, rein aus den NDJSON-Dateien) und die Ledger-Kette.

8.2 Ledger-Sonderfall

  • Nur in aufsteigender id einfügen; created_at und prev_hash unverändert übernehmen. Der Import darf ledger.add() nicht benutzen, das würde neue Hashes erzeugen.
  • Die Ledger-Tabelle im Ziel muss leer sein, sonst Abbruch. Bei replace auf MSSQL kommt ein Hinweis auf die Soft-Drop-Tabellen.
  • Offene Frage: Die README (§ledger) nennt warehouse_id nicht in der Hash-Payload, add.js (computePrevHash) enthält es. Vor Phase 2 klären, damit verify die richtige kanonische Payload nutzt. Gegebenenfalls nach hash_version verzweigen.

9. Sicherheit und Datenschutz

Betroffene Felder (Registry-Flag secret):

  • google_auth: email, sub, picture, picturebinary (personenbezogen)
  • posterminal: token, pairingpin
  • syncpos: paircode, token
  • companysetting: Zeilen mit writeonly = 1 (z. B. API-Schlüssel)

Regeln:

  • Default includeSecrets: true, weil ein Backup ohne Zugangsdaten unvollständig ist. Das Manifest setzt containsSecrets: true, die CLI warnt. (Default siehe offene Frage 4.)
  • includeSecrets: false ersetzt die Werte durch null bzw. ein Platzhalter-Token. Importierte, geschwärzte Terminals müssen neu gekoppelt werden (Hinweis im Report).
  • Dateien und Verzeichnisse werden mit 0600 bzw. 0700 angelegt.
  • Verschlüsselung (z. B. AES-256-GCM mit Schlüssel vom Aufrufer) ist bewusst Phase 5. Das Format reserviert manifest.encryption.
  • export und import loggen keine Zeilendaten, nur Tabellennamen und Zähler.

10. Tests und Qualitätssicherung

Neue Datei scripts/test-transfer.js (Stil wie scripts/test-api.js und test-sqlite-storage.js, node:assert) und das Script npm run test:transfer.

Test Inhalt
Registry ↔ Live-Schema introspect() beider Provider; Registry-Spalten == Live-Spalten (Namen, Nullability, Typklasse). Fehlende oder zusätzliche Spalten → Fehler. Verhindert, dass neue Spalten unbemerkt im Export fehlen.
FK-Reihenfolge ORDER ist mit den FK-Metadaten beider Provider verträglich (außer deferredFks)
Fixture Datensatz über alle Tabellen (Artikelvarianten, Stücklisten, Optionen, Bilder, Anhänge, Lieferungen, Ledger, POS, Labels). Kann auf scripts/benchmark/workload.js aufsetzen.
Round-Trip-Matrix sqlite→dir→sqlite, sqlite→dir→mssql, mssql→dir→sqlite, mssql→dir→mssql (MSSQL nur, wenn eine Test-Verbindung konfiguriert ist)
Akzeptanzkriterium Export(Quelle) und Re-Export(Ziel) haben identische Tabellen-SHA-256 und identische Blob-Menge
Ledger Kette nach dem Import intakt; eine manipulierte Zeile → verify meldet genau diese id
Zeitstempel ms-genaue created_at überstehen MSSQL ⇄ SQLite unverändert
Blobs Bytes identisch (pic, file, picturebinary, thumb); fehlende SQLite-Datei → Warnung bzw. strict-Fehler
Fehlerpfade Import in nicht-leere DB, unbekannte Spalte, neueres formatVersion, korrupte Prüfsumme, Case-Kollision, Abbruch per AbortSignal → DB unverändert (Rollback) und keine verwaisten SQLite-Dateien
Große Daten Benchmark-Szenario; der Speicherverbrauch bleibt bei steigender Datenmenge flach
Determinismus Zweimal exportieren → byte-identische tables/*.ndjson

11. Umsetzungsphasen

Phase 0 — Spikes (vor dem Design-Freeze)

  • MSSQL: DATETIME2(3)-Binding mit dem mssql-Treiber; ms-Round-Trip, besonders ledger.created_at
  • MSSQL: IDENTITY_INSERT in die LEDGER = ON (APPEND_ONLY = ON)-Tabelle (erlaubt? Verhalten der Ledger-Systemspalten?)
  • MSSQL: NOCHECK / WITH CHECK CHECK bei zirkulären FKs innerhalb einer Transaktion
  • MSSQL: Laufen beim Massen-Insert Trigger (z. B. TR_manifestattachment_delete_file) mit unerwarteten Effekten?
  • SQLite: PRAGMA defer_foreign_keys zusammen mit den Triggern TR_entity_set_company_* beim Massen-Insert
  • Klären: warehouse_id in der Ledger-Hash-Payload (§8.2)
  • Klären: Semantik von pic.hash / file.hash (nur Doku; der Export nutzt einen eigenen SHA-256)

Phase 1 — Kern (Export + Import in leere DB, Verzeichnisformat)

  • src/transfer/registry.js (alle Tabellen, Typen, Blobs, ORDER, secret, derived, deferredFks)
  • src/transfer/format/ (NDJSON-Writer und -Reader, Manifest, SHA-256, gzip, Blob-Store)
  • TransferAdapter-Typedef in src/providers/provider.js
  • src/providers/sqlite3/transfer.js und src/providers/mssql/transfer.js
  • src/store/transfer/{export,import,verify,inspect}.js und Einbindung in src/index.js (this.transfer = { ... })
  • Export-Subpath "./transfer" in package.json
  • Tests: Registry ↔ Schema, Round-Trip SQLite→SQLite, Determinismus

Phase 2 — Cross-Provider und Integrität

  • Round-Trip-Matrix inkl. MSSQL (CI mit SQL-Server-Container)
  • Ledger-Verify, Pre-Flight (Case-Kollisionen, CHECK-Constraints), Post-Import-Verify
  • includeSecrets, Dateirechte, Manifest-Flag
  • AbortSignal, onProgress, Fehlerberichte mit Tabelle und ID

Phase 3 — Tooling und Doku

  • CLI scripts/export-db.js und scripts/import-db.js (--provider=, --out=, --in=, --mode=, --no-secrets; Provider-Auswahl analog TODO §7)
  • npm run export und npm run import in package.json
  • API.transfer.md per npm run api-doc erzeugen; README-Abschnitt „Backup / Migration“ (Beispiele MSSQL→SQLite und zurück)
  • TODO.md um einen Verweis auf diesen Plan ergänzen

Phase 4 — Robustheit

  • Blockweise Commits für Blob-Tabellen mit Resume (Checkpoint im Import-Kontext)
  • Schema-Migrationspfade: Export von schemaVersion N in ein Ziel N+k (Spalten-Defaults, Umbenennungen über Registry-Felder wie since / renamedFrom)
  • Archiv-Targets: einzelne .tar.gz/.zip-Datei und Stream-Target (HTTP-Download in dashback)

Phase 5 — Optional

  • Verschlüsselung (AES-256-GCM, Schlüssel vom Aufrufer)
  • Export/Import einer Firma in eine bestehende DB (ID-Remapping, siehe §12.2)
  • Inkrementeller Export (Wasserzeichen über updated_at/id; Ledger nur Append)

12. Risiken und offene Entscheidungen

12.1 Risiken

Risiko Auswirkung Gegenmaßnahme
Registry driftet vom Schema (neue Spalte vergessen) Stiller Datenverlust im Export Pflichttest „Registry ↔ Live-Schema“ auf beiden Providern, blockierend in CI
Date-Binding im mssql-Treiber Ledger-Hashkette bricht nach dem Import Typisierte DATETIME2(3)-Parameter, Spike, Regressionstest, Ketten-Verify
Collation-Unterschiede SQLite ↔ MSSQL UQ-Verletzung beim Import nach MSSQL Pre-Flight mit konkreter Zeilenliste
Große Blobs in einer Transaktion (MSSQL-Log) Log-Wachstum, Timeout Phase 1 dokumentiert das Limit; Phase 4 blockweise Commits
APPEND_ONLY-Ledger verweigert explizite IDs oder Systemspalten Ledger nicht importierbar Phase-0-Spike; Fallback ist ein dokumentierter Sonderpfad oder der Verzicht auf den Ledger-Import in MSSQL
SQLite-Dateien und DB-Zeilen laufen auseinander (Datei fehlt) Export unvollständig Warnung bzw. strict; der Report listet die betroffenen IDs
Export enthält Geheimnisse Datenleck Manifest-Flag, Dateirechte, includeSecrets: false, Doku
mode: 'replace' zerstört Daten Datenverlust confirmDestroy-Pflicht, Standard ist empty

12.2 Offene Fragen

  1. Scope: Nur Voll-Export der ganzen DB (wie in diesem Plan) oder in Phase 1 zusätzlich Export pro Firma? Ein Pro-Firma-Import in eine bestehende DB braucht ID-Remapping (alle PKs und FKs neu vergeben). Dabei bricht die Ledger-Hashkette, weil id Teil der Hash-Payload ist. Deshalb erst Phase 5.
  2. Zielformat: Reicht ein Verzeichnis (plus optional .tar.gz in Phase 4) oder braucht dashback sofort eine einzelne Archivdatei bzw. einen HTTP-Stream?
  3. Berechtigungen: Wer darf transfer.* aufrufen? Der Plan sieht nur serverseitigen Gebrauch vor, die Autorisierung liegt beim Aufrufer.
  4. Secrets: Ist der Default includeSecrets: true in Ordnung oder soll er false sein?
  5. Ledger im Ziel MSSQL: Ist ein Import in eine LEDGER-Tabelle fachlich und rechtlich erlaubt (Manipulationssicherheit, TSE-Bezug „TODO_TSE_BASED_INITHASH“)? Gegebenenfalls wird der Ledger-Import ein eigener, bestätigungspflichtiger Schritt.
  6. Umfang: Sollen articlehistory und entityhistory standardmäßig mitexportiert werden (Datenmenge)?

13. Betroffene Dateien

Neu:

  • FEATURE_PLAN_EXPORT.md (dieses Dokument)
  • src/transfer/registry.js, src/transfer/format/*.js, src/transfer/index.js
  • src/providers/sqlite3/transfer.js, src/providers/mssql/transfer.js
  • src/store/transfer/{export,import,verify,inspect}.js
  • scripts/test-transfer.js, scripts/export-db.js, scripts/import-db.js
  • API.transfer.md (generiert)

Geändert:

  • src/index.js (Namespace transfer)
  • src/providers/provider.js (Typedef TransferAdapter, transferAdapter())
  • package.json (Scripts test:transfer, export, import; Export ./transfer)
  • README.md, TODO.md (Verweis auf den Plan), API.md (regeneriert)