fix(articles): import-projection accepts F3 + legacy field_meta shapes

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:22:30 +02:00
parent 18f13e19b2
commit 054b9e5beb
11 changed files with 28 additions and 92 deletions

View file

@ -21,15 +21,32 @@
import { getSyncConnection } from '../../mcp/sync-db'; import { getSyncConnection } from '../../mcp/sync-db';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
/**
* `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 { interface ChangeRow {
user_id: string; user_id: string;
record_id: string; record_id: string;
op: string; op: string;
data: Row | null; data: Row | null;
field_meta: Record<string, string> | null; field_meta: Record<string, unknown> | null;
created_at: Date; 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 { export interface ImportJobRow {
id: string; id: string;
userId: string; userId: string;
@ -134,6 +151,9 @@ function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map<string, MergedEnt
userId: string; userId: string;
recordId: string; recordId: string;
record: Row | null; record: Row | null;
/** Per-field LWW timestamps (normalised to ISO strings see
* fieldMetaTime). Both wire shapes are folded down to plain
* strings here so the projection comparison stays trivial. */
fm: Record<string, string>; fm: Record<string, string>;
}; };
let current: Cur | null = null; let current: Cur | null = null;
@ -151,14 +171,19 @@ function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map<string, MergedEnt
continue; continue;
} }
if (!r.data) continue; if (!r.data) continue;
const rowCreatedAt = r.created_at.toISOString();
if (!current.record) { if (!current.record) {
current.record = { id: r.record_id, ...r.data }; current.record = { id: r.record_id, ...r.data };
current.fm = { ...(r.field_meta ?? {}) }; const initFM = r.field_meta ?? {};
current.fm = {};
for (const k of Object.keys(initFM)) {
current.fm[k] = fieldMetaTime(initFM[k]) || rowCreatedAt;
}
continue; continue;
} }
const rowFM = r.field_meta ?? {}; const rowFM = r.field_meta ?? {};
for (const [k, v] of Object.entries(r.data)) { for (const [k, v] of Object.entries(r.data)) {
const serverTime = rowFM[k] ?? r.created_at.toISOString(); const serverTime = fieldMetaTime(rowFM[k]) || rowCreatedAt;
const localTime = current.fm[k] ?? ''; const localTime = current.fm[k] ?? '';
if (serverTime >= localTime) { if (serverTime >= localTime) {
current.record[k] = v; current.record[k] = v;

View file

@ -20,7 +20,6 @@ import {
MoneyWavy, MoneyWavy,
MapPin, MapPin,
ChatCircle, ChatCircle,
File,
Clock, Clock,
Quotes, Quotes,
Cards, 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({ registerApp({
id: 'times', id: 'times',
name: 'Times', name: 'Times',
@ -855,16 +844,6 @@ registerApp({
paramKey: 'eventId', paramKey: 'eventId',
}); });
registerApp({
id: 'who',
name: 'Who',
color: '#a855f7',
icon: PersonSimpleCircle,
views: {
list: { load: () => import('$lib/modules/who/ListView.svelte') },
},
});
registerApp({ registerApp({
id: 'news', id: 'news',
name: 'News', name: 'News',

View file

@ -23,7 +23,6 @@ import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte'; import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte'; import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte';
import PresiDecksWidget from './widgets/PresiDecksWidget.svelte'; import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
import ContextDocsWidget from './widgets/ContextDocsWidget.svelte';
// Phase 4: Unified app widgets (direct Dexie queries, internal routing) // Phase 4: Unified app widgets (direct Dexie queries, internal routing)
import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte'; import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte';
@ -56,7 +55,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'storage-usage': StorageUsageWidget, 'storage-usage': StorageUsageWidget,
'music-library': MusicLibraryWidget, 'music-library': MusicLibraryWidget,
'presi-decks': PresiDecksWidget, 'presi-decks': PresiDecksWidget,
'context-docs': ContextDocsWidget,
'active-timer': ActiveTimerWidget, 'active-timer': ActiveTimerWidget,
'nutrition-progress': NutritionProgressWidget, 'nutrition-progress': NutritionProgressWidget,
'plant-watering': PlantWateringWidget, 'plant-watering': PlantWateringWidget,

View file

@ -19,7 +19,6 @@ import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types'; import type { LocalFile } from '$lib/modules/storage/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types'; import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/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'; import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types';
// ─── Todo Queries ─────────────────────────────────────────── // ─── Todo Queries ───────────────────────────────────────────
@ -278,38 +277,6 @@ export function useRecentDecks(limit = 5) {
}, [] as LocalPresiDeck[]); }, [] 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<LocalDocument>('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<LocalContextSpace>('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 ───────────────────────────────────────── // ─── Cards Queries ─────────────────────────────────────────
interface CardsProgress { interface CardsProgress {

View file

@ -38,7 +38,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'companionGoals', // TODO: audit 'companionGoals', // TODO: audit
'companionMessages', // TODO: audit 'companionMessages', // TODO: audit
'contactTags', // TODO: audit 'contactTags', // TODO: audit
'contextSpaces', // TODO: audit
'conversationTags', // TODO: audit 'conversationTags', // TODO: audit
'customQuotes', // TODO: audit 'customQuotes', // TODO: audit
'dashboardConfigs', // TODO: audit 'dashboardConfigs', // TODO: audit

View file

@ -162,7 +162,6 @@ describe('module-registry — pre-refactor snapshot', () => {
'timeWorldClocks', 'timeWorldClocks',
'entryTags', 'entryTags',
], ],
context: ['contextSpaces', 'documents', 'documentTags'],
questions: ['qCollections', 'questions', 'answers', 'questionTags'], questions: ['qCollections', 'questions', 'answers', 'questionTags'],
food: ['meals', 'goals', 'foodFavorites', 'mealTags'], food: ['meals', 'goals', 'foodFavorites', 'mealTags'],
plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'], plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
@ -210,7 +209,6 @@ describe('module-registry — pre-refactor snapshot', () => {
timeAlarms: 'alarms', timeAlarms: 'alarms',
timeCountdownTimers: 'countdownTimers', timeCountdownTimers: 'countdownTimers',
timeWorldClocks: 'worldClocks', timeWorldClocks: 'worldClocks',
contextSpaces: 'spaces',
qCollections: 'collections', qCollections: 'collections',
foodFavorites: 'favorites', foodFavorites: 'favorites',
memoroSpaces: 'spaces', memoroSpaces: 'spaces',

View file

@ -66,7 +66,6 @@ import { photosModuleConfig } from '$lib/modules/photos/module.config';
import { skilltreeModuleConfig } from '$lib/modules/skilltree/module.config'; import { skilltreeModuleConfig } from '$lib/modules/skilltree/module.config';
import { citycornersModuleConfig } from '$lib/modules/citycorners/module.config'; import { citycornersModuleConfig } from '$lib/modules/citycorners/module.config';
import { timesModuleConfig } from '$lib/modules/times/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 { questionsModuleConfig } from '$lib/modules/questions/module.config';
import { foodModuleConfig } from '$lib/modules/food/module.config'; import { foodModuleConfig } from '$lib/modules/food/module.config';
import { plantsModuleConfig } from '$lib/modules/plants/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 { financeModuleConfig } from '$lib/modules/finance/module.config';
import { placesModuleConfig } from '$lib/modules/places/module.config'; import { placesModuleConfig } from '$lib/modules/places/module.config';
import { playgroundModuleConfig } from '$lib/modules/playground/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 { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config'; import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config'; import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
@ -132,7 +130,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
skilltreeModuleConfig, skilltreeModuleConfig,
citycornersModuleConfig, citycornersModuleConfig,
timesModuleConfig, timesModuleConfig,
contextModuleConfig,
questionsModuleConfig, questionsModuleConfig,
foodModuleConfig, foodModuleConfig,
plantsModuleConfig, plantsModuleConfig,
@ -150,7 +147,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
financeModuleConfig, financeModuleConfig,
placesModuleConfig, placesModuleConfig,
playgroundModuleConfig, playgroundModuleConfig,
whoModuleConfig,
newsModuleConfig, newsModuleConfig,
bodyModuleConfig, bodyModuleConfig,
firstsModuleConfig, firstsModuleConfig,

View file

@ -39,7 +39,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
files, files,
songs, songs,
decks, decks,
spaces,
cardDecks, cardDecks,
cards, cards,
] = await Promise.all([ ] = await Promise.all([
@ -53,7 +52,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
safeGetAll('files'), safeGetAll('files'),
safeGetAll('songs'), safeGetAll('songs'),
safeGetAll('presiDecks'), safeGetAll('presiDecks'),
safeGetAll('contextSpaces'),
safeGetAll('cardDecks'), safeGetAll('cardDecks'),
safeGetAll('cards'), safeGetAll('cards'),
]); ]);
@ -181,18 +179,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
}); });
} }
// 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 // Cards
if (cardDecks.length > 0 || cards.length > 0) { if (cardDecks.length > 0 || cards.length > 0) {
snapshots.push({ snapshots.push({

View file

@ -235,7 +235,6 @@ export const dashboardStore = {
'quotes-quote', 'quotes-quote',
'music-library', 'music-library',
'presi-decks', 'presi-decks',
'context-docs',
] as WidgetType[] ] as WidgetType[]
).filter((type) => { ).filter((type) => {
const meta = getWidgetMeta(type); const meta = getWidgetMeta(type);

View file

@ -81,7 +81,6 @@ describe('WIDGET_REGISTRY', () => {
expect(types).toContain('storage-usage'); expect(types).toContain('storage-usage');
expect(types).toContain('music-library'); expect(types).toContain('music-library');
expect(types).toContain('presi-decks'); expect(types).toContain('presi-decks');
expect(types).toContain('context-docs');
}); });
it('should have i18n-style name keys', () => { it('should have i18n-style name keys', () => {

View file

@ -24,7 +24,6 @@ export type WidgetType =
| 'storage-usage' // Storage: file storage stats | 'storage-usage' // Storage: file storage stats
| 'music-library' // Music: music library stats | 'music-library' // Music: music library stats
| 'presi-decks' // Presi: recent presentations | 'presi-decks' // Presi: recent presentations
| 'context-docs' // Context: recent documents & spaces
| 'active-timer' // Times: running timer | 'active-timer' // Times: running timer
| 'nutrition-progress' // Food: today's calorie progress | 'nutrition-progress' // Food: today's calorie progress
| 'plant-watering' // Plants: plants due for watering | 'plant-watering' // Plants: plants due for watering
@ -278,15 +277,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false, allowMultiple: false,
requiredBackend: 'presi', 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', type: 'contacts-recent',
nameKey: 'dashboard.widgets.contacts_recent.title', nameKey: 'dashboard.widgets.contacts_recent.title',