fix sync problems

This commit is contained in:
beo3000 2026-02-22 14:39:38 +01:00
parent 4106c63803
commit 60e38dc593
12 changed files with 255 additions and 14 deletions

View File

@ -33,7 +33,8 @@
"Bash(export:*)",
"Bash(curl:*)",
"Bash(rm -f 'C:\\\\work\\\\chrka\\\\myNote\\\\work\\\\_tmp_test_silent.ps1' && cd /c/work/chrka/myNote && git add ka-note/scripts/get-token.ps1 && git commit -m \"fix: persistent token cache in get-token.ps1\n\n- Fast path: reads from ~/.ka-note/token.txt if not expired \\(no MSAL needed\\)\n- On expiry: MSAL silent refresh with login hint\n- Fallback: interactive browser login \\(~once per 90 days\\)\n- Auto-saves every acquired token back to cache file\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>\" 2>&1)",
"Bash(cd /c/work/chrka/myNote && git rm -r --cached work/ 2>&1)"
"Bash(cd /c/work/chrka/myNote && git rm -r --cached work/ 2>&1)",
"Bash(sqlite3:*)"
]
}
}

View File

@ -1 +1 @@
1.0.34
1.0.38

View File

@ -132,6 +132,30 @@ async function pushAll(): Promise<void> {
if (!res.ok) throw new Error(`Push failed: ${res.status}`);
}
export interface TableStatus {
total: number;
active: number;
deleted: number;
lastUpdated: string | null;
}
export interface SyncStatusResult {
serverTimestamp: string;
userId: string;
tables: {
contexts: TableStatus;
topics: TableStatus;
historyEntries: TableStatus;
ratings: TableStatus;
};
}
export async function fetchSyncStatus(): Promise<SyncStatusResult> {
const res = await apiFetch('/api/sync/status', { method: 'GET' });
if (!res.ok) throw new Error(`Status fetch failed: ${res.status}`);
return res.json();
}
export async function fullSync(): Promise<void> {
await doSync(null);
}
@ -146,7 +170,8 @@ async function doSync(since: Date | null): Promise<void> {
syncStatus.set('syncing');
try {
await pushAll();
const serverTimestamp = await pullAndMerge(since);
const sinceWithBuffer = since ? new Date(since.getTime() - 60_000) : null;
const serverTimestamp = await pullAndMerge(sinceWithBuffer);
const syncTime = new Date(serverTimestamp);
lastSyncAt.set(syncTime);
localStorage.setItem('lastSyncAt', syncTime.toISOString());

View File

@ -1,3 +1,145 @@
<h1 class="mb-6 text-2xl font-bold text-accent">Einstellungen</h1>
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import { lastSyncAt, fetchSyncStatus, type SyncStatusResult } from '$lib/sync/syncService';
<div class="text-muted">Keine Einstellungen vorhanden.</div>
const counts$ = liveQuery(() => Promise.all([
db.contexts.count(),
db.topics.count(),
db.historyEntries.count(),
db.ratings.count(),
]));
let lastSync = $state<Date | null>(null);
let confirmReset = $state(false);
let serverStatus = $state<SyncStatusResult | null>(null);
let serverLoading = $state(false);
let serverError = $state<string | null>(null);
lastSyncAt.subscribe((v) => (lastSync = v));
async function loadServerStatus() {
serverLoading = true;
serverError = null;
try {
serverStatus = await fetchSyncStatus();
} catch (e) {
serverError = e instanceof Error ? e.message : String(e);
} finally {
serverLoading = false;
}
}
async function fullReset() {
await indexedDB.deleteDatabase('ka-note');
localStorage.removeItem('lastSyncAt');
location.reload();
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return iso.slice(0, 19).replace('T', ' ');
}
loadServerStatus();
</script>
<div class="space-y-6 max-w-lg">
<h1 class="text-xl font-bold text-white">Einstellungen</h1>
<!-- Local DB -->
<section class="rounded-lg bg-white/5 border border-border p-4 space-y-4">
<h2 class="text-sm font-semibold text-white uppercase tracking-wide">Lokale Datenbank</h2>
{#if $counts$}
{@const [ctxCount, topicCount, histCount, ratingCount] = $counts$}
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<dt class="text-muted">Contexts</dt><dd class="text-[#ccc]">{ctxCount}</dd>
<dt class="text-muted">Topics</dt><dd class="text-[#ccc]">{topicCount}</dd>
<dt class="text-muted">History</dt><dd class="text-[#ccc]">{histCount}</dd>
<dt class="text-muted">Ratings</dt><dd class="text-[#ccc]">{ratingCount}</dd>
<dt class="text-muted">Letzter Sync</dt><dd class="text-[#ccc]">{lastSync ? formatDate(lastSync.toISOString()) : '—'}</dd>
</dl>
{:else}
<p class="text-muted text-sm">Lade...</p>
{/if}
{#if !confirmReset}
<button
class="rounded px-3 py-1.5 text-sm bg-danger/20 text-danger hover:bg-danger/40 transition-colors"
onclick={() => confirmReset = true}
>
Full Reset
</button>
{:else}
<div class="rounded border border-danger/40 bg-danger/10 p-3 space-y-3">
<p class="text-sm text-white">Lokale DB und Sync-State loeschen und neu laden?</p>
<div class="flex gap-2">
<button
class="rounded px-3 py-1 text-sm bg-danger text-white hover:bg-danger/80 transition-colors"
onclick={fullReset}
>
Ja, zuruecksetzen
</button>
<button
class="rounded px-3 py-1 text-sm text-muted hover:text-white transition-colors"
onclick={() => confirmReset = false}
>
Abbrechen
</button>
</div>
</div>
{/if}
</section>
<!-- Server Status -->
<section class="rounded-lg bg-white/5 border border-border p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white uppercase tracking-wide">Server Status</h2>
<button
class="text-xs text-muted hover:text-white transition-colors disabled:opacity-40"
disabled={serverLoading}
onclick={loadServerStatus}
>
{serverLoading ? 'Lade...' : 'Aktualisieren'}
</button>
</div>
{#if serverError}
<p class="text-sm text-danger">{serverError}</p>
{:else if serverStatus}
<dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<dt class="text-muted">User ID</dt>
<dd class="text-[#ccc] font-mono text-xs break-all">{serverStatus.userId}</dd>
<dt class="text-muted">Server Time</dt>
<dd class="text-[#ccc]">{formatDate(serverStatus.serverTimestamp)}</dd>
</dl>
<table class="w-full text-xs border-collapse mt-2">
<thead>
<tr class="text-muted text-left">
<th class="pb-1 font-normal">Tabelle</th>
<th class="pb-1 font-normal text-right">Gesamt</th>
<th class="pb-1 font-normal text-right">Aktiv</th>
<th class="pb-1 font-normal text-right">Geloescht</th>
<th class="pb-1 font-normal text-right">Zuletzt</th>
</tr>
</thead>
<tbody>
{#each Object.entries(serverStatus.tables) as [name, s]}
<tr class="border-t border-white/5">
<td class="py-1 text-[#aaa]">{name}</td>
<td class="py-1 text-[#ccc] text-right">{s.total}</td>
<td class="py-1 text-[#ccc] text-right">{s.active}</td>
<td class="py-1 text-[#ccc] text-right">{s.deleted}</td>
<td class="py-1 text-muted text-right">{s.lastUpdated ? s.lastUpdated.slice(0, 10) : '—'}</td>
</tr>
{/each}
</tbody>
</table>
{:else if !serverLoading}
<p class="text-muted text-sm">Nicht geladen.</p>
{/if}
</section>
</div>

View File

@ -86,10 +86,14 @@
if (!entities.length) return;
deleteError = null;
try {
// Push soft-deletes to server before removing from local DB.
// Without this, items removed locally before the next auto-sync
// would never be pushed as soft-deleted, so other clients would
// never learn of the deletion.
await sync();
await hardDeleteEntities(entities);
selected = new Set();
confirming = false;
sync();
} catch (e) {
deleteError = e instanceof Error ? e.message : String(e);
confirming = false;

Binary file not shown.

Binary file not shown.

View File

@ -248,6 +248,60 @@ export async function pullChanges(request: SyncPullRequest, userId: string): Pro
};
}
export interface TableStatus {
total: number;
active: number;
deleted: number;
lastUpdated: string | null;
}
export interface SyncStatusResult {
serverTimestamp: string;
userId: string;
tables: {
contexts: TableStatus;
topics: TableStatus;
historyEntries: TableStatus;
ratings: TableStatus;
};
}
async function tableStatus(table: TableDef, userId: string): Promise<TableStatus> {
const rows = await db.select({
total: sql<number>`COUNT(*)`,
deleted: sql<number>`SUM(CASE WHEN ${table.deletedAt} IS NOT NULL THEN 1 ELSE 0 END)`,
lastUpdated: sql<string | null>`MAX(${table.updatedAt})`,
}).from(table).where(sql`${table.userId} = ${userId}`).get();
const total = rows?.total ?? 0;
const deleted = rows?.deleted ?? 0;
return {
total,
active: total - deleted,
deleted,
lastUpdated: rows?.lastUpdated ?? null,
};
}
export async function getStatus(userId: string): Promise<SyncStatusResult> {
const [ctxStatus, topicStatus, heStatus, ratingStatus] = await Promise.all([
tableStatus(contexts, userId),
tableStatus(topics, userId),
tableStatus(historyEntries, userId),
tableStatus(ratings, userId),
]);
return {
serverTimestamp: now(),
userId,
tables: {
contexts: ctxStatus,
topics: topicStatus,
historyEntries: heStatus,
ratings: ratingStatus,
},
};
}
export async function ensureDailyLog(userId: string): Promise<void> {
const existing = await db.select().from(contexts)
.where(and(sql`${contexts.id} = 'daily-log'`, eq(contexts.userId, userId)))

View File

@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { pushChanges, pullChanges, ensureDailyLog } from '../lib/sync-service.js';
import { pushChanges, pullChanges, ensureDailyLog, getStatus } from '../lib/sync-service.js';
import { isLocked } from '../lib/ai-export-service.js';
import type { SyncPushRequest, SyncPullRequest } from '@ka-note/shared';
import type { AuthEnv } from '../middleware/auth.js';
@ -24,4 +24,10 @@ sync.get('/pull', async (c) => {
return c.json(result);
});
sync.get('/status', async (c) => {
const { userId } = c.get('auth');
const result = await getStatus(userId);
return c.json(result);
});
export default sync;

View File

@ -1,8 +1,9 @@
import { Hono } from 'hono';
import { db } from '../db/connection.js';
import { contexts, topics, historyEntries, ratings } from '../db/schema.js';
import { and, eq, inArray } from 'drizzle-orm';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import type { AuthEnv } from '../middleware/auth.js';
import { sql } from 'drizzle-orm';
const trash = new Hono<AuthEnv>();
@ -11,7 +12,7 @@ interface EntityRef {
id: string;
}
/** Resolves all dependent IDs that must be deleted alongside the given entities. */
/** Resolves all dependent IDs that must be soft-deleted alongside the given entities. */
async function resolveCascade(entities: EntityRef[], userId: string) {
const contextIds = new Set(entities.filter(e => e.type === 'context').map(e => e.id));
const topicIds = new Set(entities.filter(e => e.type === 'topic').map(e => e.id));
@ -48,6 +49,9 @@ async function resolveCascade(entities: EntityRef[], userId: string) {
return { contextIds, topicIds, historyIds, ratingIds };
}
// Ensure rows are soft-deleted on server (tombstone for sync).
// Hard-deleting would break incremental pull on other clients — deleted rows
// would simply vanish with no tombstone, so other clients never learn of the deletion.
trash.delete('/', async (c) => {
const { userId } = c.get('auth');
const body = await c.req.json<{ entities: EntityRef[] }>();
@ -58,19 +62,24 @@ trash.delete('/', async (c) => {
}
const { contextIds, topicIds, historyIds, ratingIds } = await resolveCascade(entities, userId);
const deletedAt = new Date().toISOString();
// Delete in FK-safe order: ratings → history → topics → contexts
// Soft-delete any rows that don't already have deletedAt set
if (ratingIds.size > 0) {
await db.delete(ratings).where(and(eq(ratings.userId, userId), inArray(ratings.id, [...ratingIds])));
await db.update(ratings).set({ deletedAt, updatedAt: deletedAt, version: sql`${ratings.version} + 1` })
.where(and(eq(ratings.userId, userId), inArray(ratings.id, [...ratingIds]), isNull(ratings.deletedAt)));
}
if (historyIds.size > 0) {
await db.delete(historyEntries).where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds])));
await db.update(historyEntries).set({ deletedAt, updatedAt: deletedAt, version: sql`${historyEntries.version} + 1` })
.where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds]), isNull(historyEntries.deletedAt)));
}
if (topicIds.size > 0) {
await db.delete(topics).where(and(eq(topics.userId, userId), inArray(topics.id, [...topicIds])));
await db.update(topics).set({ deletedAt, updatedAt: deletedAt, version: sql`${topics.version} + 1` })
.where(and(eq(topics.userId, userId), inArray(topics.id, [...topicIds]), isNull(topics.deletedAt)));
}
if (contextIds.size > 0) {
await db.delete(contexts).where(and(eq(contexts.userId, userId), inArray(contexts.id, [...contextIds])));
await db.update(contexts).set({ deletedAt, updatedAt: deletedAt, version: sql`${contexts.version} + 1` })
.where(and(eq(contexts.userId, userId), inArray(contexts.id, [...contextIds]), isNull(contexts.deletedAt)));
}
const total = ratingIds.size + historyIds.size + topicIds.size + contextIds.size;

BIN
vorlagen/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
vorlagen/test1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB