Three new sync-tracked Dexie tables under the articles appId:
articleImportJobs — job header (counters, status, lease metadata).
articleImportItems — one row per URL in a job, state-machine driven.
articleExtractPickup — short-lived server→client handoff inbox.
URL stays plaintext on items by necessity — the server-worker reads it
without master-key access, same rationale as articles.originalUrl. The
extracted article eventually lands encrypted in the existing `articles`
table; bulk-import rows hold only pointers.
Plan: docs/plans/articles-bulk-import.md (full architecture, 7 phases,
test matrix, edge-cases). Phase 2 already shipped in 5535f2da4 (worker);
this commit lays the schema underneath it.
Originally committed as b2f4e8314, lost during a parallel reset, here
restored via cherry-pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
Articles — Bulk URL Import
Status (2026-04-28)
Phase 0 — Plan: in progress. Phasen 1–7: offen.
Ziel
User wirft eine Liste von URLs in ein Textfeld (zeilengetrennt), Mana extrahiert + speichert alle Artikel im Hintergrund. Funktioniert auch wenn der Tab schließt, das Gerät wechselt, das Netz kurz weg ist. Der Job überlebt Sessions und ist auf jedem Gerät sichtbar an dem der User eingeloggt ist.
Heute existiert nur Single-URL-Ingestion (AddUrlForm, QuickAddInput,
Bookmarklets v1+v2, Share-Target). Alle Pfade rufen am Ende
articlesStore.saveFromUrl() oder saveFromExtracted() auf.
Leitsätze
- Job-State lebt in der synchronisierten DB, nicht im Tab. Damit fallen Tab-Close-Resilienz, Multi-Device-Sicht, Resume-after-Offline und Audit automatisch ab.
- Server macht Extract, Client macht Encrypt. Das ehrt das At-Rest-
Modell — der Master-Key bleibt clientseitig, der Server sieht den
extrahierten Text nur kurz in einer Pickup-Inbox (gleicher Threat-
Model wie heute schon der
/extract-Endpoint). - Eine Code-Bahn für jede Ingestion. Single-URL und Bulk laufen nach Phase 7 durch denselben Worker — der QuickAdd-Pfad legt unter der Haube auch einen 1-URL-Job an.
- Soft → Hard. Schema- und Semantik-Migrationen kommen in zwei Commits: erst tolerant zu alten Rows, dann hartes Cleanup.
Architektur
┌──────────────────────────────────────────┐
│ /articles/import (List + JobDetail) │
│ pure liveQuery-View, UI macht keine │
│ Job-Logik selbst │
└─────────────────┬────────────────────────┘
│ liveQuery
▼
┌────────────────────────────────────────────────────────┐
│ Dexie + mana-sync (articles appId) │
│ │
│ articleImportJobs │
│ id, spaceId, totalUrls, status, leasedBy, │
│ leasedUntil, savedCount, duplicateCount, │
│ errorCount, warningCount, finishedAt │
│ │
│ articleImportItems │
│ id, jobId, spaceId, idx, url, state, articleId, │
│ warning, error, attempts, lastAttemptAt │
│ │
│ articleExtractPickup (kurzlebige Inbox) │
│ id, itemId, payload (extracted), createdAt │
└─────────────────┬───────────────────────┬──────────────┘
│ pending items │ pickup rows
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────┐
│ apps/api Extract-Worker │ │ Client Pickup-Consumer │
│ • snapshot der Items │ │ • liveQuery auf Pickup │
│ • Lease + Heartbeat │ │ • encryptRecord │
│ • Concurrency 3 / User │ │ • articleTable.add() │
│ • shared-rss extractFromUrl│ │ • item.state='saved' │
│ • schreibt Pickup-Row │ │ • pickup.delete() │
│ • setzt Item-State │ │ │
└─────────────────────────────┘ └──────────────────────────┘
Datenmodell
articleImportJobs (synced, articles appId)
interface LocalArticleImportJob extends BaseRecord {
totalUrls: number;
status: 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
/** Worker-Lease — verhindert dass mehrere Worker denselben Job ziehen.
* Server-Worker stempelt seine workerId beim Claim, erneuert die
* leasedUntil per Heartbeat. Lease-Ablauf > 60s = Job ist verfügbar. */
leasedBy: string | null;
leasedUntil: string | null;
startedAt: string | null;
finishedAt: string | null;
/** Counters werden vom Server beim Item-Übergang in einen Terminal-State
* inkrementiert. Pure Bookkeeping — Truth liegt in den Item-Rows, das
* hier ist die Cache-Spalte für die Liste. */
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
}
articleImportItems (synced, articles appId)
type ImportItemState =
| 'pending' // wartet auf den Worker
| 'extracting' // Worker hat geclaimed
| 'extracted' // Pickup-Row liegt für den Client bereit
| 'saved' // im Article-Table angekommen
| 'duplicate' // Article mit dieser URL gabs schon
| 'consent-wall' // gespeichert, aber Cookie-Wand erkannt
| 'error' // X Versuche fehlgeschlagen
| 'cancelled'; // Job abgebrochen vor Verarbeitung
interface LocalArticleImportItem extends BaseRecord {
jobId: string;
idx: number; // Reihenfolge aus der User-Eingabe
url: string; // PLAINTEXT — Server muss lesen können
state: ImportItemState;
articleId: string | null; // bei saved/duplicate gesetzt
warning: 'probable_consent_wall' | null;
error: string | null;
attempts: number;
lastAttemptAt: string | null;
}
url bleibt bewusst plaintext — der Server-Worker liest sie aus
sync_changes und kann nicht entschlüsseln. Gleiche Begründung wie bei
articles.originalUrl / newsArticles.originalUrl / links.originalUrl.
error bleibt plaintext, weil Fehlertexte technisch sind ("502 Bad
Gateway") und keinen User-Inhalt enthalten.
articleExtractPickup (synced, articles appId, kurzlebig)
interface LocalArticleExtractPickup extends BaseRecord {
itemId: string; // Pointer zum Item — auch dessen jobId
payload: ExtractedArticle; // PLAINTEXT — Server hat das eh
createdAt: string;
}
Inbox-Tabelle. Server schreibt rein, Client liest und löscht. Im Steady-State leer. TTL serverseitig: 24 h, dann GC.
Warum eine eigene Tabelle statt direkt articles schreiben? Der
Server hat keinen Master-Key — er kann den Article nicht verschlüsseln.
Pickup ist die Übergabe-Pufferzone, der Client holt sie ab und ruft die
existierende saveFromExtracted() auf, die encryptRecord() triggert.
Crypto-Registry
// articleImportJobs: keine User-typed Inhalte → plaintext-allowlist
// articleImportItems: url + error sind plaintext, sonst nichts schützenswert → plaintext-allowlist
// articleExtractPickup: payload wird gleich nach Apply gelöscht → plaintext-allowlist
Alle drei landen auf der plaintext-allowlist.ts, nicht in
ENCRYPTION_REGISTRY. Items und Job-Rows enthalten keine User-typed-
Felder die nicht eh schon plaintext bleiben müssten (URL fürs Routing,
Counters, Foreign Keys). Der eigentliche Article-Inhalt wandert wie
bisher verschlüsselt in articles.
Module-Config + Sync
modules/articles/module.config.ts bekommt drei neue Tabellen:
tables: [
{ name: 'articles' },
{ name: 'articleHighlights', syncName: 'highlights' },
{ name: 'articleTags' },
{ name: 'articleImportJobs', syncName: 'importJobs' },
{ name: 'articleImportItems', syncName: 'importItems' },
{ name: 'articleExtractPickup', syncName: 'extractPickup' },
]
Damit gehen sie automatisch durch den Standard-Sync-Pfad, RLS, field-level LWW. Keine neue Sync-Infrastruktur.
Server-Worker
Wo
apps/api/src/modules/articles/import-worker.ts, gestartet aus
apps/api/src/index.ts neben den Routes. Nicht in services/mana-ai
(falscher Scope) und nicht in services/mana-research (Provider-
Orchestrierung, kein Persistenz-Worker).
Konzept
Standard-Pattern aus services/mana-ai:
- Snapshot-Projektion — eine kleine Tabelle in
mana_platform.articles_imports-Schema diesync_changesfürappId='articles'undtableName ∈ {articleImportJobs, articleImportItems}zu Live-Records faltet (field-level LWW). Refreshed sich pro Tick. - Tick alle 2 s. Liest die Snapshot, sucht:
- Jobs mit
status='running'und (leasedBy=nullORleasedUntil < now) - dazu Items mit
state='pending'für diese Jobs
- Jobs mit
- Lease —
leasedByauf eigeneworkerIdsetzen,leasedUntil = now + 60s. Schreiben alssync_changes-Row mitactor=system,origin=system,source='articles-import-worker'. - Concurrency 3 pro Job — pro Tick max 3 Items in
state='extracting'schalten,extractFromUrl()aus@mana/shared-rssaufrufen. - Pickup-Write — bei Erfolg:
articleExtractPickup-Row schreiben + Item-State aufextracted. Bei Fehler:attempts += 1, wennattempts >= 3→state='error', sonst zurück aufpending. - Job-Completion — wenn alle Items eines Jobs in einem Terminal-State sind
(
saved | duplicate | consent-wall | error | cancelled), setzejob.status='done'+finishedAt. Counter-Spalten gleich mit aktualisieren. - Heartbeat — solange Items
extracting, alle 30 sleasedUntilerneuern.
Single-Instance-Garantie
pg_advisory_lock(<key>) über die Worker-Loop. Falls apps/api in mehreren
Instanzen läuft, nimmt nur eine den Lock und tickt. Andere idlen.
Counters: woher
Worker tracked Item-Übergänge und stempelt:
pending → extracted: keine Counter-Änderungextracted → saved(Client signalisiert):savedCount += 1extracted → duplicate(Client signalisiert):duplicateCount += 1extracted → consent-wall(Client signalisiert):warningCount += 1- jeder Übergang →
error:errorCount += 1
Counter-Updates gehen als normale articleImportJobs.update durch
sync_changes, RLS-correct.
Server-side Cleanup
Stündlicher GC-Job:
- Pickup-Rows älter 24 h löschen (Sicherheits-Cap)
- Jobs mit
status='done' AND finishedAt < now - 30darchivieren (späteres Polish — erst mal nur Cap)
Client-Pickup-Consumer
apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts,
gestartet aus data-layer-listeners.ts zusammen mit den anderen
Listener-Wirings.
Logik:
liveQuery(() => articleExtractPickup
.filter(r => !r.deletedAt)
.toArray()
).subscribe(rows => {
for (const row of rows) {
void consumeOne(row);
}
});
async function consumeOne(row: LocalArticleExtractPickup) {
// Re-entrancy guard via in-memory Set so multiple liveQuery ticks
// don't race the same row.
if (inFlight.has(row.id)) return;
inFlight.add(row.id);
try {
const item = await articleImportItemTable.get(row.itemId);
if (!item || item.state !== 'extracted') {
await articleExtractPickupTable.delete(row.id); // Stale row
return;
}
// Dedupe-Check für den Fall dass der User die URL parallel
// single-saved hat während der Job lief.
const existing = await articlesStore.findByUrl(row.payload.originalUrl);
if (existing) {
await articleImportItemTable.update(item.id, {
state: 'duplicate',
articleId: existing.id,
});
await articleExtractPickupTable.delete(row.id);
return;
}
const article = await articlesStore.saveFromExtracted(row.payload);
const nextState: ImportItemState =
row.payload.warning === 'probable_consent_wall'
? 'consent-wall'
: 'saved';
await articleImportItemTable.update(item.id, {
state: nextState,
articleId: article.id,
warning: row.payload.warning ?? null,
});
await articleExtractPickupTable.delete(row.id);
} finally {
inFlight.delete(row.id);
}
}
Multi-Tab: alle Tabs sehen Pickup-Rows. Web-Lock mana:articles:pickup
sorgt dafür dass nur ein Tab gleichzeitig konsumiert. Andere Tabs sehen
die liveQuery, der Lock-halter pickt ab.
Store-API
modules/articles/stores/imports.svelte.ts — neue Datei.
export const articleImportsStore = {
/** Erzeugt Job + N Items in einem Dexie bulkAdd, returns jobId. */
async createJob(urls: string[]): Promise<string> { … },
async pauseJob(jobId: string): Promise<void> { … },
async resumeJob(jobId: string): Promise<void> { … },
async cancelJob(jobId: string): Promise<void> { … },
/** Setzt alle Error-Items eines Jobs zurück auf pending. */
async retryFailed(jobId: string): Promise<void> { … },
/** Soft-Delete des Jobs + aller Items. Article-Rows bleiben. */
async deleteJob(jobId: string): Promise<void> { … },
};
saveFromExtracted in modules/articles/stores/articles.svelte.ts
bleibt der gemeinsame Kern — der Pickup-Consumer ruft sie genauso auf wie
der existierende Single-URL-Pfad.
URL-Parser steht im Store, nicht im Component:
export function parseUrls(raw: string): {
valid: string[];
invalid: string[];
duplicates: string[];
} { … }
Pure Funktion, unit-testbar.
UI
/articles/import — Index + Eingabe (+page.svelte)
<BulkImportForm>Komponente mit<textarea>, Live-Validierung als$derivedüberparseUrls().- Counter „X gültig · Y ungültig · Z Duplikate" über dem Submit-Button.
- Submit →
articleImportsStore.createJob(urls)→ goto/articles/import/[jobId]. - Unter dem Form:
<JobsList>mit aktiven + abgeschlossenen Jobs der letzten 30 Tage. Each row: Status-Pill, Counter, Click → Detail.
/articles/import/[jobId] — JobDetailView
- Header: Job-Status, Fortschrittsbalken, Counter („3 / 12 — 1 Duplikat, 1 Warnung").
- Action-Bar: Pause / Fortsetzen / Abbrechen / „Fehler erneut versuchen"
(nur wenn
errorCount > 0). - Liste der Items (virtuelle Scroll für > 100 Einträge): URL + State-Pill + Title (sobald gespeichert) + Action-Link (Öffnen / Erneut versuchen / Fehler-Detail-Tooltip).
Verlinkung
AddUrlForm(/articles/add) bekommt unter dem Input einen kleinen Link „Mehrere URLs auf einmal? → Bulk-Import".- Wenn der User in
AddUrlFormeinen Mehrzeiler einfügt, schlägt der Form vor: „4 URLs erkannt — als Bulk importieren?" → routet auf/articles/importmit pre-fill viasessionStorage. /articles/settingsbekommt eine vierte Karte „Mehrere URLs importieren" mit Link auf/articles/import.
Domain-Events
Zwei neue Events in data/events:
type ArticleImportStarted = {
type: 'ArticleImportStarted';
appId: 'articles';
collection: 'articleImportJobs';
recordId: string;
payload: { totalUrls: number };
};
type ArticleImportFinished = {
type: 'ArticleImportFinished';
appId: 'articles';
collection: 'articleImportJobs';
recordId: string;
payload: {
totalUrls: number;
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
};
};
Activity-Modul + Recap-Aggregator picken sie automatisch auf (über das bestehende Event-Bus-Pattern).
AI-Tool
Neuer Eintrag im AI_TOOL_CATALOG (packages/shared-ai/src/tools/schemas.ts):
{
name: 'import_articles_from_urls',
module: 'articles',
policy: 'auto', // Deterministisch, kein per-Article-Approval
schema: z.object({
urls: z.array(z.string().url()).min(1).max(50),
}),
describe: 'Ein Bulk-Import-Job für Artikel-URLs. Returns jobId zum Tracken.',
}
modules/articles/tools.ts registriert die execute-Function: ruft
articleImportsStore.createJob(urls) auf, returns { jobId, totalUrls }.
save_article (single-URL, propose) bleibt für „bitte speichere DIESEN
Artikel" Befehle. import_articles_from_urls ist für Listen.
Phasen
Phase 1 — Datenmodell (soft)
- Dexie-Version v56 (next free):
articleImportJobs,articleImportItems,articleExtractPickupdeclaren mit Indexen:articleImportJobs: 'id, status, [spaceId+status], createdAt'articleImportItems: 'id, jobId, [jobId+state], idx, state'articleExtractPickup: 'id, itemId, createdAt'
- Types in
modules/articles/types.tsergänzen. module.config.tsum die drei Tabellen erweitern.collections.tsum Table-Refs erweitern.- Plaintext-Allowlist-Eintrag für alle drei Tabellen.
- Smoke:
pnpm check:crypto+pnpm validate:allgrün.
Acceptance: Schema lädt, kein neuer Daten-Pfad aktiv. Bestehender Single-URL-Pfad läuft weiter.
Phase 2 — Server-Worker
apps/api/src/modules/articles/import-worker.tsmit:- Snapshot-Projektion (Mirror von
mana-ai/db/missions-projection.ts) - Tick-Loop, advisory-lock, Lease/Heartbeat
extractFromUrl()Aufruf, Pickup-Write, State-Transitions
- Snapshot-Projektion (Mirror von
- Migration
apps/api/drizzle/articles/<n>-imports-snapshot.sql:- Schema
articles_imports - Snapshot-Tabellen für jobs + items
- GC-Cron-Definition
- Schema
- Boot in
apps/api/src/index.ts:import { startArticleImportWorker } from './modules/articles/import-worker';startArticleImportWorker();
Acceptance: Manuelles Insert eines Job + Items via SQL → Worker
extracted → Pickup-Row erscheint → Item-State extracted.
Phase 3 — Client-Pickup-Consumer
modules/articles/consume-pickup.ts(s.o.).data/data-layer-listeners.tsregistriert den Consumer beim Boot.- Web-Lock
mana:articles:pickupfür Multi-Tab-Koordination. - Re-entrancy-Guard via in-memory Set.
Acceptance: Pickup-Row aus Phase 2 wird konsumiert, Article landet
verschlüsselt in articles, Item geht auf saved, Pickup-Row gelöscht.
Phase 4 — Store-API
modules/articles/stores/imports.svelte.tsmit allen Methoden.parseUrlsals pure Funktion + Unit-Tests.saveFromExtractedbleibt unverändert — wird re-used.
Acceptance: articleImportsStore.createJob(['url1', 'url2']) erzeugt
Job + 2 Items, Worker zieht sie, beide landen in articles.
Phase 5 — UI
/articles/import/+page.sveltemit Form + JobsList./articles/import/[jobId]/+page.sveltemit JobDetailView.BulkImportForm.svelte,JobsList.svelte,JobDetailView.svelte,ImportItemRow.svelte(alles inmodules/articles/components/).- Verlinkung in
AddUrlForm,/articles/settings. - Smart-Detect-Hint in
AddUrlFormfür Mehrzeiler-Paste. - i18n-Keys in
de.json+en.json(fr/it/es konsistent mit anderen Articles-Strings).
Acceptance: User pasted 5 URLs in <textarea>, klickt „Importieren",
sieht Detail-View mit Live-Updates, kann Pause/Fortsetzen drücken.
Phase 6 — Domain-Events + AI-Tool
ArticleImportStarted+ArticleImportFinishedindata/events/types.ts.- Worker emittet
Startedbeim ersten Item-Claim,Finishedbeim Job-Completion. import_articles_from_urlsinAI_TOOL_CATALOG+tools.ts.
Acceptance: Activity-Modul zeigt beide Events, AI-Tool funktioniert in einer Mission.
Phase 7 — Konvergenz (optional, nach Soak)
QuickAddInput+AddUrlFormlegen unter der Haube auch einen Job an (1 Item) statt direkt zu speichern.- Eine einzige Code-Bahn für jede Ingestion.
- Hard-Cleanup:
saveFromUrlentfernen, Aufrufer aufarticleImportsStore.createJob([url])migrieren. - Single-URL-Pfad navigiert zum Reader sobald der Pickup-Consumer das
saved-Event meldet (gleicher Code-Pfad, nur eine UI-Reaktion).
Acceptance: Heart-of-the-app smoke test — neuer User → URL pasten in QuickAddInput → Reader öffnet sich. Performance vergleichbar (max +200 ms Latenz akzeptabel, das ist der Worker-Tick).
Tests
Phase 1
parseUrls— gültige / ungültige / Duplikate / Whitespace-Varianten.
Phase 2 (Server)
- Worker pickt nur einen Job pro Lease — zweiter Worker-Stub kommt nicht ran.
- Lease-Renewal — Worker verlängert während Extracting.
- Lease-Expiry — toter Worker, Job wird wieder available.
- Item-Retry —
attempts < 3schickt zurück auf pending, danach error.
Phase 3 (Client)
- Pickup → encryptRecord → article.add. Mit
fake-indexeddb. - Multi-Tab Web-Lock — nur ein Tab konsumiert.
- Stale Pickup (Item nicht mehr
extracted) wird ignoriert + gelöscht. - Dedupe-Race: User hat URL parallel single-saved → Item wird
duplicate.
Phase 4 (Store)
- Full lifecycle: createJob → 3 Items → Worker-Mock → 2 saved + 1 error.
- retryFailed setzt nur Error-Items zurück.
- cancelJob setzt alle pending-Items auf
cancelled.
Phase 5 (UI)
- E2E Playwright: Bulk-Import mit 5 URLs, alle landen in der Article- List. Pause-Button stoppt Worker (in Test-Env mit Fake-Worker-Hook).
Phase 7 (Konvergenz)
- QuickAddInput-Pfad geht durch den Worker, Performance-Smoke (Latenz vom Klick bis Reader < 5 s im Local-Dev).
Bekannte Edge-Cases
- Worst-Case-Dauer: 50 URLs × ~25 s Server-Extract / Concurrency 3 ≈ 7 min. UI muss das ehrlich anzeigen. „Im Hintergrund — du kannst weitermachen."
- Consent-Wall im Bulk: Server flagged
warning, Item landet aufconsent-wall, JobDetailView zeigt Hinweis „N Artikel mit Cookie- Wand — mit Bookmarklet erneut speichern?". Bulk-Retry dieser Items ist ein späteres Polish, kein MVP-Blocker. - Eingabe-Duplikate:
parseUrlsdedupliziert vor Job-Erzeugung. - Bestehende Articles: Pickup-Consumer prüft per
findByUrlvorsaveFromExtracted— falls in der Zwischenzeit single-saved, geht das Item aufduplicate. - Worker-Crash mid-Item:
state='extracting'mit abgelaufener Lease wird vom nächsten Tick zurück aufpendinggesetzt. - Job-Cancel während Extract läuft: Worker prüft pro Item den
Job-Status vor dem Pickup-Write. Bei
status='cancelled'→ Item-State aufcancelled, kein Pickup. - Ratelimit auf
extractFromUrl: Der Server hat Rate-Limits pro User auf/api/v1/articles/extract(200/min). Worker geht nicht über HTTP — er ruftextractFromUrl()direkt aus dem Modul. Kein Rate-Limit-Konflikt. - Job-Liste wird zu lang: Soft-Delete-on-30-Days-old in Phase 7-Polish.
Was bewusst NICHT im Scope ist
- Cross-User-Resharing von Job-Definitionen (kein Use-Case)
- Server-side Re-Extraction (für Re-Index nach Readability-Updates) — separater Plan
- Bulk-Tagging im selben Schritt — der User taggt nach dem Import, nicht während
- Import aus Pocket / Instapaper / Raindrop-Backup-Dateien — eigenes Modul-Feature, später
Reference Implementations im Repo
- Server-Worker-Pattern:
services/mana-ai/src/runner/,services/mana-ai/src/db/missions-projection.ts - Snapshot-Projektion + Advisory-Lock:
services/mana-ai/src/db/snapshot-refresh.ts - Singleton-Bootstrap-via-sync_changes (Server-Write mit system actor):
services/mana-auth/src/services/bootstrap-singletons.ts - Local-only Listener-Wiring:
apps/mana/apps/web/src/lib/data/data-layer-listeners.ts - liveQuery-Driven UI:
apps/mana/apps/web/src/lib/modules/ai-missions/