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, keinVARBINARY, keineIDENTITY-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:
- TableRegistry: beschreibt, was exportiert wird (Tabellen, Spalten, Typen, Reihenfolge, Blobs). Provider-neutral, eine Quelle der Wahrheit.
- Format: beschreibt, wie es gespeichert wird (Dateilayout, Serialisierung, Prüfsummen). Kennt weder SQL noch Dialekte.
- 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:
bigintunddecimalwerden als String exportiert, um Präzisionsverlust zu vermeiden (pic.size,file.sizesindBIGINT).floatmuss verlustfrei round-trippen (JSON.stringifyeines JS-numbergenügt).NaNundInfinitywerden als Fehler gemeldet.datetimewird immer nach UTC/ms normalisiert. Die Normalisierung sitzt im Adapter (MSSQL liefertDate, 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:
meta,language,taxcategory,pic,google_authcompany,customergroup(zirkulär →deferredFks)entity,entityaddress,entitycontact,unit,unittext,brandpropertyname,attributename,attributevalue,optionslist,articlelistarticle(Selbstreferenz),articletext,articleean,articlepic,articleproperty,articleattributevalue,articlelistarticle,optionslistarticle,brandpic,categoryarticle,categorypic,categorytextprice,articlesuppliermanifest,manifestarticle,file,manifestattachment,warehouse,ledgernumbers,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, dazuLIMIT nbzw.TOP n). KeinOFFSET: 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, fallsALLOW_SNAPSHOT_ISOLATIONaktiv ist, sonstREPEATABLE READmit Hinweis im Report. - Normalisierung im Adapter:
Date→ ISO-ms-Z,bit→ boolean,bigint/decimal→ String,Bufferbleibt 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/OFFje Tabelle. - SQLite:
id INTEGER PRIMARY KEYnimmt explizite Werte direkt an.
- MSSQL:
- Zeitstempel nicht als JS-
Datebinden. Der mssql-Treiber bindetDatestandardmäßig nicht alsDATETIME2(3). Das kann ms-genauecreated_at-Werte verfälschen und damit die Ledger-Kette brechen. Der MSSQL-Adapter bindetdatetimedeshalb typisiert (sql.DateTime2(3)) oder über einen ISO-String mit expliziter Konvertierung. Das muss ein Spike in Phase 0 bestätigen. Dazu mussMssqlExecutor.querytypisierte Parameter akzeptieren, was heute nicht der Fall ist (er ruft nurreq.input(key, value)auf). created_at/updated_atwerden explizit gesetzt und überschreiben die Defaults. Ob im Ziel Trigger oder Defaults Werte ändern, prüft Phase 0.derived-Spalten (entity.company_idin 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 = ONin der Transaktion, die FKs werden beimCOMMITgeprüft. VorherPRAGMA foreign_key_checkfür eine brauchbare Fehlermeldung (Tabelle, rowid, Parent). - MSSQL:
ALTER TABLE ... NOCHECK CONSTRAINTfür die FKs ausdeferredFks, am EndeALTER TABLE ... WITH CHECK CHECK CONSTRAINT, damit die Constraints wieder trusted sind. Der Zustand wird imfinallyimmer wiederhergestellt. Im Verify-Schritt läuftDBCC 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 beifile.contentkommt ausextFromMimetype(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 imINSERT. 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
pksortiert. Der Export ist damit deterministisch: gleicher DB-Inhalt → byte-gleiche Dateien. - Kanonische Serialisierung: feste Schlüsselreihenfolge, keine Leerzeichen, UTF-8,
\nals Trenner. compress: 'gzip'übernode: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" }
}
formatVersionist unabhängig vonmeta.version(DB-Schema). Der Import akzeptiertformatVersion≤ der eigenen und lehnt neuere ab.schemaVersionstammt ausmeta.version. Bei Abweichung zum Ziel gibt es eine Warnung bzw. in Phase 4 einen Migrationspfad, nie stilles Weiterimportieren.columnsim 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
- Open + Verify-light:
manifest.jsonlesen,formatVersionundschemaVersionprüfen, Dateien vorhanden? - 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,unitu. 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'istconfirmDestroy: truePflicht.
- Zieltabellen leer (
- Transaktion + FK-Deferral (§6.3). Bei
mode: 'replace': zuerstprovider.purge()undprovider.initialize(), danach die Seed-Zeilen löschen. - Tabellen in
ORDER: Zeilen lesen → Blobs auflösen und schreiben →insertRows. - Nachbereitung: Identity-Stand prüfen (MSSQL:
DBCC CHECKIDENT(..., NORESEED)mitMAX(id)vergleichen, bei BedarfRESEED; SQLite: nichts nötig), Constraints wieder aktivieren (WITH CHECK). - 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
ideinfügen;created_atundprev_hashunverändert übernehmen. Der Import darfledger.add()nicht benutzen, das würde neue Hashes erzeugen. - Die Ledger-Tabelle im Ziel muss leer sein, sonst Abbruch. Bei
replaceauf MSSQL kommt ein Hinweis auf die Soft-Drop-Tabellen. - Offene Frage: Die README (§ledger) nennt
warehouse_idnicht in der Hash-Payload,add.js(computePrevHash) enthält es. Vor Phase 2 klären, damitverifydie richtige kanonische Payload nutzt. Gegebenenfalls nachhash_versionverzweigen.
9. Sicherheit und Datenschutz
Betroffene Felder (Registry-Flag secret):
google_auth:email,sub,picture,picturebinary(personenbezogen)posterminal:token,pairingpinsyncpos:paircode,tokencompanysetting: Zeilen mitwriteonly = 1(z. B. API-Schlüssel)
Regeln:
- Default
includeSecrets: true, weil ein Backup ohne Zugangsdaten unvollständig ist. Das Manifest setztcontainsSecrets: true, die CLI warnt. (Default siehe offene Frage 4.) includeSecrets: falseersetzt die Werte durchnullbzw. ein Platzhalter-Token. Importierte, geschwärzte Terminals müssen neu gekoppelt werden (Hinweis im Report).- Dateien und Verzeichnisse werden mit
0600bzw.0700angelegt. - Verschlüsselung (z. B. AES-256-GCM mit Schlüssel vom Aufrufer) ist bewusst Phase 5. Das Format reserviert
manifest.encryption. exportundimportloggen 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 demmssql-Treiber; ms-Round-Trip, besondersledger.created_at - MSSQL:
IDENTITY_INSERTin dieLEDGER = ON (APPEND_ONLY = ON)-Tabelle (erlaubt? Verhalten der Ledger-Systemspalten?) - MSSQL:
NOCHECK/WITH CHECK CHECKbei 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_keyszusammen mit den TriggernTR_entity_set_company_*beim Massen-Insert - Klären:
warehouse_idin 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 insrc/providers/provider.jssrc/providers/sqlite3/transfer.jsundsrc/providers/mssql/transfer.jssrc/store/transfer/{export,import,verify,inspect}.jsund Einbindung insrc/index.js(this.transfer = { ... })- Export-Subpath
"./transfer"inpackage.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-FlagAbortSignal,onProgress, Fehlerberichte mit Tabelle und ID
Phase 3 — Tooling und Doku
- CLI
scripts/export-db.jsundscripts/import-db.js(--provider=,--out=,--in=,--mode=,--no-secrets; Provider-Auswahl analog TODO §7) npm run exportundnpm run importinpackage.jsonAPI.transfer.mdpernpm run api-docerzeugen; 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
schemaVersionN in ein Ziel N+k (Spalten-Defaults, Umbenennungen über Registry-Felder wiesince/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
- 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
idTeil der Hash-Payload ist. Deshalb erst Phase 5. - Zielformat: Reicht ein Verzeichnis (plus optional
.tar.gzin Phase 4) oder braucht dashback sofort eine einzelne Archivdatei bzw. einen HTTP-Stream? - Berechtigungen: Wer darf
transfer.*aufrufen? Der Plan sieht nur serverseitigen Gebrauch vor, die Autorisierung liegt beim Aufrufer. - Secrets: Ist der Default
includeSecrets: truein Ordnung oder soll erfalsesein? - 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. - Umfang: Sollen
articlehistoryundentityhistorystandardmäß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.jssrc/providers/sqlite3/transfer.js,src/providers/mssql/transfer.jssrc/store/transfer/{export,import,verify,inspect}.jsscripts/test-transfer.js,scripts/export-db.js,scripts/import-db.jsAPI.transfer.md(generiert)
Geändert:
src/index.js(Namespacetransfer)src/providers/provider.js(TypedefTransferAdapter,transferAdapter())package.json(Scriptstest:transfer,export,import; Export./transfer)README.md,TODO.md(Verweis auf den Plan),API.md(regeneriert)