u
This commit is contained in:
426
FEATURE_PLAN_EXPORT.md
Normal file
426
FEATURE_PLAN_EXPORT.md
Normal file
@@ -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}/<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.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:<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.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/<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)
|
||||
Reference in New Issue
Block a user