mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(articles): bulk-import schema + plan (Phase 1)
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>
This commit is contained in:
parent
5535f2da48
commit
7bca16dfa7
59 changed files with 785 additions and 4249 deletions
585
docs/plans/articles-bulk-import.md
Normal file
585
docs/plans/articles-bulk-import.md
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
# 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
|
||||
|
||||
1. **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.
|
||||
2. **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).
|
||||
3. **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.
|
||||
4. **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)
|
||||
|
||||
```ts
|
||||
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)
|
||||
|
||||
```ts
|
||||
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)
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
// 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:
|
||||
|
||||
```ts
|
||||
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`:
|
||||
|
||||
1. **Snapshot-Projektion** — eine kleine Tabelle in `mana_platform.articles_imports`-Schema
|
||||
die `sync_changes` für `appId='articles'` und `tableName ∈ {articleImportJobs, articleImportItems}`
|
||||
zu Live-Records faltet (field-level LWW). Refreshed sich pro Tick.
|
||||
2. **Tick alle 2 s.** Liest die Snapshot, sucht:
|
||||
- Jobs mit `status='running'` und (`leasedBy=null` OR `leasedUntil < now`)
|
||||
- dazu Items mit `state='pending'` für diese Jobs
|
||||
3. **Lease** — `leasedBy` auf eigene `workerId` setzen, `leasedUntil = now + 60s`. Schreiben als
|
||||
`sync_changes`-Row mit `actor=system`, `origin=system`, `source='articles-import-worker'`.
|
||||
4. **Concurrency 3 pro Job** — pro Tick max 3 Items in `state='extracting'` schalten,
|
||||
`extractFromUrl()` aus `@mana/shared-rss` aufrufen.
|
||||
5. **Pickup-Write** — bei Erfolg: `articleExtractPickup`-Row schreiben +
|
||||
Item-State auf `extracted`. Bei Fehler: `attempts += 1`, wenn `attempts >= 3`
|
||||
→ `state='error'`, sonst zurück auf `pending`.
|
||||
6. **Job-Completion** — wenn alle Items eines Jobs in einem Terminal-State sind
|
||||
(`saved | duplicate | consent-wall | error | cancelled`), setze
|
||||
`job.status='done'` + `finishedAt`. Counter-Spalten gleich mit aktualisieren.
|
||||
7. **Heartbeat** — solange Items `extracting`, alle 30 s `leasedUntil` erneuern.
|
||||
|
||||
### 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-Änderung
|
||||
- `extracted → saved` (Client signalisiert): `savedCount += 1`
|
||||
- `extracted → duplicate` (Client signalisiert): `duplicateCount += 1`
|
||||
- `extracted → 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 - 30d` archivieren
|
||||
(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:
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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` über `parseUrls()`.
|
||||
- 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 `AddUrlForm` einen Mehrzeiler einfügt, schlägt der
|
||||
Form vor: „4 URLs erkannt — als Bulk importieren?" → routet auf
|
||||
`/articles/import` mit pre-fill via `sessionStorage`.
|
||||
- `/articles/settings` bekommt eine vierte Karte „Mehrere URLs
|
||||
importieren" mit Link auf `/articles/import`.
|
||||
|
||||
## Domain-Events
|
||||
|
||||
Zwei neue Events in `data/events`:
|
||||
|
||||
```ts
|
||||
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`):
|
||||
|
||||
```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)*
|
||||
|
||||
1. Dexie-Version v56 (next free): `articleImportJobs`, `articleImportItems`,
|
||||
`articleExtractPickup` declaren mit Indexen:
|
||||
- `articleImportJobs: 'id, status, [spaceId+status], createdAt'`
|
||||
- `articleImportItems: 'id, jobId, [jobId+state], idx, state'`
|
||||
- `articleExtractPickup: 'id, itemId, createdAt'`
|
||||
2. Types in `modules/articles/types.ts` ergänzen.
|
||||
3. `module.config.ts` um die drei Tabellen erweitern.
|
||||
4. `collections.ts` um Table-Refs erweitern.
|
||||
5. Plaintext-Allowlist-Eintrag für alle drei Tabellen.
|
||||
6. **Smoke**: `pnpm check:crypto` + `pnpm validate:all` grün.
|
||||
|
||||
Acceptance: Schema lädt, kein neuer Daten-Pfad aktiv. Bestehender
|
||||
Single-URL-Pfad läuft weiter.
|
||||
|
||||
### Phase 2 — Server-Worker
|
||||
|
||||
1. `apps/api/src/modules/articles/import-worker.ts` mit:
|
||||
- Snapshot-Projektion (Mirror von `mana-ai/db/missions-projection.ts`)
|
||||
- Tick-Loop, advisory-lock, Lease/Heartbeat
|
||||
- `extractFromUrl()` Aufruf, Pickup-Write, State-Transitions
|
||||
2. Migration `apps/api/drizzle/articles/<n>-imports-snapshot.sql`:
|
||||
- Schema `articles_imports`
|
||||
- Snapshot-Tabellen für jobs + items
|
||||
- GC-Cron-Definition
|
||||
3. 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
|
||||
|
||||
1. `modules/articles/consume-pickup.ts` (s.o.).
|
||||
2. `data/data-layer-listeners.ts` registriert den Consumer beim Boot.
|
||||
3. Web-Lock `mana:articles:pickup` für Multi-Tab-Koordination.
|
||||
4. 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
|
||||
|
||||
1. `modules/articles/stores/imports.svelte.ts` mit allen Methoden.
|
||||
2. `parseUrls` als pure Funktion + Unit-Tests.
|
||||
3. `saveFromExtracted` bleibt unverändert — wird re-used.
|
||||
|
||||
Acceptance: `articleImportsStore.createJob(['url1', 'url2'])` erzeugt
|
||||
Job + 2 Items, Worker zieht sie, beide landen in `articles`.
|
||||
|
||||
### Phase 5 — UI
|
||||
|
||||
1. `/articles/import/+page.svelte` mit Form + JobsList.
|
||||
2. `/articles/import/[jobId]/+page.svelte` mit JobDetailView.
|
||||
3. `BulkImportForm.svelte`, `JobsList.svelte`, `JobDetailView.svelte`,
|
||||
`ImportItemRow.svelte` (alles in `modules/articles/components/`).
|
||||
4. Verlinkung in `AddUrlForm`, `/articles/settings`.
|
||||
5. Smart-Detect-Hint in `AddUrlForm` für Mehrzeiler-Paste.
|
||||
6. 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
|
||||
|
||||
1. `ArticleImportStarted` + `ArticleImportFinished` in `data/events/types.ts`.
|
||||
2. Worker emittet `Started` beim ersten Item-Claim, `Finished` beim
|
||||
Job-Completion.
|
||||
3. `import_articles_from_urls` in `AI_TOOL_CATALOG` + `tools.ts`.
|
||||
|
||||
Acceptance: Activity-Modul zeigt beide Events, AI-Tool funktioniert
|
||||
in einer Mission.
|
||||
|
||||
### Phase 7 — Konvergenz *(optional, nach Soak)*
|
||||
|
||||
1. `QuickAddInput` + `AddUrlForm` legen unter der Haube auch einen Job
|
||||
an (1 Item) statt direkt zu speichern.
|
||||
2. Eine einzige Code-Bahn für jede Ingestion.
|
||||
3. Hard-Cleanup: `saveFromUrl` entfernen, Aufrufer auf
|
||||
`articleImportsStore.createJob([url])` migrieren.
|
||||
4. 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 < 3` schickt 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
|
||||
|
||||
1. **Worst-Case-Dauer**: 50 URLs × ~25 s Server-Extract / Concurrency 3
|
||||
≈ 7 min. UI muss das ehrlich anzeigen. „Im Hintergrund — du kannst
|
||||
weitermachen."
|
||||
2. **Consent-Wall im Bulk**: Server flagged `warning`, Item landet auf
|
||||
`consent-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.
|
||||
3. **Eingabe-Duplikate**: `parseUrls` dedupliziert vor Job-Erzeugung.
|
||||
4. **Bestehende Articles**: Pickup-Consumer prüft per `findByUrl` vor
|
||||
`saveFromExtracted` — falls in der Zwischenzeit single-saved, geht
|
||||
das Item auf `duplicate`.
|
||||
5. **Worker-Crash mid-Item**: `state='extracting'` mit abgelaufener
|
||||
Lease wird vom nächsten Tick zurück auf `pending` gesetzt.
|
||||
6. **Job-Cancel während Extract läuft**: Worker prüft pro Item den
|
||||
Job-Status vor dem Pickup-Write. Bei `status='cancelled'` →
|
||||
Item-State auf `cancelled`, kein Pickup.
|
||||
7. **Ratelimit auf `extractFromUrl`**: Der Server hat Rate-Limits pro
|
||||
User auf `/api/v1/articles/extract` (200/min). Worker geht nicht
|
||||
über HTTP — er ruft `extractFromUrl()` direkt aus dem Modul. Kein
|
||||
Rate-Limit-Konflikt.
|
||||
8. **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/`
|
||||
Loading…
Add table
Add a link
Reference in a new issue