# 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}/.`). `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 ```js { 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:"}` 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/.avif`, `…/thumbbinary/.avif` | | extern | `file.content`, `file.thumbavif` | Spalte | `storageDir/content/`, `…/thumbavif/.avif` | Im Export wird jeder Blob **inhaltsadressiert** unter `blobs//` 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`, `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`) ```js 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 ```js 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) ```js 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//transfer.js`. Der Provider bekommt die optionale Methode `transferAdapter()`. `provider.js` dokumentiert den Vertrag als JSDoc-Typedef (passt zu TODO §3, „Document provider contract“). ```js /** * @typedef {Object} TransferAdapter * @property {string} dialect * @property {(identifier: string) => string} quote * @property {() => Promise} introspect // Tabellen, Spalten, FKs der Live-DB * @property {(table, columns, pk, pageSize) => AsyncIterable} readPages * @property {(table, columns, rows) => Promise} insertRows * @property {(fn) => Promise<*>} withImportTransaction // inkl. FK-Deferral und Identity-Insert * @property {BlobAccess} blobs * @property {() => Promise} 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 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` ```js { read(table, column, row) // -> Promise write(table, column, row, buffer) // -> Promise } ``` - 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` ```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` - `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)