From 96c9176826d318ec4a1fbf32ea92dc31311f61e4 Mon Sep 17 00:00:00 2001 From: Armin Fauland Date: Tue, 4 Aug 2026 06:45:22 +0000 Subject: [PATCH] fix(api): store dotted keys nested so templates can read them back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `storeData` writes the runtime value under the flat key: this.storedData[key] = value; // "amazon.downloadedInvoices" But templates resolve `{{storedData.amazon.downloadedInvoices}}` as a *path*, and the DB loader (`convertJobDataToNestedObject` in ScrapeDataService) builds a nested object from the same dotted key. So the flat entry is invisible to every template — the expression yields an empty string. The comment on that line says "für Template-Zugriff in diesem Run", which is exactly what did not work: a value stored during a run could not be read back during that run. It only became visible in the *next* run, via the detour through the database. That is easy to miss because it almost works. A config that appends to a list like updatedList = storedData.x.list != '' ? storedData.x.list & ',' & item : item storeData x.list = updatedList grows by exactly one entry per run instead of one per loop iteration, since every iteration reads the value loaded at run start. In my case a list meant to hold ~800 downloaded invoice ids held 12 — one per run — so the scraper re-downloaded everything on each run and relied on the downstream consumer to deduplicate. Fix: mirror the loader and write the nested form as well. The flat key is kept so configs that read it as a whole keep working. Adds five tests: nested write, flat key retained, deep nesting, merging into an existing branch, and replacing a non-object value that would otherwise block the path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LixHBPkhb8h5oDdMqSG4se --- .../actions/store-data.action.spec.ts | 54 +++++++++++++++++++ .../actions/store-data.action.ts | 45 +++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/apps/api/src/action-handler/actions/store-data.action.spec.ts b/apps/api/src/action-handler/actions/store-data.action.spec.ts index fb2c566..13d4dd5 100644 --- a/apps/api/src/action-handler/actions/store-data.action.spec.ts +++ b/apps/api/src/action-handler/actions/store-data.action.spec.ts @@ -27,6 +27,60 @@ describe('StoreDataAction', () => { expect((action as any).storedData.testKey).toBe('testValue'); }); + it('stores a dotted key as a nested object', async () => { + // Regression: templates resolve {{storedData.a.b}} as a PATH, so a flat + // key "a.b" is invisible to them. The DB loader builds nested objects + // (convertJobDataToNestedObject) — the runtime store has to match, + // otherwise a value written during a run cannot be read back in it. + action.params = { key: 'amazon.downloadedInvoices', value: 'a,b' } as any; + (action as any).storedData = {}; + + await action.run(); + + expect((action as any).storedData.amazon.downloadedInvoices).toBe('a,b'); + }); + + it('keeps the flat key as well, for configs that rely on it', async () => { + action.params = { key: 'amazon.lastOrderId', value: '306-1' } as any; + (action as any).storedData = {}; + + await action.run(); + + expect((action as any).storedData['amazon.lastOrderId']).toBe('306-1'); + }); + + it('handles deeply nested keys', async () => { + action.params = { key: 'a.b.c.d', value: 'tief' } as any; + (action as any).storedData = {}; + + await action.run(); + + expect((action as any).storedData.a.b.c.d).toBe('tief'); + }); + + it('merges into an existing branch instead of replacing it', async () => { + action.params = { key: 'amazon.lastOrderId', value: '306-2' } as any; + (action as any).storedData = { amazon: { downloadedInvoices: 'a,b' } }; + + await action.run(); + + expect((action as any).storedData.amazon).toEqual({ + downloadedInvoices: 'a,b', + lastOrderId: '306-2', + }); + }); + + it('replaces a non-object value that blocks the path', async () => { + // "amazon" was stored as a plain string earlier — without this the + // nested write would throw or silently do nothing. + action.params = { key: 'amazon.foo', value: 'bar' } as any; + (action as any).storedData = { amazon: 'irgendwas' }; + + await action.run(); + + expect((action as any).storedData.amazon.foo).toBe('bar'); + }); + it('should warn when databaseService is not available', async () => { action.params = { key: 'k', value: 'v' } as any; (action as any).data = { databaseService: null }; diff --git a/apps/api/src/action-handler/actions/store-data.action.ts b/apps/api/src/action-handler/actions/store-data.action.ts index 066e042..ab4fcc0 100644 --- a/apps/api/src/action-handler/actions/store-data.action.ts +++ b/apps/api/src/action-handler/actions/store-data.action.ts @@ -28,7 +28,22 @@ export class StoreDataAction extends BaseAction { async run(): Promise { const { key, value, persist = false, attachToRun } = this.params; - // Speichere die neuen Werte im Runtime-Speicher (für Template-Zugriff in diesem Run) + // Speichere die neuen Werte im Runtime-Speicher (für Template-Zugriff in diesem Run). + // + // Der Wert muss GENAUSO abgelegt werden, wie er beim Laden aus der DB + // aufgebaut wird — dort zerlegt convertJobDataToNestedObject() den Key am + // Punkt in ein verschachteltes Objekt. Eine rein flache Ablage unter + // "amazon.downloadedInvoices" ist für Handlebars unsichtbar: + // {{storedData.amazon.downloadedInvoices}} löst als Pfad auf und findet + // den flachen Key nicht — der Ausdruck liefert dann einen leeren String. + // + // Folge vor diesem Fix: Ein im selben Run gespeicherter Wert war für alle + // folgenden Actions unsichtbar. Erst der nächste Run sah ihn, weil er den + // Umweg über die DB nahm. Wer damit eine Liste fortschreibt, bekommt pro + // Run genau EINEN neuen Eintrag statt einen pro Schleifendurchlauf. + this.setNested(key, value); + // Zusätzlich flach ablegen — Konfigurationen, die den Key mit Punkt als + // ganzes lesen, funktionieren damit unverändert weiter. this.storedData[key] = value; this.logger.log(`💾 Stored: ${key} = ${value}`); @@ -68,4 +83,32 @@ export class StoreDataAction extends BaseAction { this.logger.error(`❌ Failed to persist data: ${error.message}`); } } + + /** + * Legt einen Punkt-Key als verschachteltes Objekt ab — spiegelbildlich zu + * convertJobDataToNestedObject() in ScrapeDataService, das die Daten beim + * Laden aus der DB genauso aufbaut. + * + * "a.b.c" → storedData.a.b.c + * + * Ein bestehender Nicht-Objekt-Wert auf dem Weg wird ersetzt, sonst würde + * ein früher gespeichertes "a" das Anlegen von "a.b" verhindern. + */ + private setNested(key: string, value: unknown): void { + const teile = key.split('.'); + if (teile.length === 1) { + this.storedData[key] = value; + return; + } + + let ziel: Record = this.storedData; + for (let i = 0; i < teile.length - 1; i++) { + const t = teile[i]; + if (typeof ziel[t] !== 'object' || ziel[t] === null) { + ziel[t] = {}; + } + ziel = ziel[t]; + } + ziel[teile[teile.length - 1]] = value; + } }