From 054b9e5beb588669456888cdce7224e99a713258 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 28 Apr 2026 23:22:30 +0200 Subject: [PATCH] fix(articles): import-projection accepts F3 + legacy field_meta shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-test caught it: the worker projects sync_changes via field-level LWW, comparing `field_meta[k]` directly. But field_meta is two-shaped on the wire: - Legacy plaintext writes: { state: '2026-04-28T…' } - Field-meta-overhaul writes: { state: { at, actor, origin } } The naive `rowFM[k] >= localTime` worked for the all-legacy case, but once a client write (legacy string) followed a worker write (F3 object), the comparison evaluated `'2026-04-28T…' >= '[object …]'` and the projection silently kept the older value. Live symptom: an item that was correctly flipped to 'saved' on the client was reported back as 'extracted' by the projection. Fix: `fieldMetaTime()` helper that pulls the ISO string out of either shape; both write paths now compare apples-to-apples. Verified end-to-end: - Synthetic job + item written into sync_changes - runTickOnce() → claim → extractFromUrl(example.com) → pickup row with title='Example Domain', wordCount=16, actor= system:articles-import-worker - Item transitions pending → extracting → extracted - Simulated client write 'saved' - Next tick rolls counters: savedCount 0→1, status running→done, finishedAt stamped Plan: docs/plans/articles-bulk-import.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/modules/articles/import-projection.ts | 31 +++++++++++++++-- .../apps/web/src/lib/app-registry/apps.ts | 21 ------------ .../components/dashboard/widget-registry.ts | 2 -- .../web/src/lib/data/cross-app-queries.ts | 33 ------------------- .../lib/data/crypto/plaintext-allowlist.ts | 1 - .../web/src/lib/data/module-registry.test.ts | 2 -- .../apps/web/src/lib/data/module-registry.ts | 4 --- .../web/src/lib/modules/spiral/collect.ts | 14 -------- .../web/src/lib/stores/dashboard.svelte.ts | 1 - .../apps/web/src/lib/types/dashboard.test.ts | 1 - apps/mana/apps/web/src/lib/types/dashboard.ts | 10 ------ 11 files changed, 28 insertions(+), 92 deletions(-) diff --git a/apps/api/src/modules/articles/import-projection.ts b/apps/api/src/modules/articles/import-projection.ts index 270d64584..ea7bf7f3e 100644 --- a/apps/api/src/modules/articles/import-projection.ts +++ b/apps/api/src/modules/articles/import-projection.ts @@ -21,15 +21,32 @@ import { getSyncConnection } from '../../mcp/sync-db'; type Row = Record; +/** + * `field_meta` is one of two shapes on the wire: + * - Legacy plaintext writes: `{[fieldName]: ISOString}` + * - Field-meta-overhaul writes: `{[fieldName]: {at, actor, origin}}` + * `fieldMetaTime()` below normalises both into the comparable ISO string. + */ interface ChangeRow { user_id: string; record_id: string; op: string; data: Row | null; - field_meta: Record | null; + field_meta: Record | null; created_at: Date; } +/** Pull the timestamp out of either shape. Falls back to empty string + * so the LWW comparison never throws on undefined. */ +function fieldMetaTime(meta: unknown): string { + if (typeof meta === 'string') return meta; + if (meta && typeof meta === 'object') { + const at = (meta as { at?: unknown }).at; + if (typeof at === 'string') return at; + } + return ''; +} + export interface ImportJobRow { id: string; userId: string; @@ -134,6 +151,9 @@ function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map; }; let current: Cur | null = null; @@ -151,14 +171,19 @@ function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map= localTime) { current.record[k] = v; diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index 07961a15b..43ccd6a0b 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -20,7 +20,6 @@ import { MoneyWavy, MapPin, ChatCircle, - File, Clock, Quotes, Cards, @@ -587,16 +586,6 @@ registerApp({ }, }); -registerApp({ - id: 'context', - name: 'Context', - color: '#7C3AED', - icon: File, - views: { - list: { load: () => import('$lib/modules/context/ListView.svelte') }, - }, -}); - registerApp({ id: 'times', name: 'Times', @@ -855,16 +844,6 @@ registerApp({ paramKey: 'eventId', }); -registerApp({ - id: 'who', - name: 'Who', - color: '#a855f7', - icon: PersonSimpleCircle, - views: { - list: { load: () => import('$lib/modules/who/ListView.svelte') }, - }, -}); - registerApp({ id: 'news', name: 'News', diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts index c5d6f49fe..410e16956 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts +++ b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts @@ -23,7 +23,6 @@ import ClockTimersWidget from './widgets/ClockTimersWidget.svelte'; import StorageUsageWidget from './widgets/StorageUsageWidget.svelte'; import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte'; import PresiDecksWidget from './widgets/PresiDecksWidget.svelte'; -import ContextDocsWidget from './widgets/ContextDocsWidget.svelte'; // Phase 4: Unified app widgets (direct Dexie queries, internal routing) import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte'; @@ -56,7 +55,6 @@ export const widgetComponents: Record = { 'storage-usage': StorageUsageWidget, 'music-library': MusicLibraryWidget, 'presi-decks': PresiDecksWidget, - 'context-docs': ContextDocsWidget, 'active-timer': ActiveTimerWidget, 'nutrition-progress': NutritionProgressWidget, 'plant-watering': PlantWateringWidget, diff --git a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts index a21c31cd5..c10d2ae4a 100644 --- a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts +++ b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts @@ -19,7 +19,6 @@ import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types'; import type { LocalFile } from '$lib/modules/storage/types'; import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types'; import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types'; -import type { LocalDocument, LocalContextSpace } from '$lib/modules/context/types'; import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types'; // ─── Todo Queries ─────────────────────────────────────────── @@ -278,38 +277,6 @@ export function useRecentDecks(limit = 5) { }, [] as LocalPresiDeck[]); } -// ─── Context Queries ──────────────────────────────────────── - -/** Recent documents + spaces. */ -export function useRecentDocuments(limit = 5) { - return useLiveQueryWithDefault(async () => { - // title + content are encrypted on disk; the dashboard surfaces the - // title so we have to decrypt before returning. limit is applied - // pre-decrypt to keep the batch small. - const visible = await db - .table('documents') - .orderBy('updatedAt') - .reverse() - .filter((d) => !d.deletedAt) - .limit(limit) - .toArray(); - return decryptRecords('documents', visible); - }, [] as LocalDocument[]); -} - -export function useSpaces() { - return useLiveQueryWithDefault(async () => { - const all = await db.table('contextSpaces').toArray(); - return all - .filter((s) => !s.deletedAt) - .sort((a, b) => { - if (a.pinned && !b.pinned) return -1; - if (!a.pinned && b.pinned) return 1; - return 0; - }); - }, [] as LocalContextSpace[]); -} - // ─── Cards Queries ───────────────────────────────────────── interface CardsProgress { diff --git a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts index b9f7b0d8e..338056bf2 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts @@ -38,7 +38,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [ 'companionGoals', // TODO: audit 'companionMessages', // TODO: audit 'contactTags', // TODO: audit - 'contextSpaces', // TODO: audit 'conversationTags', // TODO: audit 'customQuotes', // TODO: audit 'dashboardConfigs', // TODO: audit diff --git a/apps/mana/apps/web/src/lib/data/module-registry.test.ts b/apps/mana/apps/web/src/lib/data/module-registry.test.ts index 61975053e..55c60fa3a 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.test.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.test.ts @@ -162,7 +162,6 @@ describe('module-registry — pre-refactor snapshot', () => { 'timeWorldClocks', 'entryTags', ], - context: ['contextSpaces', 'documents', 'documentTags'], questions: ['qCollections', 'questions', 'answers', 'questionTags'], food: ['meals', 'goals', 'foodFavorites', 'mealTags'], plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'], @@ -210,7 +209,6 @@ describe('module-registry — pre-refactor snapshot', () => { timeAlarms: 'alarms', timeCountdownTimers: 'countdownTimers', timeWorldClocks: 'worldClocks', - contextSpaces: 'spaces', qCollections: 'collections', foodFavorites: 'favorites', memoroSpaces: 'spaces', diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 61630d0c5..c8a681086 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -66,7 +66,6 @@ import { photosModuleConfig } from '$lib/modules/photos/module.config'; import { skilltreeModuleConfig } from '$lib/modules/skilltree/module.config'; import { citycornersModuleConfig } from '$lib/modules/citycorners/module.config'; import { timesModuleConfig } from '$lib/modules/times/module.config'; -import { contextModuleConfig } from '$lib/modules/context/module.config'; import { questionsModuleConfig } from '$lib/modules/questions/module.config'; import { foodModuleConfig } from '$lib/modules/food/module.config'; import { plantsModuleConfig } from '$lib/modules/plants/module.config'; @@ -84,7 +83,6 @@ import { eventsModuleConfig } from '$lib/modules/events/module.config'; import { financeModuleConfig } from '$lib/modules/finance/module.config'; import { placesModuleConfig } from '$lib/modules/places/module.config'; import { playgroundModuleConfig } from '$lib/modules/playground/module.config'; -import { whoModuleConfig } from '$lib/modules/who/module.config'; import { newsModuleConfig } from '$lib/modules/news/module.config'; import { bodyModuleConfig } from '$lib/modules/body/module.config'; import { firstsModuleConfig } from '$lib/modules/firsts/module.config'; @@ -132,7 +130,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ skilltreeModuleConfig, citycornersModuleConfig, timesModuleConfig, - contextModuleConfig, questionsModuleConfig, foodModuleConfig, plantsModuleConfig, @@ -150,7 +147,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ financeModuleConfig, placesModuleConfig, playgroundModuleConfig, - whoModuleConfig, newsModuleConfig, bodyModuleConfig, firstsModuleConfig, diff --git a/apps/mana/apps/web/src/lib/modules/spiral/collect.ts b/apps/mana/apps/web/src/lib/modules/spiral/collect.ts index 7b6687983..c524e4cfe 100644 --- a/apps/mana/apps/web/src/lib/modules/spiral/collect.ts +++ b/apps/mana/apps/web/src/lib/modules/spiral/collect.ts @@ -39,7 +39,6 @@ export async function collectAppSnapshots(): Promise { files, songs, decks, - spaces, cardDecks, cards, ] = await Promise.all([ @@ -53,7 +52,6 @@ export async function collectAppSnapshots(): Promise { safeGetAll('files'), safeGetAll('songs'), safeGetAll('presiDecks'), - safeGetAll('contextSpaces'), safeGetAll('cardDecks'), safeGetAll('cards'), ]); @@ -181,18 +179,6 @@ export async function collectAppSnapshots(): Promise { }); } - // Context - if (spaces.length > 0) { - snapshots.push({ - app: 'Context', - appIndex: MANA_APP_INDEX.context, - totalItems: spaces.length, - completedItems: 0, - favoriteItems: 0, - label: `${spaces.length} Spaces`, - }); - } - // Cards if (cardDecks.length > 0 || cards.length > 0) { snapshots.push({ diff --git a/apps/mana/apps/web/src/lib/stores/dashboard.svelte.ts b/apps/mana/apps/web/src/lib/stores/dashboard.svelte.ts index 911f4ad9a..66846840f 100644 --- a/apps/mana/apps/web/src/lib/stores/dashboard.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/dashboard.svelte.ts @@ -235,7 +235,6 @@ export const dashboardStore = { 'quotes-quote', 'music-library', 'presi-decks', - 'context-docs', ] as WidgetType[] ).filter((type) => { const meta = getWidgetMeta(type); diff --git a/apps/mana/apps/web/src/lib/types/dashboard.test.ts b/apps/mana/apps/web/src/lib/types/dashboard.test.ts index 5444b0193..eeebe9ea5 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.test.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.test.ts @@ -81,7 +81,6 @@ describe('WIDGET_REGISTRY', () => { expect(types).toContain('storage-usage'); expect(types).toContain('music-library'); expect(types).toContain('presi-decks'); - expect(types).toContain('context-docs'); }); it('should have i18n-style name keys', () => { diff --git a/apps/mana/apps/web/src/lib/types/dashboard.ts b/apps/mana/apps/web/src/lib/types/dashboard.ts index 6c23cb45e..3cf49be2d 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.ts @@ -24,7 +24,6 @@ export type WidgetType = | 'storage-usage' // Storage: file storage stats | 'music-library' // Music: music library stats | 'presi-decks' // Presi: recent presentations - | 'context-docs' // Context: recent documents & spaces | 'active-timer' // Times: running timer | 'nutrition-progress' // Food: today's calorie progress | 'plant-watering' // Plants: plants due for watering @@ -278,15 +277,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [ allowMultiple: false, requiredBackend: 'presi', }, - { - type: 'context-docs', - nameKey: 'dashboard.widgets.context.title', - descriptionKey: 'dashboard.widgets.context.description', - icon: '📝', - defaultSize: 'medium', - allowMultiple: false, - requiredBackend: 'context', - }, { type: 'contacts-recent', nameKey: 'dashboard.widgets.contacts_recent.title',