diff --git a/FEATURE_PLAN_EXPORT.md b/FEATURE_PLAN_EXPORT.md new file mode 100644 index 0000000..a51fafa --- /dev/null +++ b/FEATURE_PLAN_EXPORT.md @@ -0,0 +1,426 @@ +# 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.fatherarticle_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`, `syncpos`, `companysetting`, `posterminal`, `posterminalcategory`, `printer`, `labeltemplate` + +Selbstreferenzen (`category.parent_id`, `article.fatherarticle_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` +- `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) diff --git a/src/store/stock/adjust.js b/src/store/stock/adjust.js index af2544c..c9c0f28 100644 --- a/src/store/stock/adjust.js +++ b/src/store/stock/adjust.js @@ -62,21 +62,37 @@ export default function adjust(provider) { const date = input.date ? parseDate(input.date) ?? new Date() : new Date(); const createdAt = new Date(); + const isSingle = !Array.isArray(input.lines); + const normalizedLines = rawLines.map((line, idx) => { - const sku = typeof line.sku === 'string' ? line.sku.trim() : ''; + const itemLabel = isSingle ? '' : ` for item ${idx + 1}`; + if (line.sku == null) { + throw new Error(`SKU required${itemLabel}`); + } + if (typeof line.sku !== 'string') { + const val = typeof line.sku === 'object' ? JSON.stringify(line.sku) : String(line.sku); + throw new Error(`SKU must be a string${itemLabel} (received ${typeof line.sku}: ${val})`); + } + const sku = line.sku.trim(); if (!sku) { - throw new Error(`SKU required for item ${idx + 1}`); + throw new Error(`SKU cannot be empty${itemLabel}`); } const rawWhId = line.warehouseId ?? line.warehouse_id ?? defaultWarehouseId; + if (rawWhId == null) { + throw new Error(`Warehouse required${itemLabel} (${sku})`); + } const warehouseId = Number(rawWhId); if (!Number.isInteger(warehouseId) || warehouseId <= 0) { - throw new Error(`Warehouse required for item ${idx + 1} (${sku})`); + throw new Error(`Warehouse must be a positive integer${itemLabel} (${sku})`); } + if (line.amount == null) { + throw new Error(`Amount required${itemLabel} (${sku})`); + } const amount = Number(line.amount); if (!Number.isFinite(amount) || amount === 0) { - throw new Error(`Invalid amount for item ${idx + 1} (${sku}): must be non-zero number`); + throw new Error(`Invalid amount${itemLabel} (${sku}): must be non-zero number`); } const text = (line.text ?? line.transactionText ?? line.transaction_text ?? defaultText)?.trim?.() ||