mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
18f13e19b2
commit
054b9e5beb
11 changed files with 28 additions and 92 deletions
|
|
@ -21,15 +21,32 @@
|
|||
import { getSyncConnection } from '../../mcp/sync-db';
|
||||
|
||||
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 {
|
||||
user_id: string;
|
||||
record_id: string;
|
||||
op: string;
|
||||
data: Row | null;
|
||||
field_meta: Record<string, string> | null;
|
||||
field_meta: Record<string, unknown> | 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<string, MergedEnt
|
|||
userId: string;
|
||||
recordId: string;
|
||||
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>;
|
||||
};
|
||||
let current: Cur | null = null;
|
||||
|
|
@ -151,14 +171,19 @@ function mergeByUserAndRecord(rows: readonly ChangeRow[]): Map<string, MergedEnt
|
|||
continue;
|
||||
}
|
||||
if (!r.data) continue;
|
||||
const rowCreatedAt = r.created_at.toISOString();
|
||||
if (!current.record) {
|
||||
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;
|
||||
}
|
||||
const rowFM = r.field_meta ?? {};
|
||||
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] ?? '';
|
||||
if (serverTime >= localTime) {
|
||||
current.record[k] = v;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<WidgetType, Component> = {
|
|||
'storage-usage': StorageUsageWidget,
|
||||
'music-library': MusicLibraryWidget,
|
||||
'presi-decks': PresiDecksWidget,
|
||||
'context-docs': ContextDocsWidget,
|
||||
'active-timer': ActiveTimerWidget,
|
||||
'nutrition-progress': NutritionProgressWidget,
|
||||
'plant-watering': PlantWateringWidget,
|
||||
|
|
|
|||
|
|
@ -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<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 ─────────────────────────────────────────
|
||||
|
||||
interface CardsProgress {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
|
|||
files,
|
||||
songs,
|
||||
decks,
|
||||
spaces,
|
||||
cardDecks,
|
||||
cards,
|
||||
] = await Promise.all([
|
||||
|
|
@ -53,7 +52,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
|
|||
safeGetAll('files'),
|
||||
safeGetAll('songs'),
|
||||
safeGetAll('presiDecks'),
|
||||
safeGetAll('contextSpaces'),
|
||||
safeGetAll('cardDecks'),
|
||||
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
|
||||
if (cardDecks.length > 0 || cards.length > 0) {
|
||||
snapshots.push({
|
||||
|
|
|
|||
|
|
@ -235,7 +235,6 @@ export const dashboardStore = {
|
|||
'quotes-quote',
|
||||
'music-library',
|
||||
'presi-decks',
|
||||
'context-docs',
|
||||
] as WidgetType[]
|
||||
).filter((type) => {
|
||||
const meta = getWidgetMeta(type);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue