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

427 lines
29 KiB
Markdown

# 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
```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:<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`)
```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/<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“).
```js
/**
* @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`
```js
{
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`
```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)