feat(sync): F3 — drop updatedAt as a synced data field

Removes `updatedAt` from the wire protocol and from every Local-prefixed
record type. Replaced by two orthogonal mechanisms — deriveUpdatedAt()
for read-side public-facing values, _updatedAtIndex shadow for indexed
sorts.

Local-side:
- New `_updatedAtIndex` shadow column. Stamped by the Dexie creating /
  updating hook on every write. Stripped from the pending-change payload
  so it never travels to mana-sync. Indexed in Dexie v53 on the 22 tables
  that previously indexed `updatedAt`.
- `deriveUpdatedAt(record)` in sync.ts returns max(__fieldMeta[*].at) so
  the public-facing Task / Note / etc. shape keeps an `updatedAt: string`
  property without holding it as data.
- Type-converters across ~60 module/queries.ts and types.ts files now
  call `deriveUpdatedAt(local)` instead of reading `local.updatedAt`.

Module-store sweep:
- Regex codemod removed `updatedAt: new Date().toISOString()` /
  `: now` / `: now()` / `: nowIso()` stamping from 121 store files
  (~382 call sites total). Single-property update calls
  (`{ updatedAt: now }`) collapsed to `{}`; touch-only patterns
  (writing/drafts, writing/generations) kept the call as a no-op
  because the hook now stamps `_updatedAtIndex` automatically on
  any Dexie modification.
- Local* interfaces stripped of `updatedAt: string` (43 types.ts files).
  Public-facing types (Task, Note, Mission, Agent, …) keep
  `updatedAt: string` as a computed read-side property.
- Companion's chat conversation now sorts on a real
  `lastMessageAt` data field instead of touching `updatedAt`.
- Session-only stores (times/session-alarms, session-countdown-timers)
  stamp `updatedAt: now` directly because they're not in Dexie and
  have no field-meta layer to derive from.

Sync engine:
- applyServerChanges sets `_updatedAtIndex` itself when applying
  server changes (max of server-field times for updates, recordTime
  for inserts) so server-replays land orderable.
- Dropped the legacy `localUpdatedAt` fallback — every record now has
  `__fieldMeta`, the per-field at is the canonical source.
- Soft-delete tombstone path stops stamping `updatedAt: serverTime`,
  uses `_updatedAtIndex` instead.

Server-side:
- mana-ai iteration-writer no longer emits `updatedAt` in
  sync_changes.data; receivers derive it from the field-meta map.
- mana-sync types: no change (the wire format already uses
  `field_meta` / `at` from F1).

Out of scope: backend Drizzle schemas (mana-credits, mana-events, …)
keep their `updated_at` columns. Those are pure server-internal — not
part of the sync_changes / __fieldMeta mechanism F3 cleans up.

Tests + checks:
- 0 svelte-check errors over 7652 files.
- 29/29 sync.test.ts (vitest).
- 61 mana-ai bun tests.
- mana-sync go test ./... cached green.

Plan: docs/plans/sync-field-meta-overhaul.md F3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 23:12:22 +02:00
parent e2676252d3
commit 6bb9d77be9
207 changed files with 1381 additions and 831 deletions

View file

@ -12,6 +12,7 @@
*/ */
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { deriveUpdatedAt } from '$lib/data/sync';
import { wrapValue, unwrapValue } from '$lib/data/crypto/aes'; import { wrapValue, unwrapValue } from '$lib/data/crypto/aes';
import { getActiveKey } from '$lib/data/crypto/key-provider'; import { getActiveKey } from '$lib/data/crypto/key-provider';
import type { ByokProviderId } from '@mana/shared-llm'; import type { ByokProviderId } from '@mana/shared-llm';
@ -48,7 +49,7 @@ async function recordToPlain(rec: ByokKeyRecord): Promise<ByokKeyPlain> {
model: rec.model, model: rec.model,
isDefault: rec.isDefault, isDefault: rec.isDefault,
createdAt: rec.createdAt, createdAt: rec.createdAt,
updatedAt: rec.updatedAt, updatedAt: deriveUpdatedAt(rec),
lastUsedAt: rec.lastUsedAt, lastUsedAt: rec.lastUsedAt,
usageCount: rec.usageCount, usageCount: rec.usageCount,
totalTokens: rec.totalTokens, totalTokens: rec.totalTokens,
@ -75,7 +76,7 @@ export const byokVault = {
model: r.model, model: r.model,
isDefault: r.isDefault, isDefault: r.isDefault,
createdAt: r.createdAt, createdAt: r.createdAt,
updatedAt: r.updatedAt, updatedAt: deriveUpdatedAt(r),
lastUsedAt: r.lastUsedAt, lastUsedAt: r.lastUsedAt,
usageCount: r.usageCount, usageCount: r.usageCount,
totalTokens: r.totalTokens, totalTokens: r.totalTokens,

View file

@ -141,7 +141,7 @@ export async function backfillMissionsAgentId(targetAgentId: string): Promise<nu
const now = new Date().toISOString(); const now = new Date().toISOString();
await db.transaction('rw', table, async () => { await db.transaction('rw', table, async () => {
for (const m of pending) { for (const m of pending) {
await table.update(m.id, { agentId: targetAgentId, updatedAt: now }); await table.update(m.id, { agentId: targetAgentId });
} }
}); });

View file

@ -54,7 +54,6 @@ export async function saveAgentKontext(agentId: string, content: string): Promis
if (existing) { if (existing) {
const diff: Partial<LocalAgentKontextDoc> = { const diff: Partial<LocalAgentKontextDoc> = {
content, content,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord(TABLE, diff); await encryptRecord(TABLE, diff);
await db.table<LocalAgentKontextDoc>(TABLE).update(existing.id, diff); await db.table<LocalAgentKontextDoc>(TABLE).update(existing.id, diff);

View file

@ -147,7 +147,6 @@ export async function updateAgent(id: string, patch: AgentPatch): Promise<void>
} }
const mods: Partial<Agent> = { const mods: Partial<Agent> = {
...deepClone(patch), ...deepClone(patch),
updatedAt: new Date().toISOString(),
}; };
await encryptRecord(AGENTS_TABLE, mods); await encryptRecord(AGENTS_TABLE, mods);
await table().update(id, mods); await table().update(id, mods);
@ -156,15 +155,15 @@ export async function updateAgent(id: string, patch: AgentPatch): Promise<void>
// ── Lifecycle ────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────
export async function archiveAgent(id: string): Promise<void> { export async function archiveAgent(id: string): Promise<void> {
await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() }); await table().update(id, { state: 'archived' });
} }
export async function pauseAgent(id: string): Promise<void> { export async function pauseAgent(id: string): Promise<void> {
await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() }); await table().update(id, { state: 'paused' });
} }
export async function resumeAgent(id: string): Promise<void> { export async function resumeAgent(id: string): Promise<void> {
await table().update(id, { state: 'active', updatedAt: new Date().toISOString() }); await table().update(id, { state: 'active' });
} }
/** Soft-delete. Missions owned by the agent keep running; the Workbench /** Soft-delete. Missions owned by the agent keep running; the Workbench

View file

@ -119,7 +119,6 @@ export async function updateMission(id: string, patch: MissionPatch): Promise<vo
// Same Proxy-stripping reason as createMission. // Same Proxy-stripping reason as createMission.
const mods: Partial<Mission> = { const mods: Partial<Mission> = {
...deepClone(patch), ...deepClone(patch),
updatedAt: new Date().toISOString(),
}; };
if (patch.cadence) { if (patch.cadence) {
mods.nextRunAt = nextRunForCadence(patch.cadence, new Date()); mods.nextRunAt = nextRunForCadence(patch.cadence, new Date());
@ -130,7 +129,7 @@ export async function updateMission(id: string, patch: MissionPatch): Promise<vo
// ── Lifecycle ────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────
export async function pauseMission(id: string): Promise<void> { export async function pauseMission(id: string): Promise<void> {
await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() }); await table().update(id, { state: 'paused' });
} }
export async function resumeMission(id: string): Promise<void> { export async function resumeMission(id: string): Promise<void> {
@ -139,7 +138,6 @@ export async function resumeMission(id: string): Promise<void> {
await table().update(id, { await table().update(id, {
state: 'active', state: 'active',
nextRunAt: nextRunForCadence(mission.cadence, new Date()), nextRunAt: nextRunForCadence(mission.cadence, new Date()),
updatedAt: new Date().toISOString(),
}); });
} }
@ -147,12 +145,11 @@ export async function completeMission(id: string): Promise<void> {
await table().update(id, { await table().update(id, {
state: 'done', state: 'done',
nextRunAt: undefined, nextRunAt: undefined,
updatedAt: new Date().toISOString(),
}); });
} }
export async function archiveMission(id: string): Promise<void> { export async function archiveMission(id: string): Promise<void> {
await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() }); await table().update(id, { state: 'archived' });
} }
export async function deleteMission(id: string): Promise<void> { export async function deleteMission(id: string): Promise<void> {
@ -173,7 +170,6 @@ export async function setMissionGrant(
// attached — matches the pattern used in createMission / updateMission. // attached — matches the pattern used in createMission / updateMission.
await table().update(id, { await table().update(id, {
grant: deepClone(grant), grant: deepClone(grant),
updatedAt: new Date().toISOString(),
}); });
} }
@ -183,7 +179,6 @@ export async function setMissionGrant(
export async function revokeMissionGrant(id: string): Promise<void> { export async function revokeMissionGrant(id: string): Promise<void> {
await table().update(id, { await table().update(id, {
grant: undefined, grant: undefined,
updatedAt: new Date().toISOString(),
}); });
} }
@ -213,7 +208,6 @@ export async function startIteration(
}; };
await table().update(missionId, { await table().update(missionId, {
iterations: [...mission.iterations, iteration], iterations: [...mission.iterations, iteration],
updatedAt: now,
}); });
return iteration; return iteration;
} }
@ -242,7 +236,7 @@ export async function setIterationPhase(
} }
: it : it
); );
await table().update(missionId, { iterations: updated, updatedAt: now }); await table().update(missionId, { iterations: updated });
} catch (err) { } catch (err) {
console.warn('[mission-store] setIterationPhase failed:', err); console.warn('[mission-store] setIterationPhase failed:', err);
} }
@ -263,7 +257,6 @@ export async function requestIterationCancel(
); );
await table().update(missionId, { await table().update(missionId, {
iterations: updated, iterations: updated,
updatedAt: new Date().toISOString(),
}); });
} }
@ -317,7 +310,6 @@ export async function finishIteration(
iterations: updatedIterations, iterations: updatedIterations,
// Advance nextRunAt now that this iteration is done // Advance nextRunAt now that this iteration is done
nextRunAt: nextRunForCadence(mission.cadence, new Date()), nextRunAt: nextRunForCadence(mission.cadence, new Date()),
updatedAt: new Date().toISOString(),
}); });
} }
@ -334,6 +326,5 @@ export async function addIterationFeedback(
); );
await table().update(missionId, { await table().update(missionId, {
iterations: updatedIterations, iterations: updatedIterations,
updatedAt: new Date().toISOString(),
}); });
} }

View file

@ -153,7 +153,7 @@ async function restore(id: string): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0)); await new Promise<void>((resolve) => setTimeout(resolve, 0));
const now = new Date().toISOString(); const now = new Date().toISOString();
const updates: Record<string, unknown> = { updatedAt: now }; const updates: Record<string, unknown> = {};
for (const [field, info] of Object.entries(conflict.fields)) { for (const [field, info] of Object.entries(conflict.fields)) {
updates[field] = info.wasLocal; updates[field] = info.wasLocal;

View file

@ -1157,10 +1157,10 @@ db.version(48).upgrade(async (tx) => {
} }
const survivorAppCount = Array.isArray(survivor.openApps) ? survivor.openApps.length : 0; const survivorAppCount = Array.isArray(survivor.openApps) ? survivor.openApps.length : 0;
if (merged.length !== survivorAppCount) { if (merged.length !== survivorAppCount) {
await tx.table('workbenchScenes').update(survivor.id, { openApps: merged, updatedAt: now }); await tx.table('workbenchScenes').update(survivor.id, { openApps: merged });
} }
for (const loser of losers) { for (const loser of losers) {
await tx.table('workbenchScenes').update(loser.id, { deletedAt: now, updatedAt: now }); await tx.table('workbenchScenes').update(loser.id, { deletedAt: now });
removed += 1; removed += 1;
} }
} }
@ -1260,6 +1260,97 @@ db.version(52).stores({
lastsCooldown: 'id, refTable, dismissedAt, [refTable+refId]', lastsCooldown: 'id, refTable, dismissedAt, [refTable+refId]',
}); });
// v53 — Sync Field-Meta Overhaul F3 (docs/plans/sync-field-meta-overhaul.md).
// `updatedAt` is no longer a synced data field; replaced by a non-synced
// shadow column `_updatedAtIndex` that the Dexie creating/updating hook
// stamps on every write. All 22 tables that previously indexed `updatedAt`
// now index `_updatedAtIndex` so module queries can keep using
// `.orderBy(...)` for sort. The upgrade step copies the existing
// updatedAt value into _updatedAtIndex so existing local rows stay
// orderable across the version bump.
db.version(53)
.stores({
conversations: 'id, isArchived, isPinned, spaceId, templateId, _updatedAtIndex',
images:
'id, isFavorite, isPublic, isArchived, prompt, _updatedAtIndex, wardrobeOutfitId, wardrobeGarmentId',
songs: 'id, artist, album, genre, favorite, title, _updatedAtIndex',
mukkePlaylists: 'id, name, _updatedAtIndex',
presiDecks: 'id, isPublic, _updatedAtIndex',
documents: 'id, contextSpaceId, type, pinned, title, [contextSpaceId+type], _updatedAtIndex',
playgroundConversations: 'id, model, isPinned, _updatedAtIndex',
journalEntries: 'id, entryDate, mood, isPinned, isArchived, isFavorite, _updatedAtIndex',
dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, _updatedAtIndex',
dreamSymbols: 'id, name, count, _updatedAtIndex',
periods: 'id, startDate, endDate, isPredicted, isArchived, _updatedAtIndex',
periodSymptoms: 'id, name, category, count, _updatedAtIndex',
notes: 'id, isPinned, isArchived, color, title, _updatedAtIndex',
quizzes: 'id, isPinned, isArchived, _updatedAtIndex',
userTagPresets: 'id, userId, isDefault, _updatedAtIndex',
websites: 'id, slug, publishedVersion, _updatedAtIndex, deletedAt',
websitePages: 'id, siteId, [siteId+order], [siteId+path], _updatedAtIndex, deletedAt',
websiteBlocks:
'id, pageId, parentBlockId, [pageId+order], [pageId+parentBlockId+order], type, _updatedAtIndex, deletedAt',
writingDrafts: 'id, kind, status, _updatedAtIndex, isFavorite',
writingStyles: 'id, source, isSpaceDefault, isFavorite, _updatedAtIndex',
})
.upgrade(async (tx) => {
// Copy the existing `updatedAt` value into `_updatedAtIndex` for every
// row in the affected tables so post-upgrade sorts continue to land
// in the right order. Rows without an updatedAt fall back to
// `__fieldMeta` argmax, then to createdAt, then to empty string.
const tables = [
'conversations',
'images',
'songs',
'mukkePlaylists',
'presiDecks',
'documents',
'playgroundConversations',
'journalEntries',
'dreams',
'dreamSymbols',
'periods',
'periodSymptoms',
'notes',
'quizzes',
'userTagPresets',
'websites',
'websitePages',
'websiteBlocks',
'writingDrafts',
'writingStyles',
];
for (const tableName of tables) {
await tx
.table(tableName)
.toCollection()
.modify((row: Record<string, unknown>) => {
const updatedAt =
(typeof row.updatedAt === 'string' ? row.updatedAt : undefined) ??
deriveFromFieldMeta(row) ??
(typeof row.createdAt === 'string' ? row.createdAt : undefined) ??
'';
row._updatedAtIndex = updatedAt;
// Keep `updatedAt` on the row for now — the F3 store/type
// sweep below renames every read. The next-version
// upgrade (or a follow-up cleanup) can drop it once all
// modules read via the type-converter helper.
});
}
});
/** Local helper for the v53 upgrade read `__fieldMeta` argmax `at`
* without importing the sync layer (which would create a cycle here). */
function deriveFromFieldMeta(row: Record<string, unknown>): string | undefined {
const meta = row[FIELD_META_KEY];
if (!meta || typeof meta !== 'object') return undefined;
let max = '';
for (const fm of Object.values(meta as Record<string, { at?: string }>)) {
if (fm && typeof fm.at === 'string' && fm.at > max) max = fm.at;
}
return max || undefined;
}
// ─── Sync Routing ────────────────────────────────────────── // ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module // toSyncName() and fromSyncName() are now derived from per-module
@ -1403,8 +1494,17 @@ function trackActivity(
*/ */
export const FIELD_META_KEY = '__fieldMeta'; export const FIELD_META_KEY = '__fieldMeta';
/**
* Local-only shadow column the Dexie hook stamps on every create + update
* write. Holds the latest `now` ISO so module queries can `.orderBy(...)`
* by it instead of the (now derived) `updatedAt`. Stripped from
* pending-change payloads so it never travels to mana-sync it is rebuilt
* locally on each write, including server-replays via applyServerChanges.
*/
export const UPDATED_AT_INDEX_KEY = '_updatedAtIndex';
function isInternalKey(key: string): boolean { function isInternalKey(key: string): boolean {
return key === 'id' || key === FIELD_META_KEY; return key === 'id' || key === FIELD_META_KEY || key === UPDATED_AT_INDEX_KEY;
} }
/** /**
@ -1513,9 +1613,16 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
fieldMeta[key] = makeFieldMeta(now, actor, origin); fieldMeta[key] = makeFieldMeta(now, actor, origin);
} }
objRecord[FIELD_META_KEY] = fieldMeta; objRecord[FIELD_META_KEY] = fieldMeta;
// Stamp the local-only shadow column for indexed sorts. Stripped
// from `dataForSync` below so it never lands in pending-changes.
objRecord[UPDATED_AT_INDEX_KEY] = now;
// Build payload for pending-change WITHOUT the internal bookkeeping field. // Build payload for pending-change WITHOUT the internal bookkeeping fields.
const { [FIELD_META_KEY]: _fm, ...dataForSync } = obj as Record<string, unknown>; const {
[FIELD_META_KEY]: _fm,
[UPDATED_AT_INDEX_KEY]: _uai,
...dataForSync
} = obj as Record<string, unknown>;
trackPendingChange(tableName, { trackPendingChange(tableName, {
appId, appId,
@ -1604,9 +1711,12 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
// Returning an object from a Dexie 'updating' hook merges it into the // Returning an object from a Dexie 'updating' hook merges it into the
// modifications applied to the record — use this to persist the merged // modifications applied to the record — use this to persist the merged
// __fieldMeta alongside the user's data update. // __fieldMeta alongside the user's data update, plus refresh the
// local-only `_updatedAtIndex` shadow so indexed sorts see the
// latest write.
return { return {
[FIELD_META_KEY]: newMeta, [FIELD_META_KEY]: newMeta,
[UPDATED_AT_INDEX_KEY]: now,
}; };
}); });
} }

View file

@ -175,7 +175,6 @@ export function useDetailEntity<T extends { id?: string }>(
toastStore.undo(label, () => { toastStore.undo(label, () => {
db.table(opts.table!).update(id, { db.table(opts.table!).update(id, {
deletedAt: undefined, deletedAt: undefined,
updatedAt: new Date().toISOString(),
}); });
}); });
} else { } else {

View file

@ -74,21 +74,18 @@ function createCollectionWrapper<T extends BaseRecord>(tableName: string) {
await table.put({ await table.put({
...record, ...record,
createdAt: record.createdAt ?? now, createdAt: record.createdAt ?? now,
updatedAt: now,
}); });
}, },
async update(id: string, changes: Partial<T>): Promise<void> { async update(id: string, changes: Partial<T>): Promise<void> {
await table.update(id, { await table.update(id, {
...changes, ...changes,
updatedAt: new Date().toISOString(),
} as any); } as any);
}, },
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await table.update(id, { await table.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any); } as any);
}, },

View file

@ -65,7 +65,6 @@ export async function seedWorkbenchHomeOn(
openApps: DEFAULT_HOME_APPS, openApps: DEFAULT_HOME_APPS,
order: 0, order: 0,
createdAt: now, createdAt: now,
updatedAt: now,
spaceId, spaceId,
}; };
await table.add(row); await table.add(row);

View file

@ -21,6 +21,7 @@ import {
fromSyncName, fromSyncName,
beginApplyingTables, beginApplyingTables,
FIELD_META_KEY, FIELD_META_KEY,
UPDATED_AT_INDEX_KEY,
setPendingChangeListener, setPendingChangeListener,
} from './database'; } from './database';
import { isQuotaError, cleanupTombstones, notifyQuotaExceeded } from './quota'; import { isQuotaError, cleanupTombstones, notifyQuotaExceeded } from './quota';
@ -44,6 +45,30 @@ export function readFieldMeta(record: unknown): Record<string, FieldMeta> {
return fm && typeof fm === 'object' ? (fm as Record<string, FieldMeta>) : {}; return fm && typeof fm === 'object' ? (fm as Record<string, FieldMeta>) : {};
} }
/**
* Derive a record's "last modified" timestamp from its `__fieldMeta`
* map. Returns the max `at` across all per-field entries empty
* string when the record has no field-meta yet (only seen on records
* mid-write or pre-hook).
*
* Replaces the older synced `updatedAt` data field. F3 of the
* sync-field-meta overhaul moved updatedAt from a wire-protocol
* property to a read-side computed property type converters call
* this to populate the public-facing `updatedAt` from the merged
* field-meta map.
*
* For Dexie indexed sorting, prefer the non-synced `_updatedAtIndex`
* shadow column the creating/updating hook stamps on every write.
*/
export function deriveUpdatedAt(record: unknown): string {
const meta = readFieldMeta(record);
let max = '';
for (const fm of Object.values(meta)) {
if (fm.at > max) max = fm.at;
}
return max;
}
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
/** Operations the sync protocol supports. */ /** Operations the sync protocol supports. */
@ -370,12 +395,11 @@ export async function applyServerChanges(
const tombActor: Actor = change.actor ?? USER_ACTOR; const tombActor: Actor = change.actor ?? USER_ACTOR;
await table.update(recordId, { await table.update(recordId, {
deletedAt: serverTime, deletedAt: serverTime,
updatedAt: serverTime,
[FIELD_META_KEY]: { [FIELD_META_KEY]: {
...localMeta, ...localMeta,
deletedAt: makeFieldMeta(serverTime, tombActor, replayOrigin), deletedAt: makeFieldMeta(serverTime, tombActor, replayOrigin),
updatedAt: makeFieldMeta(serverTime, tombActor, replayOrigin),
}, },
[UPDATED_AT_INDEX_KEY]: serverTime,
}); });
} }
} else { } else {
@ -405,17 +429,16 @@ export async function applyServerChanges(
...changeData, ...changeData,
id: recordId, id: recordId,
[FIELD_META_KEY]: fieldMeta, [FIELD_META_KEY]: fieldMeta,
[UPDATED_AT_INDEX_KEY]: recordTime,
}); });
} else { } else {
const localMeta = readFieldMeta(existing); const localMeta = readFieldMeta(existing);
const localUpdatedAt =
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
const newMeta: Record<string, FieldMeta> = { ...localMeta }; const newMeta: Record<string, FieldMeta> = { ...localMeta };
for (const [key, val] of Object.entries(changeData)) { for (const [key, val] of Object.entries(changeData)) {
if (key === 'id' || key === FIELD_META_KEY) continue; if (key === 'id' || key === FIELD_META_KEY) continue;
const localFieldTime = localMeta[key]?.at ?? localUpdatedAt; const localFieldTime = localMeta[key]?.at ?? '';
if (recordTime >= localFieldTime) { if (recordTime >= localFieldTime) {
// Conflict signal: server STRICTLY wins (>), the local // Conflict signal: server STRICTLY wins (>), the local
// field had a non-empty value that differs from the new // field had a non-empty value that differs from the new
@ -448,6 +471,7 @@ export async function applyServerChanges(
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
updates[FIELD_META_KEY] = newMeta; updates[FIELD_META_KEY] = newMeta;
updates[UPDATED_AT_INDEX_KEY] = recordTime;
await table.update(recordId, updates); await table.update(recordId, updates);
} }
} }
@ -464,23 +488,26 @@ export async function applyServerChanges(
const record: Record<string, unknown> = { id: recordId }; const record: Record<string, unknown> = { id: recordId };
const fieldMeta: Record<string, FieldMeta> = {}; const fieldMeta: Record<string, FieldMeta> = {};
const fallback = new Date().toISOString(); const fallback = new Date().toISOString();
let maxAt = '';
for (const [key, fc] of Object.entries(serverFields)) { for (const [key, fc] of Object.entries(serverFields)) {
record[key] = fc.value; record[key] = fc.value;
fieldMeta[key] = makeFieldMeta(fc.at ?? fallback, changeActor, replayOrigin); const at = fc.at ?? fallback;
fieldMeta[key] = makeFieldMeta(at, changeActor, replayOrigin);
if (at > maxAt) maxAt = at;
} }
record[FIELD_META_KEY] = fieldMeta; record[FIELD_META_KEY] = fieldMeta;
record[UPDATED_AT_INDEX_KEY] = maxAt || fallback;
await table.put(record); await table.put(record);
} else { } else {
// Per-field comparison. // Per-field comparison.
const localMeta = readFieldMeta(existing); const localMeta = readFieldMeta(existing);
const localUpdatedAt =
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
const newMeta: Record<string, FieldMeta> = { ...localMeta }; const newMeta: Record<string, FieldMeta> = { ...localMeta };
let maxApplied = '';
for (const [key, fc] of Object.entries(serverFields)) { for (const [key, fc] of Object.entries(serverFields)) {
const serverTime = fc.at ?? ''; const serverTime = fc.at ?? '';
const localFieldTime = localMeta[key]?.at ?? localUpdatedAt; const localFieldTime = localMeta[key]?.at ?? '';
if (serverTime >= localFieldTime) { if (serverTime >= localFieldTime) {
// Same conflict criteria as the insert-as-update path: // Same conflict criteria as the insert-as-update path:
// strictly newer + non-empty local + actually different // strictly newer + non-empty local + actually different
@ -506,10 +533,12 @@ export async function applyServerChanges(
} }
updates[key] = fc.value; updates[key] = fc.value;
newMeta[key] = makeFieldMeta(serverTime, changeActor, replayOrigin); newMeta[key] = makeFieldMeta(serverTime, changeActor, replayOrigin);
if (serverTime > maxApplied) maxApplied = serverTime;
} }
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
updates[FIELD_META_KEY] = newMeta; updates[FIELD_META_KEY] = newMeta;
if (maxApplied) updates[UPDATED_AT_INDEX_KEY] = maxApplied;
await table.update(recordId, updates); await table.update(recordId, updates);
} }
} }

View file

@ -70,7 +70,7 @@ async function clearDefaultFlag(userId: string, exceptId?: string): Promise<void
.and((p) => p.isDefault && p.id !== exceptId) .and((p) => p.isDefault && p.id !== exceptId)
.toArray(); .toArray();
for (const row of rows) { for (const row of rows) {
await table.update(row.id, { isDefault: false, updatedAt: now() }); await table.update(row.id, { isDefault: false });
} }
} }
@ -85,7 +85,6 @@ export const tagPresetsStore = {
isDefault: input.isDefault ?? false, isDefault: input.isDefault ?? false,
tags: input.tags ?? [], tags: input.tags ?? [],
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp,
}; };
if (newLocal.isDefault) await clearDefaultFlag(userId, newLocal.id); if (newLocal.isDefault) await clearDefaultFlag(userId, newLocal.id);
@ -108,21 +107,20 @@ export const tagPresetsStore = {
...(input.name !== undefined && { name: input.name }), ...(input.name !== undefined && { name: input.name }),
...(input.tags !== undefined && { tags: input.tags }), ...(input.tags !== undefined && { tags: input.tags }),
...(input.isDefault !== undefined && { isDefault: input.isDefault }), ...(input.isDefault !== undefined && { isDefault: input.isDefault }),
updatedAt: now(),
}; };
await encryptRecord('userTagPresets', diff); await encryptRecord('userTagPresets', diff);
await table.update(id, diff); await table.update(id, diff);
}, },
async deletePreset(id: string): Promise<void> { async deletePreset(id: string): Promise<void> {
await table.update(id, { deletedAt: now(), updatedAt: now() }); await table.update(id, { deletedAt: now() });
}, },
async setDefault(id: string): Promise<void> { async setDefault(id: string): Promise<void> {
const existing = await table.get(id); const existing = await table.get(id);
if (!existing) throw new Error(`Preset ${id} not found`); if (!existing) throw new Error(`Preset ${id} not found`);
await clearDefaultFlag(existing.userId, id); await clearDefaultFlag(existing.userId, id);
await table.update(id, { isDefault: true, updatedAt: now() }); await table.update(id, { isDefault: true });
}, },
/** /**

View file

@ -1,3 +1,4 @@
import { deriveUpdatedAt } from '$lib/data/sync';
/** /**
* User-level tag presets. * User-level tag presets.
* *
@ -33,11 +34,15 @@ export interface LocalUserTagPreset {
isDefault: boolean; isDefault: boolean;
tags: TagPresetEntry[]; tags: TagPresetEntry[];
createdAt: string; createdAt: string;
updatedAt: string;
deletedAt?: string; deletedAt?: string;
} }
export type UserTagPreset = Omit<LocalUserTagPreset, 'deletedAt'>; export type UserTagPreset = Omit<LocalUserTagPreset, 'deletedAt'> & {
/** Computed read-side property: max(__fieldMeta[*].at). The Local
* record stamps `_updatedAtIndex` for sort indexes; consumers see
* `updatedAt` here via {@link toUserTagPreset}. */
updatedAt: string;
};
export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset { export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset {
return { return {
@ -47,7 +52,7 @@ export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset {
isDefault: local.isDefault, isDefault: local.isDefault,
tags: local.tags ?? [], tags: local.tags ?? [],
createdAt: local.createdAt, createdAt: local.createdAt,
updatedAt: local.updatedAt, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -58,7 +58,6 @@ export const TIME_BLOCK_GUEST_SEED = {
icon: null, icon: null,
projectId: null, projectId: null,
createdAt: nowISO, createdAt: nowISO,
updatedAt: nowISO,
}, },
{ {
id: 'sample-tb-event-2', id: 'sample-tb-event-2',
@ -79,7 +78,6 @@ export const TIME_BLOCK_GUEST_SEED = {
icon: null, icon: null,
projectId: null, projectId: null,
createdAt: nowISO, createdAt: nowISO,
updatedAt: nowISO,
}, },
] satisfies LocalTimeBlock[]; ] satisfies LocalTimeBlock[];
})(), })(),

View file

@ -9,6 +9,7 @@
*/ */
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { deriveUpdatedAt } from '$lib/data/sync';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -46,7 +47,7 @@ export function toTimeBlock(local: LocalTimeBlock): TimeBlock {
icon: local.icon ?? null, icon: local.icon ?? null,
projectId: local.projectId ?? null, projectId: local.projectId ?? null,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -10,6 +10,7 @@
*/ */
import { RRule } from 'rrule'; import { RRule } from 'rrule';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { timeBlockTable } from './collections'; import { timeBlockTable } from './collections';
import { createBlock, deleteBlock } from './service'; import { createBlock, deleteBlock } from './service';
@ -311,7 +312,7 @@ export function expandTemplatesVirtually(
linkedBlockId: null, linkedBlockId: null,
isVirtual: true, isVirtual: true,
createdAt: template.createdAt ?? new Date().toISOString(), createdAt: template.createdAt ?? new Date().toISOString(),
updatedAt: template.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(template),
}); });
} }
} }

View file

@ -45,7 +45,6 @@ export async function createBlock(input: CreateTimeBlockInput): Promise<string>
icon: input.icon ?? null, icon: input.icon ?? null,
projectId: input.projectId ?? null, projectId: input.projectId ?? null,
createdAt: now, createdAt: now,
updatedAt: now,
}; };
// Encrypt configured fields (title + description) before write. // Encrypt configured fields (title + description) before write.
@ -60,7 +59,6 @@ export async function updateBlock(id: string, input: UpdateTimeBlockInput): Prom
const now = new Date().toISOString(); const now = new Date().toISOString();
const diff: Partial<LocalTimeBlock> = { const diff: Partial<LocalTimeBlock> = {
...input, ...input,
updatedAt: now,
}; };
await encryptRecord('timeBlocks', diff); await encryptRecord('timeBlocks', diff);
await timeBlockTable.update(id, diff); await timeBlockTable.update(id, diff);
@ -71,7 +69,6 @@ export async function deleteBlock(id: string): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
await timeBlockTable.update(id, { await timeBlockTable.update(id, {
deletedAt: now, deletedAt: now,
updatedAt: now,
}); });
} }
@ -85,8 +82,8 @@ export async function linkBlocks(scheduledId: string, loggedId: string): Promise
if (scheduled.kind !== 'scheduled') throw new Error('First block must be scheduled'); if (scheduled.kind !== 'scheduled') throw new Error('First block must be scheduled');
if (logged.kind !== 'logged') throw new Error('Second block must be logged'); if (logged.kind !== 'logged') throw new Error('Second block must be logged');
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now }); await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId });
await timeBlockTable.update(loggedId, { linkedBlockId: scheduledId, updatedAt: now }); await timeBlockTable.update(loggedId, { linkedBlockId: scheduledId });
}); });
} }
@ -125,7 +122,7 @@ export async function startFromScheduled(
}); });
// Link back from scheduled → logged // Link back from scheduled → logged
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now }); await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId });
return loggedId; return loggedId;
} }

View file

@ -138,7 +138,7 @@ export async function runArticlesFromNewsMigration(): Promise<number> {
// the server + other devices. Keep it in the local table so // the server + other devices. Keep it in the local table so
// if someone later rolls back the migration they can still // if someone later rolls back the migration they can still
// see what was there. // see what was there.
await newsTable.update(row.id, { deletedAt: now, updatedAt: now }); await newsTable.update(row.id, { deletedAt: now });
moved++; moved++;
} catch (rowErr) { } catch (rowErr) {
console.warn(`[articles/from-news] skipping row ${row.id}${(rowErr as Error).message}`); console.warn(`[articles/from-news] skipping row ${row.id}${(rowErr as Error).message}`);

View file

@ -7,6 +7,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule, scopedGet } from '$lib/data/scope'; import { scopedForModule, scopedGet } from '$lib/data/scope';
import { articleTagOps } from './stores/tags.svelte'; import { articleTagOps } from './stores/tags.svelte';
@ -37,7 +38,7 @@ export function toArticle(local: LocalArticle): Article {
userNote: local.userNote ?? null, userNote: local.userNote ?? null,
extractedVersion: local.extractedVersion ?? 1, extractedVersion: local.extractedVersion ?? 1,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -54,7 +55,7 @@ export function toHighlight(local: LocalHighlight): Highlight {
contextBefore: local.contextBefore ?? null, contextBefore: local.contextBefore ?? null,
contextAfter: local.contextAfter ?? null, contextAfter: local.contextAfter ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -20,7 +20,6 @@ export const articlesStore = {
async setStatus(id: string, status: ArticleStatus): Promise<void> { async setStatus(id: string, status: ArticleStatus): Promise<void> {
const diff: Partial<LocalArticle> = { const diff: Partial<LocalArticle> = {
status, status,
updatedAt: new Date().toISOString(),
}; };
if (status === 'finished') { if (status === 'finished') {
const existing = await articleTable.get(id); const existing = await articleTable.get(id);
@ -34,7 +33,6 @@ export const articlesStore = {
if (!existing) return; if (!existing) return;
await articleTable.update(id, { await articleTable.update(id, {
isFavorite: !existing.isFavorite, isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -42,14 +40,12 @@ export const articlesStore = {
const clamped = Math.max(0, Math.min(1, progress)); const clamped = Math.max(0, Math.min(1, progress));
await articleTable.update(id, { await articleTable.update(id, {
readingProgress: clamped, readingProgress: clamped,
updatedAt: new Date().toISOString(),
}); });
}, },
async updateNote(id: string, note: string | null): Promise<void> { async updateNote(id: string, note: string | null): Promise<void> {
const diff: Partial<LocalArticle> = { const diff: Partial<LocalArticle> = {
userNote: note, userNote: note,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('articles', diff as LocalArticle); await encryptRecord('articles', diff as LocalArticle);
await articleTable.update(id, diff); await articleTable.update(id, diff);
@ -58,7 +54,6 @@ export const articlesStore = {
async deleteArticle(id: string): Promise<void> { async deleteArticle(id: string): Promise<void> {
await articleTable.update(id, { await articleTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },

View file

@ -44,14 +44,12 @@ export const highlightsStore = {
async setColor(id: string, color: HighlightColor): Promise<void> { async setColor(id: string, color: HighlightColor): Promise<void> {
await articleHighlightTable.update(id, { await articleHighlightTable.update(id, {
color, color,
updatedAt: new Date().toISOString(),
}); });
}, },
async setNote(id: string, note: string | null): Promise<void> { async setNote(id: string, note: string | null): Promise<void> {
const diff: Partial<LocalHighlight> = { const diff: Partial<LocalHighlight> = {
note, note,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('articleHighlights', diff as LocalHighlight); await encryptRecord('articleHighlights', diff as LocalHighlight);
await articleHighlightTable.update(id, diff); await articleHighlightTable.update(id, diff);
@ -60,7 +58,6 @@ export const highlightsStore = {
async deleteHighlight(id: string): Promise<void> { async deleteHighlight(id: string): Promise<void> {
await articleHighlightTable.update(id, { await articleHighlightTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -1,4 +1,5 @@
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { isDue } from './lib/reminders'; import { isDue } from './lib/reminders';
@ -32,7 +33,7 @@ export function toAugurEntry(local: LocalAugurEntry): AugurEntry {
unlistedToken: local.unlistedToken ?? '', unlistedToken: local.unlistedToken ?? '',
unlistedExpiresAt: local.unlistedExpiresAt ?? null, unlistedExpiresAt: local.unlistedExpiresAt ?? null,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -95,7 +95,6 @@ export const augurStore = {
) { ) {
const diff: Partial<LocalAugurEntry> = { const diff: Partial<LocalAugurEntry> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('augurEntries', diff); await encryptRecord('augurEntries', diff);
await augurEntriesTable.update(id, diff); await augurEntriesTable.update(id, diff);
@ -106,7 +105,6 @@ export const augurStore = {
outcome, outcome,
outcomeNote: note ?? null, outcomeNote: note ?? null,
resolvedAt: new Date().toISOString(), resolvedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('augurEntries', diff); await encryptRecord('augurEntries', diff);
await augurEntriesTable.update(id, diff); await augurEntriesTable.update(id, diff);
@ -115,7 +113,6 @@ export const augurStore = {
async archiveEntry(id: string) { async archiveEntry(id: string) {
await augurEntriesTable.update(id, { await augurEntriesTable.update(id, {
isArchived: true, isArchived: true,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -141,7 +138,6 @@ export const augurStore = {
await augurEntriesTable.update(id, { await augurEntriesTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -161,7 +157,6 @@ export const augurStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId() ?? undefined, visibilityChangedBy: getEffectiveUserId() ?? undefined,
updatedAt: now,
}; };
if (next === 'unlisted') { if (next === 'unlisted') {
@ -230,7 +225,6 @@ export const augurStore = {
}); });
await augurEntriesTable.update(id, { await augurEntriesTable.update(id, {
unlistedToken: token, unlistedToken: token,
updatedAt: new Date().toISOString(),
}); });
return token; return token;
} catch (e) { } catch (e) {
@ -264,7 +258,6 @@ export const augurStore = {
}); });
await augurEntriesTable.update(id, { await augurEntriesTable.update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null, unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null,
updatedAt: new Date().toISOString(),
}); });
} catch (e) { } catch (e) {
console.error('[augur] setUnlistedExpiry failed', e); console.error('[augur] setUnlistedExpiry failed', e);

View file

@ -5,6 +5,7 @@
*/ */
import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { automationTable } from './collections'; import { automationTable } from './collections';
import type { LocalAutomation } from './types'; import type { LocalAutomation } from './types';
@ -42,7 +43,7 @@ export function toAutomation(local: LocalAutomation): Automation {
targetAction: local.targetAction, targetAction: local.targetAction,
targetParams: local.targetParams, targetParams: local.targetParams,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -37,7 +37,6 @@ export const automationsStore = {
targetAction: data.targetAction, targetAction: data.targetAction,
targetParams: data.targetParams, targetParams: data.targetParams,
createdAt: now, createdAt: now,
updatedAt: now,
}; };
await automationTable.add(auto); await automationTable.add(auto);
await loadAutomations(); await loadAutomations();
@ -47,7 +46,6 @@ export const automationsStore = {
async update(id: string, data: Partial<LocalAutomation>) { async update(id: string, data: Partial<LocalAutomation>) {
await automationTable.update(id, { await automationTable.update(id, {
...data, ...data,
updatedAt: new Date().toISOString(),
}); });
await loadAutomations(); await loadAutomations();
}, },
@ -57,7 +55,6 @@ export const automationsStore = {
if (!auto) return; if (!auto) return;
await automationTable.update(id, { await automationTable.update(id, {
enabled: !auto.enabled, enabled: !auto.enabled,
updatedAt: new Date().toISOString(),
}); });
await loadAutomations(); await loadAutomations();
}, },
@ -65,7 +62,6 @@ export const automationsStore = {
async remove(id: string) { async remove(id: string) {
await automationTable.update(id, { await automationTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
await loadAutomations(); await loadAutomations();
}, },

View file

@ -5,6 +5,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
@ -39,7 +40,7 @@ export function toBodyExercise(local: LocalBodyExercise): BodyExercise {
isArchived: local.isArchived, isArchived: local.isArchived,
isPreset: local.isPreset, isPreset: local.isPreset,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -53,7 +54,7 @@ export function toBodyRoutine(local: LocalBodyRoutine): BodyRoutine {
order: local.order, order: local.order,
isArchived: local.isArchived, isArchived: local.isArchived,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -68,7 +69,7 @@ export function toBodyWorkout(local: LocalBodyWorkout): BodyWorkout {
notes: local.notes ?? null, notes: local.notes ?? null,
rpe: local.rpe ?? null, rpe: local.rpe ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -124,7 +125,7 @@ export function toBodyPhase(local: LocalBodyPhase): BodyPhase {
targetWeight: local.targetWeight ?? null, targetWeight: local.targetWeight ?? null,
notes: local.notes ?? null, notes: local.notes ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -82,7 +82,6 @@ export const bodyStore = {
const wrapped = await encryptRecord('bodyExercises', { ...patch }); const wrapped = await encryptRecord('bodyExercises', { ...patch });
await bodyExerciseTable.update(id, { await bodyExerciseTable.update(id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -95,7 +94,6 @@ export const bodyStore = {
if (!exercise || exercise.isPreset) return; if (!exercise || exercise.isPreset) return;
await bodyExerciseTable.update(id, { await bodyExerciseTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -128,14 +126,12 @@ export const bodyStore = {
const wrapped = await encryptRecord('bodyRoutines', { ...patch }); const wrapped = await encryptRecord('bodyRoutines', { ...patch });
await bodyRoutineTable.update(id, { await bodyRoutineTable.update(id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
}, },
async deleteRoutine(id: string) { async deleteRoutine(id: string) {
await bodyRoutineTable.update(id, { await bodyRoutineTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -202,7 +198,6 @@ export const bodyStore = {
const wrapped = await encryptRecord('bodyWorkouts', { ...update }); const wrapped = await encryptRecord('bodyWorkouts', { ...update });
await bodyWorkoutTable.update(id, { await bodyWorkoutTable.update(id, {
...wrapped, ...wrapped,
updatedAt: now,
}); });
// Stamp the TimeBlock's endDate so the calendar shows duration. // Stamp the TimeBlock's endDate so the calendar shows duration.
@ -229,7 +224,6 @@ export const bodyStore = {
const wrapped = await encryptRecord('bodyWorkouts', { ...patch }); const wrapped = await encryptRecord('bodyWorkouts', { ...patch });
await bodyWorkoutTable.update(id, { await bodyWorkoutTable.update(id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -238,7 +232,7 @@ export const bodyStore = {
// stop counting them. Also remove the linked TimeBlock. // stop counting them. Also remove the linked TimeBlock.
const workout = await bodyWorkoutTable.get(id); const workout = await bodyWorkoutTable.get(id);
const now = new Date().toISOString(); const now = new Date().toISOString();
await bodyWorkoutTable.update(id, { deletedAt: now, updatedAt: now }); await bodyWorkoutTable.update(id, { deletedAt: now });
const sets = await bodySetTable.where('workoutId').equals(id).toArray(); const sets = await bodySetTable.where('workoutId').equals(id).toArray();
for (const s of sets) { for (const s of sets) {
await bodySetTable.update(s.id, { deletedAt: now }); await bodySetTable.update(s.id, { deletedAt: now });
@ -373,7 +367,6 @@ export const bodyStore = {
const wrapped = await encryptRecord('bodyChecks', { ...patch }); const wrapped = await encryptRecord('bodyChecks', { ...patch });
await bodyCheckTable.update(existing.id, { await bodyCheckTable.update(existing.id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
return toBodyCheck({ ...existing, ...patch }); return toBodyCheck({ ...existing, ...patch });
} }
@ -437,7 +430,6 @@ export const bodyStore = {
async endPhase(id: string) { async endPhase(id: string) {
await bodyPhaseTable.update(id, { await bodyPhaseTable.update(id, {
endDate: new Date().toISOString().split('T')[0], endDate: new Date().toISOString().split('T')[0],
updatedAt: new Date().toISOString(),
}); });
}, },
@ -453,14 +445,12 @@ export const bodyStore = {
const wrapped = await encryptRecord('bodyPhases', { ...patch }); const wrapped = await encryptRecord('bodyPhases', { ...patch });
await bodyPhaseTable.update(id, { await bodyPhaseTable.update(id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
}, },
async deletePhase(id: string) { async deletePhase(id: string) {
await bodyPhaseTable.update(id, { await bodyPhaseTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -7,6 +7,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { campaignTable, templateTable, settingsTable } from './collections'; import { campaignTable, templateTable, settingsTable } from './collections';
@ -42,7 +43,7 @@ export function toCampaign(local: LocalCampaign): Campaign {
serverJobId: local.serverJobId ?? null, serverJobId: local.serverJobId ?? null,
stats: local.stats ?? null, stats: local.stats ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -66,7 +66,6 @@ export const broadcastCampaignsStore = {
serverJobId: null, serverJobId: null,
stats: null, stats: null,
createdAt: now, createdAt: now,
updatedAt: now,
}; };
await encryptRecord('broadcastCampaigns', newLocal); await encryptRecord('broadcastCampaigns', newLocal);
@ -99,10 +98,7 @@ export const broadcastCampaignsStore = {
} }
const wrapped = { ...patch } as Record<string, unknown>; const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('broadcastCampaigns', wrapped); await encryptRecord('broadcastCampaigns', wrapped);
await campaignTable.update(id, { await campaignTable.update(id, wrapped as never);
...wrapped,
updatedAt: new Date().toISOString(),
});
}, },
/** /**
@ -119,10 +115,7 @@ export const broadcastCampaignsStore = {
} }
const patch = { content } as Record<string, unknown>; const patch = { content } as Record<string, unknown>;
await encryptRecord('broadcastCampaigns', patch); await encryptRecord('broadcastCampaigns', patch);
await campaignTable.update(id, { await campaignTable.update(id, patch as never);
...patch,
updatedAt: new Date().toISOString(),
});
}, },
async updateAudience(id: string, audience: AudienceDefinition) { async updateAudience(id: string, audience: AudienceDefinition) {
@ -133,10 +126,7 @@ export const broadcastCampaignsStore = {
} }
const patch = { audience } as Record<string, unknown>; const patch = { audience } as Record<string, unknown>;
await encryptRecord('broadcastCampaigns', patch); await encryptRecord('broadcastCampaigns', patch);
await campaignTable.update(id, { await campaignTable.update(id, patch as never);
...patch,
updatedAt: new Date().toISOString(),
});
}, },
/** /**
@ -151,7 +141,6 @@ export const broadcastCampaignsStore = {
await campaignTable.update(id, { await campaignTable.update(id, {
status: 'scheduled' as CampaignStatus, status: 'scheduled' as CampaignStatus,
scheduledAt, scheduledAt,
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, { emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
campaignId: id, campaignId: id,
@ -169,7 +158,6 @@ export const broadcastCampaignsStore = {
await campaignTable.update(id, { await campaignTable.update(id, {
status: 'cancelled' as CampaignStatus, status: 'cancelled' as CampaignStatus,
scheduledAt: null, scheduledAt: null,
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, { emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
campaignId: id, campaignId: id,
@ -209,7 +197,6 @@ export const broadcastCampaignsStore = {
} }
await campaignTable.update(id, { await campaignTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, { emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
campaignId: id, campaignId: id,
@ -232,7 +219,6 @@ export const broadcastCampaignsStore = {
) { ) {
await campaignTable.update(id, { await campaignTable.update(id, {
...patch, ...patch,
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -50,7 +50,6 @@ export const broadcastSettingsStore = {
await encryptRecord('broadcastSettings', wrapped); await encryptRecord('broadcastSettings', wrapped);
await settingsTable.update(BROADCAST_SETTINGS_ID, { await settingsTable.update(BROADCAST_SETTINGS_ID, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent( emitDomainEvent(
'BroadcastSettingsUpdated', 'BroadcastSettingsUpdated',

View file

@ -3,6 +3,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import type { LocalCalculation, LocalSavedFormula } from './types'; import type { LocalCalculation, LocalSavedFormula } from './types';
@ -31,7 +32,7 @@ export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
description: local.description ?? undefined, description: local.description ?? undefined,
mode: local.mode, mode: local.mode,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -16,7 +16,6 @@ export const calculationsStore = {
result: input.result, result: input.result,
skin: input.skin, skin: input.skin,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
CalcEvents.calculationAdded(); CalcEvents.calculationAdded();
}, },
@ -24,7 +23,6 @@ export const calculationsStore = {
async deleteCalculation(id: string) { async deleteCalculation(id: string) {
await db.table('calculations').update(id, { await db.table('calculations').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -32,9 +30,7 @@ export const calculationsStore = {
const now = new Date().toISOString(); const now = new Date().toISOString();
const all = await db.table<LocalCalculation>('calculations').toArray(); const all = await db.table<LocalCalculation>('calculations').toArray();
const active = all.filter((c) => !c.deletedAt); const active = all.filter((c) => !c.deletedAt);
await Promise.all( await Promise.all(active.map((c) => db.table('calculations').update(c.id, { deletedAt: now })));
active.map((c) => db.table('calculations').update(c.id, { deletedAt: now, updatedAt: now }))
);
CalcEvents.historyCleared(); CalcEvents.historyCleared();
}, },
}; };

View file

@ -16,7 +16,6 @@ export const savedFormulasStore = {
description: input.description ?? null, description: input.description ?? null,
mode: input.mode, mode: input.mode,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
CalcEvents.formulaSaved(); CalcEvents.formulaSaved();
}, },
@ -24,14 +23,12 @@ export const savedFormulasStore = {
async updateFormula(id: string, input: UpdateFormulaInput) { async updateFormula(id: string, input: UpdateFormulaInput) {
await db.table('savedFormulas').update(id, { await db.table('savedFormulas').update(id, {
...input, ...input,
updatedAt: new Date().toISOString(),
}); });
}, },
async deleteFormula(id: string) { async deleteFormula(id: string) {
await db.table('savedFormulas').update(id, { await db.table('savedFormulas').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
CalcEvents.formulaDeleted(); CalcEvents.formulaDeleted();
}, },

View file

@ -11,6 +11,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule, applyVisibility } from '$lib/data/scope'; import { scopedForModule, applyVisibility } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -33,7 +34,7 @@ export function toCalendar(local: LocalCalendar): Calendar {
isVisible: local.isVisible, isVisible: local.isVisible,
timezone: local.timezone, timezone: local.timezone,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -38,7 +38,6 @@ export const calendarsStore = {
isVisible: input.isVisible ?? true, isVisible: input.isVisible ?? true,
timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
await db.table<LocalCalendar>('calendars').add(newLocal); await db.table<LocalCalendar>('calendars').add(newLocal);
@ -57,7 +56,6 @@ export const calendarsStore = {
try { try {
await db.table('calendars').update(id, { await db.table('calendars').update(id, {
...input, ...input,
updatedAt: new Date().toISOString(),
}); });
const updated = await db.table<LocalCalendar>('calendars').get(id); const updated = await db.table<LocalCalendar>('calendars').get(id);
if (updated) { if (updated) {
@ -78,7 +76,6 @@ export const calendarsStore = {
try { try {
await db.table('calendars').update(id, { await db.table('calendars').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
@ -106,13 +103,11 @@ export const calendarsStore = {
if (cal.isDefault && cal.id !== id) { if (cal.isDefault && cal.id !== id) {
await db.table('calendars').update(cal.id, { await db.table('calendars').update(cal.id, {
isDefault: false, isDefault: false,
updatedAt: new Date().toISOString(),
}); });
} }
} }
await db.table('calendars').update(id, { await db.table('calendars').update(id, {
isDefault: true, isDefault: true,
updatedAt: new Date().toISOString(),
}); });
const updated = await db.table<LocalCalendar>('calendars').get(id); const updated = await db.table<LocalCalendar>('calendars').get(id);
return { success: true, data: updated ? toCalendar(updated) : null }; return { success: true, data: updated ? toCalendar(updated) : null };

View file

@ -89,7 +89,6 @@ export const eventsStore = {
reminders: null, reminders: null,
visibility: defaultVisibilityFor(getActiveSpace()?.type), visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
// title/description/location are encrypted at rest. createBlock // title/description/location are encrypted at rest. createBlock
@ -154,9 +153,7 @@ export const eventsStore = {
} }
// Update LocalEvent for domain fields // Update LocalEvent for domain fields
const localData: Partial<LocalEvent> = { const localData: Partial<LocalEvent> = {};
updatedAt: new Date().toISOString(),
};
if (input.title !== undefined) localData.title = input.title; if (input.title !== undefined) localData.title = input.title;
if (input.description !== undefined) localData.description = input.description; if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location; if (input.location !== undefined) localData.location = input.location;
@ -213,7 +210,7 @@ export const eventsStore = {
await updateBlock(event.timeBlockId, blockUpdates); await updateBlock(event.timeBlockId, blockUpdates);
// Update LocalEvent // Update LocalEvent
const localData: Partial<LocalEvent> = { updatedAt: new Date().toISOString() }; const localData: Partial<LocalEvent> = {};
if (input.title !== undefined) localData.title = input.title; if (input.title !== undefined) localData.title = input.title;
if (input.description !== undefined) localData.description = input.description; if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location; if (input.location !== undefined) localData.location = input.location;
@ -277,7 +274,7 @@ export const eventsStore = {
.equals(templateBlockId) .equals(templateBlockId)
.first(); .first();
if (templateEvent) { if (templateEvent) {
const localData: Partial<LocalEvent> = { updatedAt: new Date().toISOString() }; const localData: Partial<LocalEvent> = {};
if (input.title !== undefined) localData.title = input.title; if (input.title !== undefined) localData.title = input.title;
if (input.description !== undefined) localData.description = input.description; if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location; if (input.location !== undefined) localData.location = input.location;
@ -308,7 +305,6 @@ export const eventsStore = {
} }
await db.table('events').update(id, { await db.table('events').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
CalendarEvents.eventDeleted(); CalendarEvents.eventDeleted();
return { success: true }; return { success: true };
@ -343,7 +339,7 @@ export const eventsStore = {
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const ev of allEvents) { for (const ev of allEvents) {
if (blockIds.has(ev.timeBlockId) && !ev.deletedAt) { if (blockIds.has(ev.timeBlockId) && !ev.deletedAt) {
await db.table('events').update(ev.id, { deletedAt: now, updatedAt: now }); await db.table('events').update(ev.id, { deletedAt: now });
} }
} }
@ -391,7 +387,6 @@ export const eventsStore = {
await db.table('events').update(id, { await db.table('events').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, { emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, {
eventId: id, eventId: id,
@ -412,7 +407,6 @@ export const eventsStore = {
async updateTagIds(id: string, tagIds: string[]) { async updateTagIds(id: string, tagIds: string[]) {
await db.table('events').update(id, { await db.table('events').update(id, {
tagIds, tagIds,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -503,7 +497,6 @@ export const eventsStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(), visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
}; };
// Server-authoritative token. Publish first; local update only // Server-authoritative token. Publish first; local update only
@ -590,7 +583,6 @@ export const eventsStore = {
}); });
await db.table('events').update(id, { await db.table('events').update(id, {
unlistedToken: token, unlistedToken: token,
updatedAt: new Date().toISOString(),
}); });
return { success: true, token }; return { success: true, token };
} catch (e) { } catch (e) {
@ -625,7 +617,6 @@ export const eventsStore = {
}); });
await db.table('events').update(id, { await db.table('events').update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined, unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
updatedAt: new Date().toISOString(),
}); });
} catch (e) { } catch (e) {
console.error('[calendar/events] setUnlistedExpiry failed', e); console.error('[calendar/events] setUnlistedExpiry failed', e);

View file

@ -6,6 +6,7 @@
*/ */
import type { BaseRecord } from '@mana/local-store'; import type { BaseRecord } from '@mana/local-store';
import { deriveUpdatedAt } from '$lib/data/sync';
import type { VisibilityLevel } from '@mana/shared-privacy'; import type { VisibilityLevel } from '@mana/shared-privacy';
import type { TimeBlock, TimeBlockType } from '$lib/data/time-blocks/types'; import type { TimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
@ -120,7 +121,7 @@ export function timeBlockToCalendarEvent(
unlistedToken: eventData?.unlistedToken ?? '', unlistedToken: eventData?.unlistedToken ?? '',
unlistedExpiresAt: eventData?.unlistedExpiresAt ?? null, unlistedExpiresAt: eventData?.unlistedExpiresAt ?? null,
createdAt: block.createdAt, createdAt: block.createdAt,
updatedAt: block.updatedAt, updatedAt: deriveUpdatedAt(block),
blockType: block.type, blockType: block.type,
sourceModule: block.sourceModule, sourceModule: block.sourceModule,
sourceId: block.sourceId, sourceId: block.sourceId,

View file

@ -5,6 +5,7 @@
*/ */
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecord, decryptRecords } from '$lib/data/crypto'; import { decryptRecord, decryptRecords } from '$lib/data/crypto';
@ -22,7 +23,7 @@ export function toDeck(local: LocalDeck): Deck {
tags: [], tags: [],
cardCount: local.cardCount, cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -37,7 +38,7 @@ export function toCard(local: LocalCard): Card {
reviewCount: local.reviewCount, reviewCount: local.reviewCount,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -41,7 +41,6 @@ export const cardStore = {
if (deck) { if (deck) {
await cardDeckTable.update(input.deckId, { await cardDeckTable.update(input.deckId, {
cardCount: (deck.cardCount || 0) + 1, cardCount: (deck.cardCount || 0) + 1,
updatedAt: new Date().toISOString(),
}); });
} }
@ -69,7 +68,6 @@ export const cardStore = {
const diff: Partial<LocalCard> = { const diff: Partial<LocalCard> = {
...localUpdates, ...localUpdates,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('cards', diff); await encryptRecord('cards', diff);
await cardTable.update(id, diff); await cardTable.update(id, diff);
@ -83,7 +81,7 @@ export const cardStore = {
error = null; error = null;
try { try {
const now = new Date().toISOString(); const now = new Date().toISOString();
await cardTable.update(id, { deletedAt: now, updatedAt: now }); await cardTable.update(id, { deletedAt: now });
CardsEvents.cardDeleted(); CardsEvents.cardDeleted();
// Update deck card count // Update deck card count
@ -92,7 +90,6 @@ export const cardStore = {
if (deck) { if (deck) {
await cardDeckTable.update(deckId, { await cardDeckTable.update(deckId, {
cardCount: Math.max(0, (deck.cardCount || 0) - 1), cardCount: Math.max(0, (deck.cardCount || 0) - 1),
updatedAt: now,
}); });
} }
} }
@ -107,7 +104,7 @@ export const cardStore = {
try { try {
const now = new Date().toISOString(); const now = new Date().toISOString();
for (let i = 0; i < cardIds.length; i++) { for (let i = 0; i < cardIds.length; i++) {
await cardTable.update(cardIds[i], { order: i, updatedAt: now }); await cardTable.update(cardIds[i], { order: i });
} }
} catch (err: any) { } catch (err: any) {
error = err.message || 'Failed to reorder cards'; error = err.message || 'Failed to reorder cards';

View file

@ -58,7 +58,6 @@ export const deckStore = {
const diff: Partial<LocalDeck> = { const diff: Partial<LocalDeck> = {
...localUpdates, ...localUpdates,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('cardDecks', diff); await encryptRecord('cardDecks', diff);
await cardDeckTable.update(id, diff); await cardDeckTable.update(id, diff);
@ -105,9 +104,9 @@ export const deckStore = {
await db.transaction('rw', cardDeckTable, cardTable, async () => { await db.transaction('rw', cardDeckTable, cardTable, async () => {
const cards = await cardTable.where('deckId').equals(id).toArray(); const cards = await cardTable.where('deckId').equals(id).toArray();
for (const card of cards) { for (const card of cards) {
await cardTable.update(card.id, { deletedAt: now, updatedAt: now }); await cardTable.update(card.id, { deletedAt: now });
} }
await cardDeckTable.update(id, { deletedAt: now, updatedAt: now }); await cardDeckTable.update(id, { deletedAt: now });
}); });
CardsEvents.deckDeleted(); CardsEvents.deckDeleted();
} catch (err: any) { } catch (err: any) {
@ -142,7 +141,6 @@ export const deckStore = {
await cardDeckTable.update(deckId, { await cardDeckTable.update(deckId, {
activeStudyBlockId: timeBlockId, activeStudyBlockId: timeBlockId,
lastStudied: now, lastStudied: now,
updatedAt: now,
}); });
return timeBlockId; return timeBlockId;
@ -160,7 +158,6 @@ export const deckStore = {
await cardDeckTable.update(deckId, { await cardDeckTable.update(deckId, {
activeStudyBlockId: null, activeStudyBlockId: null,
updatedAt: now,
}); });
}, },

View file

@ -54,7 +54,6 @@
// Color is not in UpdateDeckInput, update directly // Color is not in UpdateDeckInput, update directly
await db.table('decks').update(deckId, { await db.table('decks').update(deckId, {
color: editColor, color: editColor,
updatedAt: new Date().toISOString(),
}); });
} }
</script> </script>

View file

@ -8,6 +8,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -34,7 +35,7 @@ export function toConversation(local: LocalConversation): Conversation {
isArchived: local.isArchived, isArchived: local.isArchived,
isPinned: local.isPinned, isPinned: local.isPinned,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -50,7 +51,7 @@ export function toTemplate(local: LocalTemplate): Template {
isDefault: local.isDefault, isDefault: local.isDefault,
documentMode: local.documentMode, documentMode: local.documentMode,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -61,7 +62,7 @@ export function toMessage(local: LocalMessage): Message {
sender: local.sender, sender: local.sender,
messageText: local.messageText, messageText: local.messageText,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? undefined, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -55,7 +55,6 @@ export const conversationsStore = {
async update(id: string, updates: Partial<LocalConversation>) { async update(id: string, updates: Partial<LocalConversation>) {
const diff: Partial<LocalConversation> = { const diff: Partial<LocalConversation> = {
...updates, ...updates,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('conversations', diff); await encryptRecord('conversations', diff);
await conversationTable.update(id, diff); await conversationTable.update(id, diff);
@ -65,7 +64,6 @@ export const conversationsStore = {
async updateTitle(id: string, title: string) { async updateTitle(id: string, title: string) {
const diff: Partial<LocalConversation> = { const diff: Partial<LocalConversation> = {
title, title,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('conversations', diff); await encryptRecord('conversations', diff);
await conversationTable.update(id, diff); await conversationTable.update(id, diff);
@ -79,7 +77,6 @@ export const conversationsStore = {
async pin(id: string) { async pin(id: string) {
await conversationTable.update(id, { await conversationTable.update(id, {
isPinned: true, isPinned: true,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -87,7 +84,6 @@ export const conversationsStore = {
async unpin(id: string) { async unpin(id: string) {
await conversationTable.update(id, { await conversationTable.update(id, {
isPinned: false, isPinned: false,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -97,10 +93,10 @@ export const conversationsStore = {
// Atomic cascade: conversation + all messages in one Dexie transaction. // Atomic cascade: conversation + all messages in one Dexie transaction.
// Aborts as a unit on failure to avoid orphaned messages. // Aborts as a unit on failure to avoid orphaned messages.
await db.transaction('rw', conversationTable, messageTable, async () => { await db.transaction('rw', conversationTable, messageTable, async () => {
await conversationTable.update(id, { deletedAt: now, updatedAt: now }); await conversationTable.update(id, { deletedAt: now });
const msgs = await messageTable.where('conversationId').equals(id).toArray(); const msgs = await messageTable.where('conversationId').equals(id).toArray();
for (const msg of msgs) { for (const msg of msgs) {
await messageTable.update(msg.id, { deletedAt: now, updatedAt: now }); await messageTable.update(msg.id, { deletedAt: now });
} }
}); });
ChatEvents.conversationDeleted(); ChatEvents.conversationDeleted();

View file

@ -33,9 +33,7 @@ export const messagesStore = {
await encryptRecord('messages', newLocal); await encryptRecord('messages', newLocal);
await messageTable.add(newLocal); await messageTable.add(newLocal);
// Touch the conversation's updatedAt // Touch the conversation's updatedAt
await conversationTable.update(conversationId, { await conversationTable.update(conversationId, {});
updatedAt: new Date().toISOString(),
});
emitDomainEvent('ChatMessageSent', 'chat', 'messages', newLocal.id, { emitDomainEvent('ChatMessageSent', 'chat', 'messages', newLocal.id, {
messageId: newLocal.id, messageId: newLocal.id,
conversationId, conversationId,
@ -55,9 +53,7 @@ export const messagesStore = {
const plaintextSnapshot = toMessage(newLocal); const plaintextSnapshot = toMessage(newLocal);
await encryptRecord('messages', newLocal); await encryptRecord('messages', newLocal);
await messageTable.add(newLocal); await messageTable.add(newLocal);
await conversationTable.update(conversationId, { await conversationTable.update(conversationId, {});
updatedAt: new Date().toISOString(),
});
return plaintextSnapshot; return plaintextSnapshot;
}, },
@ -65,7 +61,6 @@ export const messagesStore = {
async updateText(id: string, text: string) { async updateText(id: string, text: string) {
const diff: Partial<LocalMessage> = { const diff: Partial<LocalMessage> = {
messageText: text, messageText: text,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('messages', diff); await encryptRecord('messages', diff);
await messageTable.update(id, diff); await messageTable.update(id, diff);
@ -74,6 +69,6 @@ export const messagesStore = {
/** Soft-delete a message. */ /** Soft-delete a message. */
async delete(id: string) { async delete(id: string) {
const now = new Date().toISOString(); const now = new Date().toISOString();
await messageTable.update(id, { deletedAt: now, updatedAt: now }); await messageTable.update(id, { deletedAt: now });
}, },
}; };

View file

@ -58,7 +58,6 @@ export const templatesStore = {
) { ) {
const diff: Partial<LocalTemplate> = { const diff: Partial<LocalTemplate> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('chatTemplates', diff); await encryptRecord('chatTemplates', diff);
await chatTemplateTable.update(id, diff); await chatTemplateTable.update(id, diff);
@ -67,7 +66,7 @@ export const templatesStore = {
/** Soft-delete a template. */ /** Soft-delete a template. */
async delete(id: string) { async delete(id: string) {
const now = new Date().toISOString(); const now = new Date().toISOString();
await chatTemplateTable.update(id, { deletedAt: now, updatedAt: now }); await chatTemplateTable.update(id, { deletedAt: now });
}, },
/** Set a template as default (unset all others). */ /** Set a template as default (unset all others). */
@ -77,13 +76,11 @@ export const templatesStore = {
if (t.isDefault && t.id !== templateId) { if (t.isDefault && t.id !== templateId) {
await chatTemplateTable.update(t.id, { await chatTemplateTable.update(t.id, {
isDefault: false, isDefault: false,
updatedAt: new Date().toISOString(),
}); });
} }
} }
await chatTemplateTable.update(templateId, { await chatTemplateTable.update(templateId, {
isDefault: true, isDefault: true,
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -29,7 +29,6 @@ export const favoritesStore = {
if (existing) { if (existing) {
await db.table('ccFavorites').update(existing.id, { await db.table('ccFavorites').update(existing.id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
CityCornersEvents.favoriteToggled(false); CityCornersEvents.favoriteToggled(false);
} else { } else {
@ -37,7 +36,6 @@ export const favoritesStore = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
locationId, locationId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
await db.table('ccFavorites').add(newFav); await db.table('ccFavorites').add(newFav);
CityCornersEvents.favoriteToggled(true); CityCornersEvents.favoriteToggled(true);

View file

@ -51,14 +51,12 @@
category: editCategory, category: editCategory,
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
address: editAddress.trim() || undefined, address: editAddress.trim() || undefined,
updatedAt: new Date().toISOString(),
}); });
} }
async function handleCategoryChange() { async function handleCategoryChange() {
await db.table('ccLocations').update(locationId, { await db.table('ccLocations').update(locationId, {
category: editCategory, category: editCategory,
updatedAt: new Date().toISOString(),
}); });
} }
@ -69,7 +67,6 @@
async function deleteLocation() { async function deleteLocation() {
await db.table('ccLocations').update(locationId, { await db.table('ccLocations').update(locationId, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
} }

View file

@ -187,7 +187,6 @@ export async function runCharacterGenerate(
referenceImageIds: referenceMediaIds, referenceImageIds: referenceMediaIds,
comicCharacterId: character.id, comicCharacterId: character.id,
createdAt: nowIso, createdAt: nowIso,
updatedAt: nowIso,
}); });
await comicCharactersStore.appendVariant(character.id, localImageId); await comicCharactersStore.appendVariant(character.id, localImageId);

View file

@ -220,7 +220,6 @@ export async function runPanelGenerate(
comicStoryId: story.id, comicStoryId: story.id,
comicPanelIndex: panelIndex, comicPanelIndex: panelIndex,
createdAt: now, createdAt: now,
updatedAt: now,
}); });
await comicStoriesStore.appendPanel(story.id, localImageId, { await comicStoriesStore.appendPanel(story.id, localImageId, {

View file

@ -77,14 +77,13 @@ export const comicCharactersStore = {
const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId]; const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId];
const patch: Partial<LocalComicCharacter> = { const patch: Partial<LocalComicCharacter> = {
variantMediaIds: nextIds, variantMediaIds: nextIds,
updatedAt: new Date().toISOString(),
}; };
// Auto-pin the first variant so the cover isn't blank during // Auto-pin the first variant so the cover isn't blank during
// build. User can re-pin afterwards. // build. User can re-pin afterwards.
if (!existing.pinnedVariantId) { if (!existing.pinnedVariantId) {
patch.pinnedVariantId = variantMediaId; patch.pinnedVariantId = variantMediaId;
} }
await comicCharactersTable.update(characterId, patch); await comicCharactersTable.update(characterId, patch as never);
emitDomainEvent('ComicCharacterVariantAdded', 'comic', 'comicCharacters', characterId, { emitDomainEvent('ComicCharacterVariantAdded', 'comic', 'comicCharacters', characterId, {
characterId, characterId,
variantMediaId, variantMediaId,
@ -103,7 +102,6 @@ export const comicCharactersStore = {
} }
await comicCharactersTable.update(characterId, { await comicCharactersTable.update(characterId, {
pinnedVariantId: variantMediaId, pinnedVariantId: variantMediaId,
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('ComicCharacterVariantPinned', 'comic', 'comicCharacters', characterId, { emitDomainEvent('ComicCharacterVariantPinned', 'comic', 'comicCharacters', characterId, {
characterId, characterId,
@ -121,27 +119,23 @@ export const comicCharactersStore = {
const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId); const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId);
const patch: Partial<LocalComicCharacter> = { const patch: Partial<LocalComicCharacter> = {
variantMediaIds: nextIds, variantMediaIds: nextIds,
updatedAt: new Date().toISOString(),
}; };
if (existing.pinnedVariantId === variantMediaId) { if (existing.pinnedVariantId === variantMediaId) {
patch.pinnedVariantId = nextIds[0] ?? null; patch.pinnedVariantId = nextIds[0] ?? null;
} }
await comicCharactersTable.update(characterId, patch); await comicCharactersTable.update(characterId, patch as never);
}, },
async updateCharacter( async updateCharacter(
id: string, id: string,
patch: Partial<Pick<LocalComicCharacter, 'name' | 'description' | 'addPrompt' | 'tags'>> patch: Partial<Pick<LocalComicCharacter, 'name' | 'description' | 'addPrompt' | 'tags'>>
): Promise<void> { ): Promise<void> {
const wrapped: Record<string, unknown> = { ...patch }; const wrapped: Partial<LocalComicCharacter> = { ...patch };
if (Array.isArray(wrapped.tags)) { if (Array.isArray(wrapped.tags)) {
wrapped.tags = [...(wrapped.tags as string[])]; wrapped.tags = [...wrapped.tags];
} }
await encryptRecord('comicCharacters', wrapped); await encryptRecord('comicCharacters', wrapped as Record<string, unknown>);
await comicCharactersTable.update(id, { await comicCharactersTable.update(id, wrapped as never);
...wrapped,
updatedAt: new Date().toISOString(),
});
}, },
async toggleFavorite(id: string): Promise<void> { async toggleFavorite(id: string): Promise<void> {
@ -149,14 +143,12 @@ export const comicCharactersStore = {
if (!existing) return; if (!existing) return;
await comicCharactersTable.update(id, { await comicCharactersTable.update(id, {
isFavorite: !existing.isFavorite, isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
}); });
}, },
async archiveCharacter(id: string, archived: boolean): Promise<void> { async archiveCharacter(id: string, archived: boolean): Promise<void> {
await comicCharactersTable.update(id, { await comicCharactersTable.update(id, {
isArchived: archived, isArchived: archived,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -164,7 +156,6 @@ export const comicCharactersStore = {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
await comicCharactersTable.update(id, { await comicCharactersTable.update(id, {
deletedAt: nowIso, deletedAt: nowIso,
updatedAt: nowIso,
}); });
emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, { emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, {
characterId: id, characterId: id,

View file

@ -76,18 +76,15 @@ export const comicStoriesStore = {
): Promise<void> { ): Promise<void> {
// Same proxy-breaking copy as createStory: any array on the patch // Same proxy-breaking copy as createStory: any array on the patch
// might be a $state proxy if the caller is a Svelte 5 component. // might be a $state proxy if the caller is a Svelte 5 component.
const wrapped: Record<string, unknown> = { ...patch }; const wrapped: Partial<LocalComicStory> = { ...patch };
if (Array.isArray(wrapped.characterMediaIds)) { if (Array.isArray(wrapped.characterMediaIds)) {
wrapped.characterMediaIds = [...(wrapped.characterMediaIds as string[])]; wrapped.characterMediaIds = [...wrapped.characterMediaIds];
} }
if (Array.isArray(wrapped.tags)) { if (Array.isArray(wrapped.tags)) {
wrapped.tags = [...(wrapped.tags as string[])]; wrapped.tags = [...wrapped.tags];
} }
await encryptRecord('comicStories', wrapped); await encryptRecord('comicStories', wrapped as Record<string, unknown>);
await comicStoriesTable.update(id, { await comicStoriesTable.update(id, wrapped as never);
...wrapped,
updatedAt: new Date().toISOString(),
});
}, },
async toggleFavorite(id: string): Promise<void> { async toggleFavorite(id: string): Promise<void> {
@ -95,14 +92,12 @@ export const comicStoriesStore = {
if (!existing) return; if (!existing) return;
await comicStoriesTable.update(id, { await comicStoriesTable.update(id, {
isFavorite: !existing.isFavorite, isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
}); });
}, },
async archiveStory(id: string, archived: boolean): Promise<void> { async archiveStory(id: string, archived: boolean): Promise<void> {
await comicStoriesTable.update(id, { await comicStoriesTable.update(id, {
isArchived: archived, isArchived: archived,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -110,7 +105,6 @@ export const comicStoriesStore = {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
await comicStoriesTable.update(id, { await comicStoriesTable.update(id, {
deletedAt: nowIso, deletedAt: nowIso,
updatedAt: nowIso,
}); });
emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, { emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, {
storyId: id, storyId: id,
@ -133,14 +127,13 @@ export const comicStoriesStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(), visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
}; };
if (next === 'unlisted' && !existing.unlistedToken) { if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken(); patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) { } else if (next !== 'unlisted' && existing.unlistedToken) {
patch.unlistedToken = undefined; patch.unlistedToken = undefined;
} }
await comicStoriesTable.update(id, patch); await comicStoriesTable.update(id, patch as never);
emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, { emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, {
recordId: id, recordId: id,
@ -170,10 +163,7 @@ export const comicStoriesStore = {
panelMeta: nextMeta, panelMeta: nextMeta,
} as Record<string, unknown>; } as Record<string, unknown>;
await encryptRecord('comicStories', patch); await encryptRecord('comicStories', patch);
await comicStoriesTable.update(storyId, { await comicStoriesTable.update(storyId, patch as never);
...patch,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, { emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, {
storyId, storyId,
panelImageId, panelImageId,

View file

@ -13,6 +13,7 @@
*/ */
import type { BaseRecord } from '@mana/local-store'; import type { BaseRecord } from '@mana/local-store';
import { deriveUpdatedAt } from '$lib/data/sync';
import type { VisibilityLevel } from '@mana/shared-privacy'; import type { VisibilityLevel } from '@mana/shared-privacy';
// ─── Style ──────────────────────────────────────────────────────── // ─── Style ────────────────────────────────────────────────────────
@ -148,7 +149,7 @@ export function toStory(local: LocalComicStory): ComicStory {
isArchived: local.isArchived, isArchived: local.isArchived,
visibility: local.visibility ?? 'space', visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? '', createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '', updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -235,7 +236,7 @@ export function toCharacter(local: LocalComicCharacter): ComicCharacter {
isFavorite: local.isFavorite, isFavorite: local.isFavorite,
isArchived: local.isArchived, isArchived: local.isArchived,
createdAt: local.createdAt ?? '', createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '', updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -81,12 +81,8 @@
panelImageIds: nextIds, panelImageIds: nextIds,
panelMeta: nextMeta, panelMeta: nextMeta,
} as Partial<LocalComicStory>; } as Partial<LocalComicStory>;
const wrapped = { ...patch } as Record<string, unknown>; await encryptRecord('comicStories', patch as Record<string, unknown>);
await encryptRecord('comicStories', wrapped); await comicStoriesTable.update(story.id, patch as never);
await comicStoriesTable.update(story.id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
} }
</script> </script>

View file

@ -0,0 +1,19 @@
<!--
Community Workbench-Card view — wraps the main ListView for the
Workbench/Homepage carousel. Renders the public feed in the card.
Power-user views (Roadmap, single item) live as own routes.
-->
<script lang="ts">
import ListView from './views/ListView.svelte';
</script>
<div class="community-card">
<ListView />
</div>
<style>
.community-card {
height: 100%;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,217 @@
<!--
ItemCard — Single public feedback item, used in ListView, RoadmapView,
and DetailView (as the parent display). Reactions row is editable when
the user is logged in; in read-only mode (anonymous SSR) it's static.
-->
<script lang="ts">
import {
ReactionBar,
FEEDBACK_CATEGORY_LABELS,
FEEDBACK_STATUS_CONFIG,
type PublicFeedbackItem,
type ReactionEmoji,
} from '@mana/feedback';
interface Props {
item: PublicFeedbackItem;
readOnly?: boolean;
onReact?: (id: string, emoji: ReactionEmoji) => void | Promise<void>;
onClick?: (id: string) => void;
showAdminResponse?: boolean;
}
let { item, readOnly = false, onReact, onClick, showAdminResponse = true }: Props = $props();
let statusConfig = $derived(FEEDBACK_STATUS_CONFIG[item.status]);
let categoryLabel = $derived(FEEDBACK_CATEGORY_LABELS[item.category] ?? item.category);
function handleClick() {
if (onClick) onClick(item.id);
}
function formatDate(s: string): string {
try {
return new Date(s).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
});
} catch {
return s.slice(0, 10);
}
}
async function handleReact(emoji: ReactionEmoji) {
if (onReact) await onReact(item.id, emoji);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<article
class="item-card"
class:clickable={!!onClick}
onclick={handleClick}
role={onClick ? 'button' : undefined}
tabindex={onClick ? 0 : undefined}
>
<header class="item-header">
<div class="meta-row">
<span class="badge category">{categoryLabel}</span>
{#if item.moduleContext}
<span class="badge module">{item.moduleContext}</span>
{/if}
<span class="badge status" style:color={statusConfig.color}>
{statusConfig.label}
</span>
<span class="muted">{formatDate(item.createdAt)}</span>
</div>
{#if item.title}
<h3 class="title">{item.title}</h3>
{/if}
</header>
<p class="text">{item.feedbackText}</p>
{#if showAdminResponse && item.adminResponse}
<div class="admin-response">
<div class="admin-label">Antwort vom Team</div>
<p>{item.adminResponse}</p>
</div>
{/if}
<footer class="item-footer">
<span class="author">{item.displayName}</span>
<ReactionBar
reactions={item.reactions}
myReactions={item.myReactions ?? []}
{readOnly}
onToggle={handleReact}
size="sm"
/>
</footer>
</article>
<style>
.item-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
border-radius: 0.875rem;
text-align: left;
}
.item-card.clickable {
cursor: pointer;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.item-card.clickable:hover {
border-color: hsl(var(--color-primary) / 0.4);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.08);
}
.item-header {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.4375rem;
border-radius: 999px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge.category {
background: hsl(var(--color-muted) / 0.45);
color: hsl(var(--color-foreground));
}
.badge.module {
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
}
.badge.status {
background: transparent;
border: 1px solid currentColor;
}
.muted {
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
}
.text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--color-foreground));
white-space: pre-wrap;
word-break: break-word;
}
.admin-response {
margin-top: 0.25rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.06);
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.admin-response p {
margin: 0;
}
.admin-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: hsl(var(--color-primary));
margin-bottom: 0.125rem;
}
.item-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.125rem;
}
.author {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
</style>

View file

@ -0,0 +1,12 @@
/**
* Community module server-only feedback hub. No Dexie tables, so the
* module-config is mostly nominal (registers the appId for module-context
* tagging on inline FeedbackHook submissions).
*/
import type { ModuleConfig } from '$lib/data/module-registry';
export const communityModuleConfig: ModuleConfig = {
appId: 'community',
tables: [],
};

View file

@ -0,0 +1,132 @@
/**
* Community module SWR-style queries.
*
* Feedback lives server-only (no Dexie / mana-sync), so we expose
* Svelte 5 reactive stores that pull from the feedback service and
* cache results across mounts within a session.
*/
import type { PublicFeedbackItem, FeedbackQueryParams } from '@mana/feedback';
import { feedbackService, publicFeedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
/**
* Stateful query for the public community feed.
*
* Reads via the auth-enriched endpoint when a user is logged in (so each
* item carries `myReactions` for highlight-state); falls back to the
* anonymous endpoint when guest. Polls on demand via `reload()`.
*/
export function useCommunityFeed(initial: FeedbackQueryParams = {}) {
let items = $state<PublicFeedbackItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let lastQuery = $state<FeedbackQueryParams>(initial);
async function reload(query?: FeedbackQueryParams) {
const q = query ?? lastQuery;
lastQuery = q;
loading = true;
error = null;
try {
if (authStore.user) {
items = await feedbackService.getPublicFeed(q);
} else {
items = await publicFeedbackService.getFeed(q);
}
} catch (err) {
console.error('[community/queries] reload failed:', err);
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
} finally {
loading = false;
}
}
// kick off initial load
void reload(initial);
return {
get items() {
return items;
},
get loading() {
return loading;
},
get error() {
return error;
},
reload,
};
}
/**
* Single-item query plus its replies.
*/
export function useCommunityItem(id: string) {
let item = $state<PublicFeedbackItem | null>(null);
let replies = $state<PublicFeedbackItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function reload() {
loading = true;
error = null;
try {
const data = await publicFeedbackService.getItem(id);
item = data.item;
replies = data.replies;
} catch (err) {
console.error('[community/queries] item fetch failed:', err);
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
} finally {
loading = false;
}
}
void reload();
return {
get item() {
return item;
},
get replies() {
return replies;
},
get loading() {
return loading;
},
get error() {
return error;
},
reload,
};
}
/** Toggle a reaction; on success patches `items` in place. */
export async function toggleReactionOnItem(
itemsRef: { items: PublicFeedbackItem[] },
id: string,
emoji: string
) {
if (!authStore.user) return;
try {
const res = await feedbackService.toggleReaction(id, emoji as never);
// Patch in place — caller manages reactivity by re-assigning the array
// or relying on $state-deep reactivity in calling component.
const idx = itemsRef.items.findIndex((i) => i.id === id);
if (idx >= 0) {
const old = itemsRef.items[idx];
const myReactions = res.userHasReacted
? Array.from(new Set([...(old.myReactions ?? []), emoji]))
: (old.myReactions ?? []).filter((e) => e !== emoji);
itemsRef.items[idx] = {
...old,
reactions: res.reactions,
score: res.score,
myReactions,
};
}
} catch (err) {
console.error('[community/queries] toggleReaction failed:', err);
}
}

View file

@ -0,0 +1,217 @@
<!--
DetailView — Single feedback item with its replies. Replies are
1-level (no nesting). Auth-required to post a reply; guests see the
thread read-only.
-->
<script lang="ts">
import type { PublicFeedbackItem, ReactionEmoji } from '@mana/feedback';
import { useCommunityItem } from '../queries';
import ItemCard from '../components/ItemCard.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { feedbackService } from '$lib/api/feedback';
interface Props {
id: string;
}
let { id }: Props = $props();
let view = useCommunityItem(id);
let replyText = $state('');
let saving = $state(false);
let error = $state<string | null>(null);
async function postReply() {
const trimmed = replyText.trim();
if (!trimmed || saving) return;
saving = true;
error = null;
try {
await feedbackService.createFeedback({
feedbackText: trimmed,
parentId: id,
isPublic: true,
category: 'other',
});
replyText = '';
await view.reload();
} catch (err) {
console.error('[community/detail] reply failed:', err);
error = err instanceof Error ? err.message : 'Senden fehlgeschlagen.';
} finally {
saving = false;
}
}
async function handleReact(_id: string, emoji: ReactionEmoji) {
if (!authStore.user) return;
try {
const res = await feedbackService.toggleReaction(_id, emoji);
// Patch parent item in place
if (view.item && view.item.id === _id) {
const myReactions = res.userHasReacted
? Array.from(new Set([...(view.item.myReactions ?? []), emoji]))
: (view.item.myReactions ?? []).filter((e) => e !== emoji);
view.item.reactions = res.reactions;
view.item.score = res.score;
view.item.myReactions = myReactions;
} else {
// Patch in replies
const idx = view.replies.findIndex((r: PublicFeedbackItem) => r.id === _id);
if (idx >= 0) {
const old = view.replies[idx];
const myReactions = res.userHasReacted
? Array.from(new Set([...(old.myReactions ?? []), emoji]))
: (old.myReactions ?? []).filter((e) => e !== emoji);
view.replies[idx] = {
...old,
reactions: res.reactions,
score: res.score,
myReactions,
};
}
}
} catch (err) {
console.error('[community/detail] react failed:', err);
}
}
</script>
<div class="detail">
{#if view.loading}
<div class="state">Lade…</div>
{:else if view.error || !view.item}
<div class="state error">{view.error ?? 'Nicht gefunden'}</div>
{:else}
<ItemCard item={view.item} readOnly={!authStore.user} onReact={handleReact} />
<section class="replies">
<h3 class="replies-header">
{view.replies.length} Antwort{view.replies.length === 1 ? '' : 'en'}
</h3>
{#each view.replies as reply (reply.id)}
<ItemCard
item={reply}
readOnly={!authStore.user}
onReact={handleReact}
showAdminResponse={false}
/>
{/each}
{#if authStore.user}
<div class="reply-form">
<textarea bind:value={replyText} placeholder="Deine Antwort…" maxlength={2000} rows="3"
></textarea>
{#if error}
<p class="error">{error}</p>
{/if}
<button
class="btn-primary"
disabled={saving || replyText.trim().length < 3}
onclick={postReply}
>
{saving ? 'Sende…' : 'Antworten'}
</button>
</div>
{:else}
<p class="login-hint">
<a href="/login" class="link">Login</a>, um zu antworten.
</p>
{/if}
</section>
{/if}
</div>
<style>
.detail {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
}
.replies {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
}
.replies-header {
margin: 0 0 0.25rem 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: hsl(var(--color-muted-foreground));
}
.reply-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.5rem;
}
.reply-form textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font: inherit;
resize: vertical;
}
.reply-form textarea:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
}
.btn-primary {
align-self: flex-end;
padding: 0.5rem 0.875rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.5rem;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.link {
color: hsl(var(--color-primary));
text-decoration: underline;
}
.state {
padding: 2rem 1rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.state.error {
color: hsl(var(--color-error, 0 84% 60%));
}
.error {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-error, 0 84% 60%));
}
</style>

View file

@ -0,0 +1,153 @@
<!--
Community ListView — main feed of public, anonymous feedback.
Default sort: by score (weighted reactions) descending.
Filters: category + module-context. Click an item to navigate to
the detail page; click a reaction to toggle (auth-only).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { FEEDBACK_CATEGORY_LABELS, type FeedbackCategory } from '@mana/feedback';
import { useCommunityFeed, toggleReactionOnItem } from '../queries';
import ItemCard from '../components/ItemCard.svelte';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
/** Optional initial moduleContext filter — passed by the
* workbench-card variant to scope to a single module. */
moduleContext?: string;
}
let { moduleContext }: Props = $props();
let categoryFilter = $state<FeedbackCategory | ''>('');
let modulePill = $state(moduleContext ?? '');
let feed = useCommunityFeed({ moduleContext, limit: 50 });
function applyFilters() {
feed.reload({
category: categoryFilter || undefined,
moduleContext: modulePill || undefined,
limit: 50,
});
}
$effect(() => {
void [categoryFilter, modulePill];
applyFilters();
});
const CATEGORY_OPTIONS: { value: FeedbackCategory | ''; label: string }[] = [
{ value: '', label: 'Alle Kategorien' },
{ value: 'feature', label: FEEDBACK_CATEGORY_LABELS.feature },
{ value: 'improvement', label: FEEDBACK_CATEGORY_LABELS.improvement },
{ value: 'bug', label: FEEDBACK_CATEGORY_LABELS.bug },
{ value: 'praise', label: FEEDBACK_CATEGORY_LABELS.praise },
{ value: 'question', label: FEEDBACK_CATEGORY_LABELS.question },
{ value: 'onboarding-wish', label: FEEDBACK_CATEGORY_LABELS['onboarding-wish'] },
];
let openItemId = $state<string | null>(null);
function handleClickItem(id: string) {
openItemId = id;
void goto(`/community/${id}`);
}
</script>
<div class="community-list">
<header class="filter-row">
<select bind:value={categoryFilter} aria-label="Nach Kategorie filtern">
{#each CATEGORY_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<input
type="text"
placeholder="Modul (z. B. todo)"
bind:value={modulePill}
aria-label="Nach Modul filtern"
/>
</header>
{#if feed.loading && feed.items.length === 0}
<div class="state">Lade…</div>
{:else if feed.error}
<div class="state error">{feed.error}</div>
{:else if feed.items.length === 0}
<div class="state">
Noch keine Stimmen — sei der erste, der was reinwirft.
{#if !authStore.user}
<br />
<a href="/login" class="link">Login</a>, um mitzumachen.
{/if}
</div>
{:else}
<div class="grid">
{#each feed.items as item (item.id)}
<ItemCard
{item}
readOnly={!authStore.user}
onReact={(id, emoji) => toggleReactionOnItem(feed, id, emoji)}
onClick={handleClickItem}
/>
{/each}
</div>
{/if}
</div>
<style>
.community-list {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
}
.filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.filter-row select,
.filter-row input {
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.filter-row select:focus,
.filter-row input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.625rem;
}
.state {
padding: 2rem 1rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.state.error {
color: hsl(var(--color-error, 0 84% 60%));
}
.link {
color: hsl(var(--color-primary));
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,131 @@
<!--
RoadmapView — 4-column Kanban over the public feed:
Submitted | Planned | In Progress | Shipped (= completed)
Uses 4 separate feed-queries (one per status) so each column can be
paginated independently later. Read-only for guests; clicking an item
navigates to the detail page.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import type { FeedbackStatus, PublicFeedbackItem } from '@mana/feedback';
import { useCommunityFeed, toggleReactionOnItem } from '../queries';
import ItemCard from '../components/ItemCard.svelte';
import { authStore } from '$lib/stores/auth.svelte';
const COLUMNS: { status: FeedbackStatus; label: string; tone: string }[] = [
{ status: 'submitted', label: 'Eingereicht', tone: '#6B7280' },
{ status: 'planned', label: 'Geplant', tone: '#9B59B6' },
{ status: 'in_progress', label: 'In Arbeit', tone: '#F39C12' },
{ status: 'completed', label: 'Geliefert', tone: '#27AE60' },
];
const queries = COLUMNS.map((col) => ({
col,
feed: useCommunityFeed({ status: col.status, limit: 25 }),
}));
function handleClick(id: string) {
void goto(`/community/${id}`);
}
async function handleReact(feedRef: { items: PublicFeedbackItem[] }, id: string, emoji: string) {
await toggleReactionOnItem(feedRef, id, emoji);
}
</script>
<div class="roadmap">
{#each queries as { col, feed } (col.status)}
<section class="column" style:border-top-color={col.tone}>
<header class="col-header">
<h3 style:color={col.tone}>{col.label}</h3>
<span class="count">{feed.items.length}</span>
</header>
<div class="col-body">
{#if feed.loading && feed.items.length === 0}
<div class="state">Lade…</div>
{:else if feed.items.length === 0}
<div class="state">Nichts hier.</div>
{:else}
{#each feed.items as item (item.id)}
<ItemCard
{item}
readOnly={!authStore.user}
onReact={(id, emoji) => handleReact(feed, id, emoji)}
onClick={handleClick}
/>
{/each}
{/if}
</div>
</section>
{/each}
</div>
<style>
.roadmap {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.625rem;
padding: 0.75rem;
min-height: 100%;
}
@media (max-width: 960px) {
.roadmap {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 540px) {
.roadmap {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
background: hsl(var(--color-muted) / 0.25);
border: 1px solid hsl(var(--color-border));
border-top: 3px solid;
border-radius: 0.75rem;
min-height: 12rem;
}
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.375rem 0.5rem;
}
.col-header h3 {
margin: 0;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.col-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.state {
padding: 1rem 0.5rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
}
</style>

View file

@ -14,7 +14,11 @@ export function useConversations() {
'companion', 'companion',
'companionConversations' 'companionConversations'
).toArray(); ).toArray();
return all.filter((c) => !c.deletedAt).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return all
.filter((c) => !c.deletedAt)
.sort((a, b) =>
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
);
} catch { } catch {
return []; return [];
} }

View file

@ -22,7 +22,6 @@ export const chatStore = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: title ?? 'Neues Gespraech', title: title ?? 'Neues Gespraech',
createdAt: now, createdAt: now,
updatedAt: now,
}; };
await db.table<LocalConversation>(CONV_TABLE).add(conv); await db.table<LocalConversation>(CONV_TABLE).add(conv);
emitDomainEvent('CompanionConversationStarted', 'companion', CONV_TABLE, conv.id, { emitDomainEvent('CompanionConversationStarted', 'companion', CONV_TABLE, conv.id, {
@ -35,14 +34,12 @@ export const chatStore = {
async renameConversation(id: string, title: string): Promise<void> { async renameConversation(id: string, title: string): Promise<void> {
await db.table<LocalConversation>(CONV_TABLE).update(id, { await db.table<LocalConversation>(CONV_TABLE).update(id, {
title, title,
updatedAt: new Date().toISOString(),
}); });
}, },
async deleteConversation(id: string): Promise<void> { async deleteConversation(id: string): Promise<void> {
await db.table<LocalConversation>(CONV_TABLE).update(id, { await db.table<LocalConversation>(CONV_TABLE).update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -68,9 +65,10 @@ export const chatStore = {
}; };
await db.table<LocalMessage>(MSG_TABLE).add(msg); await db.table<LocalMessage>(MSG_TABLE).add(msg);
// Touch conversation updatedAt // Touch conversation so the chat list re-sorts with the most
// recently active conversation at the top.
await db.table<LocalConversation>(CONV_TABLE).update(conversationId, { await db.table<LocalConversation>(CONV_TABLE).update(conversationId, {
updatedAt: msg.createdAt, lastMessageAt: msg.createdAt,
}); });
// Emit event only for actual user/assistant messages, not tool plumbing // Emit event only for actual user/assistant messages, not tool plumbing

View file

@ -6,7 +6,11 @@ export interface LocalConversation {
id: string; id: string;
title: string; title: string;
createdAt: string; createdAt: string;
updatedAt: string; /** Real data field touched on every new message. Used as the sort key
* in the conversation list so the most recently active chat floats
* to the top. Replaces the older `updatedAt` reliance F3 of the
* sync-field-meta overhaul moved updatedAt off Local records. */
lastMessageAt?: string;
deletedAt?: string; deletedAt?: string;
} }

View file

@ -3,6 +3,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule, applyVisibility } from '$lib/data/scope'; import { scopedForModule, applyVisibility } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -46,7 +47,7 @@ export function toContact(local: LocalContact): Contact {
isFavorite: local.isFavorite ?? false, isFavorite: local.isFavorite ?? false,
isArchived: local.isArchived ?? false, isArchived: local.isArchived ?? false,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -87,7 +87,6 @@ export const contactsStore = {
const diff: Partial<LocalContact> = { const diff: Partial<LocalContact> = {
...updateData, ...updateData,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('contacts', diff); await encryptRecord('contacts', diff);
await contactTable.update(id, diff); await contactTable.update(id, diff);
@ -99,7 +98,6 @@ export const contactsStore = {
const decrypted = local ? await decryptRecord('contacts', { ...local }) : null; const decrypted = local ? await decryptRecord('contacts', { ...local }) : null;
await contactTable.update(id, { await contactTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('ContactDeleted', 'contacts', 'contacts', id, { emitDomainEvent('ContactDeleted', 'contacts', 'contacts', id, {
contactId: id, contactId: id,
@ -114,7 +112,6 @@ export const contactsStore = {
await contactTable.update(id, { await contactTable.update(id, {
isFavorite: !local.isFavorite, isFavorite: !local.isFavorite,
updatedAt: new Date().toISOString(),
}); });
ContactsEvents.contactFavorited(); ContactsEvents.contactFavorited();
}, },
@ -122,7 +119,6 @@ export const contactsStore = {
async updateTagIds(id: string, tagIds: string[]) { async updateTagIds(id: string, tagIds: string[]) {
await contactTable.update(id, { await contactTable.update(id, {
tagIds, tagIds,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -153,7 +149,6 @@ export const contactsStore = {
isFavorite: true, isFavorite: true,
isArchived: false, isArchived: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('contacts', self); await encryptRecord('contacts', self);
await contactTable.add(self); await contactTable.add(self);
@ -178,7 +173,6 @@ export const contactsStore = {
lastName, lastName,
email: profile.email || undefined, email: profile.email || undefined,
photoUrl: profile.image || undefined, photoUrl: profile.image || undefined,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('contacts', diff); await encryptRecord('contacts', diff);
await contactTable.update(SELF_CONTACT_ID, diff); await contactTable.update(SELF_CONTACT_ID, diff);

View file

@ -76,7 +76,6 @@
await db.table('tasks').update(task.id, { await db.table('tasks').update(task.id, {
isCompleted: true, isCompleted: true,
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
} }
</script> </script>

View file

@ -1,4 +1,5 @@
import { formatDate } from '$lib/i18n/format'; import { formatDate } from '$lib/i18n/format';
import { deriveUpdatedAt } from '$lib/data/sync';
/** /**
* Reactive Queries & Pure Helpers for Dreams module. * Reactive Queries & Pure Helpers for Dreams module.
* *
@ -44,7 +45,7 @@ export function toDream(local: LocalDream): Dream {
isPinned: local.isPinned, isPinned: local.isPinned,
isArchived: local.isArchived, isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -56,7 +57,7 @@ export function toDreamSymbol(local: LocalDreamSymbol): DreamSymbol {
color: local.color, color: local.color,
count: local.count ?? 0, count: local.count ?? 0,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -198,7 +198,6 @@ export const dreamsStore = {
const diff: Partial<LocalDream> = { const diff: Partial<LocalDream> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('dreams', diff); await encryptRecord('dreams', diff);
await dreamTable.update(id, diff); await dreamTable.update(id, diff);
@ -256,7 +255,6 @@ export const dreamsStore = {
await dreamTable.update(id, { await dreamTable.update(id, {
processingStatus: status, processingStatus: status,
processingError: error, processingError: error,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -285,7 +283,6 @@ export const dreamsStore = {
content: decryptedExisting.content?.trim() ? decryptedExisting.content : transcript, content: decryptedExisting.content?.trim() ? decryptedExisting.content : transcript,
processingStatus: 'idle', processingStatus: 'idle',
processingError: null, processingError: null,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('dreams', diff); await encryptRecord('dreams', diff);
await dreamTable.update(dreamId, diff); await dreamTable.update(dreamId, diff);
@ -294,7 +291,6 @@ export const dreamsStore = {
await dreamTable.update(dreamId, { await dreamTable.update(dreamId, {
processingStatus: 'failed', processingStatus: 'failed',
processingError: msg, processingError: msg,
updatedAt: new Date().toISOString(),
}); });
} }
}, },
@ -309,7 +305,6 @@ export const dreamsStore = {
} }
await dreamTable.update(id, { await dreamTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('DreamDeleted', 'dreams', 'dreams', id, { dreamId: id }); emitDomainEvent('DreamDeleted', 'dreams', 'dreams', id, { dreamId: id });
}, },
@ -319,7 +314,6 @@ export const dreamsStore = {
if (!dream) return; if (!dream) return;
await dreamTable.update(id, { await dreamTable.update(id, {
isPinned: !dream.isPinned, isPinned: !dream.isPinned,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -328,21 +322,18 @@ export const dreamsStore = {
if (!dream) return; if (!dream) return;
await dreamTable.update(id, { await dreamTable.update(id, {
isLucid: !dream.isLucid, isLucid: !dream.isLucid,
updatedAt: new Date().toISOString(),
}); });
}, },
async setMood(id: string, mood: DreamMood | null) { async setMood(id: string, mood: DreamMood | null) {
await dreamTable.update(id, { await dreamTable.update(id, {
mood, mood,
updatedAt: new Date().toISOString(),
}); });
}, },
async setSleepQuality(id: string, quality: SleepQuality | null) { async setSleepQuality(id: string, quality: SleepQuality | null) {
await dreamTable.update(id, { await dreamTable.update(id, {
sleepQuality: quality, sleepQuality: quality,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -372,7 +363,6 @@ export const dreamsStore = {
const updated = dream.symbols.map((s) => (s === existing.name ? newName : s)); const updated = dream.symbols.map((s) => (s === existing.name ? newName : s));
await dreamTable.update(dream.id, { await dreamTable.update(dream.id, {
symbols: updated, symbols: updated,
updatedAt: new Date().toISOString(),
}); });
} }
} }
@ -380,7 +370,6 @@ export const dreamsStore = {
const symbolDiff: Record<string, unknown> = { const symbolDiff: Record<string, unknown> = {
...data, ...data,
...(data.name ? { name: data.name.trim() } : {}), ...(data.name ? { name: data.name.trim() } : {}),
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('dreamSymbols', symbolDiff); await encryptRecord('dreamSymbols', symbolDiff);
await dreamSymbolTable.update(id, symbolDiff); await dreamSymbolTable.update(id, symbolDiff);
@ -396,13 +385,11 @@ export const dreamsStore = {
if (dream.deletedAt || !dream.symbols?.includes(symbol.name)) continue; if (dream.deletedAt || !dream.symbols?.includes(symbol.name)) continue;
await dreamTable.update(dream.id, { await dreamTable.update(dream.id, {
symbols: dream.symbols.filter((s) => s !== symbol.name), symbols: dream.symbols.filter((s) => s !== symbol.name),
updatedAt: new Date().toISOString(),
}); });
} }
await dreamSymbolTable.update(id, { await dreamSymbolTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -421,17 +408,14 @@ export const dreamsStore = {
set.add(target.name); set.add(target.name);
await dreamTable.update(dream.id, { await dreamTable.update(dream.id, {
symbols: Array.from(set), symbols: Array.from(set),
updatedAt: new Date().toISOString(),
}); });
} }
await dreamSymbolTable.update(targetId, { await dreamSymbolTable.update(targetId, {
count: (target.count ?? 0) + (source.count ?? 0), count: (target.count ?? 0) + (source.count ?? 0),
updatedAt: new Date().toISOString(),
}); });
await dreamSymbolTable.update(sourceId, { await dreamSymbolTable.update(sourceId, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -445,7 +429,6 @@ export const dreamsStore = {
const next = Math.max(0, (existing.count ?? 0) + delta); const next = Math.max(0, (existing.count ?? 0) + delta);
await dreamSymbolTable.update(existing.id, { await dreamSymbolTable.update(existing.id, {
count: next, count: next,
updatedAt: new Date().toISOString(),
}); });
} else if (delta > 0) { } else if (delta > 0) {
await dreamSymbolTable.add({ await dreamSymbolTable.add({

View file

@ -3,6 +3,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
@ -22,7 +23,7 @@ export function toDrinkEntry(local: LocalDrinkEntry): DrinkEntry {
note: local.note ?? null, note: local.note ?? null,
presetId: local.presetId ?? null, presetId: local.presetId ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -38,7 +39,7 @@ export function toDrinkPreset(local: LocalDrinkPreset): DrinkPreset {
order: local.order, order: local.order,
isArchived: local.isArchived, isArchived: local.isArchived,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -69,7 +69,6 @@ export const drinkStore = {
await encryptRecord('drinkEntries', wrapped); await encryptRecord('drinkEntries', wrapped);
await drinkEntryTable.update(id, { await drinkEntryTable.update(id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -77,7 +76,6 @@ export const drinkStore = {
const entry = await drinkEntryTable.get(id); const entry = await drinkEntryTable.get(id);
await drinkEntryTable.update(id, { await drinkEntryTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
if (entry) { if (entry) {
emitDomainEvent('DrinkEntryDeleted', 'drink', 'drinkEntries', id, { emitDomainEvent('DrinkEntryDeleted', 'drink', 'drinkEntries', id, {
@ -97,7 +95,6 @@ export const drinkStore = {
const entry = active[0]; const entry = active[0];
await drinkEntryTable.update(entry.id, { await drinkEntryTable.update(entry.id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('DrinkEntryUndone', 'drink', 'drinkEntries', entry.id, { emitDomainEvent('DrinkEntryUndone', 'drink', 'drinkEntries', entry.id, {
entryId: entry.id, entryId: entry.id,
@ -146,14 +143,12 @@ export const drinkStore = {
await encryptRecord('drinkPresets', wrapped); await encryptRecord('drinkPresets', wrapped);
await drinkPresetTable.update(id, { await drinkPresetTable.update(id, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
}, },
async deletePreset(id: string) { async deletePreset(id: string) {
await drinkPresetTable.update(id, { await drinkPresetTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -162,7 +157,6 @@ export const drinkStore = {
for (let i = 0; i < presetIds.length; i++) { for (let i = 0; i < presetIds.length; i++) {
await drinkPresetTable.update(presetIds[i], { await drinkPresetTable.update(presetIds[i], {
order: i, order: i,
updatedAt: now,
}); });
} }
}, },

View file

@ -35,7 +35,6 @@ export interface DiscoverySource {
lastError: string | null; lastError: string | null;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string;
} }
export interface DiscoveredEvent { export interface DiscoveredEvent {

View file

@ -5,6 +5,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -47,7 +48,7 @@ export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | n
endTime: block?.endDate ?? block?.startDate ?? now, endTime: block?.endDate ?? block?.startDate ?? now,
isAllDay: block?.allDay ?? false, isAllDay: block?.allDay ?? false,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -64,7 +65,7 @@ export function toEventItem(local: LocalEventItem): EventItem {
claimedByName: local.claimedByName ?? null, claimedByName: local.claimedByName ?? null,
claimedAt: local.claimedAt ?? null, claimedAt: local.claimedAt ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -82,7 +83,7 @@ export function toEventGuest(local: LocalEventGuest): EventGuest {
plusOnes: local.plusOnes ?? 0, plusOnes: local.plusOnes ?? 0,
note: local.note ?? null, note: local.note ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -71,7 +71,6 @@ export const eventsStore = {
status: input.status ?? 'draft', status: input.status ?? 'draft',
visibility: defaultVisibilityFor(getActiveSpace()?.type), visibility: defaultVisibilityFor(getActiveSpace()?.type),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
// title / description / location are encrypted at rest. The // title / description / location are encrypted at rest. The
@ -125,9 +124,7 @@ export const eventsStore = {
await updateBlock(event.timeBlockId, blockUpdates); await updateBlock(event.timeBlockId, blockUpdates);
} }
const localData: Partial<LocalSocialEvent> = { const localData: Partial<LocalSocialEvent> = {};
updatedAt: new Date().toISOString(),
};
if (input.title !== undefined) localData.title = input.title; if (input.title !== undefined) localData.title = input.title;
if (input.description !== undefined) localData.description = input.description; if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location; if (input.location !== undefined) localData.location = input.location;
@ -170,7 +167,6 @@ export const eventsStore = {
} }
await db.table('socialEvents').update(id, { await db.table('socialEvents').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('SocialEventDeleted', 'events', 'socialEvents', id, { eventId: id }); emitDomainEvent('SocialEventDeleted', 'events', 'socialEvents', id, { eventId: id });
return { success: true as const }; return { success: true as const };
@ -199,7 +195,6 @@ export const eventsStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(), visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
}); });
emitDomainEvent('VisibilityChanged', 'events', 'socialEvents', id, { emitDomainEvent('VisibilityChanged', 'events', 'socialEvents', id, {
@ -246,7 +241,6 @@ export const eventsStore = {
isPublished: true, isPublished: true,
publicToken: token, publicToken: token,
status: 'published' satisfies EventStatus, status: 'published' satisfies EventStatus,
updatedAt: new Date().toISOString(),
}); });
// Push any pre-existing bring-list items right away so the // Push any pre-existing bring-list items right away so the
// public page shows them on first open. // public page shows them on first open.
@ -276,7 +270,6 @@ export const eventsStore = {
isPublished: false, isPublished: false,
publicToken: null, publicToken: null,
status: 'draft' satisfies EventStatus, status: 'draft' satisfies EventStatus,
updatedAt: new Date().toISOString(),
}); });
return { success: true as const }; return { success: true as const };
} catch (e) { } catch (e) {

View file

@ -38,7 +38,6 @@ export const eventGuestsStore = {
plusOnes: input.plusOnes ?? 0, plusOnes: input.plusOnes ?? 0,
note: input.note ?? null, note: input.note ?? null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
// name / email / phone / note are encrypted at rest. Guest // name / email / phone / note are encrypted at rest. Guest
// records stay local-only — they're never pushed to the // records stay local-only — they're never pushed to the
@ -68,7 +67,6 @@ export const eventGuestsStore = {
try { try {
const data: Partial<LocalEventGuest> = { const data: Partial<LocalEventGuest> = {
...input, ...input,
updatedAt: new Date().toISOString(),
}; };
if (input.rsvpStatus !== undefined) { if (input.rsvpStatus !== undefined) {
data.rsvpAt = new Date().toISOString(); data.rsvpAt = new Date().toISOString();
@ -91,7 +89,6 @@ export const eventGuestsStore = {
try { try {
await db.table('eventGuests').update(id, { await db.table('eventGuests').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
return { success: true as const }; return { success: true as const };
} catch (e) { } catch (e) {

View file

@ -45,7 +45,6 @@ export const eventItemsStore = {
claimedByName: null, claimedByName: null,
claimedAt: null, claimedAt: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
await db.table<LocalEventItem>('eventItems').add(newItem); await db.table<LocalEventItem>('eventItems').add(newItem);
void eventsStore.syncItems(input.eventId); void eventsStore.syncItems(input.eventId);
@ -71,7 +70,6 @@ export const eventItemsStore = {
try { try {
await db.table('eventItems').update(id, { await db.table('eventItems').update(id, {
...input, ...input,
updatedAt: new Date().toISOString(),
}); });
// Push the updated bring list to the server. We need the // Push the updated bring list to the server. We need the
// parent eventId, so re-read the row first. // parent eventId, so re-read the row first.
@ -103,7 +101,6 @@ export const eventItemsStore = {
const item = await db.table<LocalEventItem>('eventItems').get(id); const item = await db.table<LocalEventItem>('eventItems').get(id);
await db.table('eventItems').update(id, { await db.table('eventItems').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
if (item) void eventsStore.syncItems(item.eventId); if (item) void eventsStore.syncItems(item.eventId);
return { success: true as const }; return { success: true as const };

View file

@ -3,6 +3,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -26,7 +27,7 @@ export function toTransaction(local: LocalTransaction): Transaction {
date: local.date, date: local.date,
note: local.note, note: local.note,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -52,7 +52,6 @@ export const financeStore = {
) { ) {
const diff: Partial<LocalTransaction> = { const diff: Partial<LocalTransaction> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('transactions', diff); await encryptRecord('transactions', diff);
await transactionTable.update(id, diff); await transactionTable.update(id, diff);
@ -61,7 +60,6 @@ export const financeStore = {
async deleteTransaction(id: string) { async deleteTransaction(id: string) {
await transactionTable.update(id, { await transactionTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('TransactionDeleted', 'finance', 'transactions', id, { transactionId: id }); emitDomainEvent('TransactionDeleted', 'finance', 'transactions', id, { transactionId: id });
}, },

View file

@ -1,4 +1,5 @@
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -28,7 +29,7 @@ export function toFirst(local: LocalFirst): First {
isPinned: local.isPinned, isPinned: local.isPinned,
isArchived: local.isArchived, isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -126,7 +126,6 @@ export const firstsStore = {
reality: data.reality ?? null, reality: data.reality ?? null,
rating: data.rating ?? null, rating: data.rating ?? null,
wouldRepeat: data.wouldRepeat ?? null, wouldRepeat: data.wouldRepeat ?? null,
updatedAt: new Date().toISOString(),
}; };
if (data.personIds) diff.personIds = data.personIds; if (data.personIds) diff.personIds = data.personIds;
if (data.sharedWith !== undefined) diff.sharedWith = data.sharedWith; if (data.sharedWith !== undefined) diff.sharedWith = data.sharedWith;
@ -164,7 +163,6 @@ export const firstsStore = {
) { ) {
const diff: Partial<LocalFirst> = { const diff: Partial<LocalFirst> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('firsts', diff); await encryptRecord('firsts', diff);
await firstTable.update(id, diff); await firstTable.update(id, diff);
@ -173,7 +171,6 @@ export const firstsStore = {
async deleteFirst(id: string) { async deleteFirst(id: string) {
await firstTable.update(id, { await firstTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -182,14 +179,12 @@ export const firstsStore = {
if (!first) return; if (!first) return;
await firstTable.update(id, { await firstTable.update(id, {
isPinned: !first.isPinned, isPinned: !first.isPinned,
updatedAt: new Date().toISOString(),
}); });
}, },
async archiveFirst(id: string) { async archiveFirst(id: string) {
await firstTable.update(id, { await firstTable.update(id, {
isArchived: true, isArchived: true,
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -70,7 +70,6 @@ export const mealMutations = {
photoThumbnailUrl: null, photoThumbnailUrl: null,
foods: null, foods: null,
createdAt: now, createdAt: now,
updatedAt: now,
}; };
const encrypted: Record<string, unknown> = { ...row }; const encrypted: Record<string, unknown> = { ...row };
await encryptRecord('meals', encrypted); await encryptRecord('meals', encrypted);
@ -104,7 +103,6 @@ export const mealMutations = {
photoThumbnailUrl: dto.photoThumbnailUrl ?? null, photoThumbnailUrl: dto.photoThumbnailUrl ?? null,
foods: dto.foods ?? null, foods: dto.foods ?? null,
createdAt: now, createdAt: now,
updatedAt: now,
}; };
const encrypted: Record<string, unknown> = { ...row }; const encrypted: Record<string, unknown> = { ...row };
await encryptRecord('meals', encrypted); await encryptRecord('meals', encrypted);
@ -130,9 +128,7 @@ export const mealMutations = {
* and decrypts it for the caller. * and decrypts it for the caller.
*/ */
async update(id: string, dto: UpdateMealDto): Promise<LocalMeal> { async update(id: string, dto: UpdateMealDto): Promise<LocalMeal> {
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {};
updatedAt: new Date().toISOString(),
};
if (dto.mealType !== undefined) updateData.mealType = dto.mealType; if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
if (dto.description !== undefined) updateData.description = dto.description.trim(); if (dto.description !== undefined) updateData.description = dto.description.trim();
if (dto.nutrition !== undefined) updateData.nutrition = dto.nutrition; if (dto.nutrition !== undefined) updateData.nutrition = dto.nutrition;
@ -150,7 +146,7 @@ export const mealMutations = {
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
const existing = await db.table<LocalMeal>('meals').get(id); const existing = await db.table<LocalMeal>('meals').get(id);
const now = new Date().toISOString(); const now = new Date().toISOString();
await db.table('meals').update(id, { deletedAt: now, updatedAt: now }); await db.table('meals').update(id, { deletedAt: now });
emitDomainEvent('MealDeleted', 'food', 'meals', id, { emitDomainEvent('MealDeleted', 'food', 'meals', id, {
mealId: id, mealId: id,
mealType: existing?.mealType ?? '', mealType: existing?.mealType ?? '',

View file

@ -6,6 +6,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -34,7 +35,7 @@ export function toGuide(local: LocalGuide): Guide {
isPublished: local.isPublished, isPublished: local.isPublished,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -46,7 +47,7 @@ export function toSection(local: LocalSection): Section {
content: local.content, content: local.content,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -59,7 +60,7 @@ export function toStep(local: LocalStep): Step {
content: local.content, content: local.content,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -71,7 +72,7 @@ export function toRun(local: LocalRun): Run {
completedAt: local.completedAt, completedAt: local.completedAt,
completedStepIds: local.completedStepIds, completedStepIds: local.completedStepIds,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -57,7 +57,7 @@ export const guidesStore = {
}, },
async updateGuide(id: string, dto: UpdateGuideDto): Promise<void> { async updateGuide(id: string, dto: UpdateGuideDto): Promise<void> {
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() }; const updates: Record<string, unknown> = {};
if (dto.title !== undefined) updates.title = dto.title; if (dto.title !== undefined) updates.title = dto.title;
if (dto.description !== undefined) updates.description = dto.description; if (dto.description !== undefined) updates.description = dto.description;
if (dto.category !== undefined) updates.category = dto.category; if (dto.category !== undefined) updates.category = dto.category;
@ -74,17 +74,17 @@ export const guidesStore = {
// Cascade: soft-delete sections, steps, and runs // Cascade: soft-delete sections, steps, and runs
const sections = await sectionTable.where('guideId').equals(id).toArray(); const sections = await sectionTable.where('guideId').equals(id).toArray();
for (const s of sections) { for (const s of sections) {
await sectionTable.update(s.id, { deletedAt: now, updatedAt: now }); await sectionTable.update(s.id, { deletedAt: now });
} }
const steps = await stepTable.where('guideId').equals(id).toArray(); const steps = await stepTable.where('guideId').equals(id).toArray();
for (const s of steps) { for (const s of steps) {
await stepTable.update(s.id, { deletedAt: now, updatedAt: now }); await stepTable.update(s.id, { deletedAt: now });
} }
const runs = await runTable.where('guideId').equals(id).toArray(); const runs = await runTable.where('guideId').equals(id).toArray();
for (const r of runs) { for (const r of runs) {
await runTable.update(r.id, { deletedAt: now, updatedAt: now }); await runTable.update(r.id, { deletedAt: now });
} }
await guideTable.update(id, { deletedAt: now, updatedAt: now }); await guideTable.update(id, { deletedAt: now });
}, },
// ─── Sections ──────────────────────────────────────── // ─── Sections ────────────────────────────────────────
@ -107,7 +107,7 @@ export const guidesStore = {
}, },
async updateSection(id: string, dto: UpdateSectionDto): Promise<void> { async updateSection(id: string, dto: UpdateSectionDto): Promise<void> {
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() }; const updates: Record<string, unknown> = {};
if (dto.title !== undefined) updates.title = dto.title; if (dto.title !== undefined) updates.title = dto.title;
if (dto.content !== undefined) updates.content = dto.content; if (dto.content !== undefined) updates.content = dto.content;
await encryptRecord('sections', updates); await encryptRecord('sections', updates);
@ -116,7 +116,7 @@ export const guidesStore = {
async deleteSection(id: string): Promise<void> { async deleteSection(id: string): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
await sectionTable.update(id, { deletedAt: now, updatedAt: now }); await sectionTable.update(id, { deletedAt: now });
}, },
// ─── Steps ─────────────────────────────────────────── // ─── Steps ───────────────────────────────────────────
@ -140,7 +140,7 @@ export const guidesStore = {
}, },
async updateStep(id: string, dto: UpdateStepDto): Promise<void> { async updateStep(id: string, dto: UpdateStepDto): Promise<void> {
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() }; const updates: Record<string, unknown> = {};
if (dto.title !== undefined) updates.title = dto.title; if (dto.title !== undefined) updates.title = dto.title;
if (dto.content !== undefined) updates.content = dto.content; if (dto.content !== undefined) updates.content = dto.content;
if (dto.sectionId !== undefined) updates.sectionId = dto.sectionId; if (dto.sectionId !== undefined) updates.sectionId = dto.sectionId;
@ -150,7 +150,7 @@ export const guidesStore = {
async deleteStep(id: string): Promise<void> { async deleteStep(id: string): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
await stepTable.update(id, { deletedAt: now, updatedAt: now }); await stepTable.update(id, { deletedAt: now });
}, },
// ─── Runs (Progress Tracking) ──────────────────────── // ─── Runs (Progress Tracking) ────────────────────────
@ -194,7 +194,6 @@ export const guidesStore = {
if (run.completedStepIds.includes(stepId)) return; if (run.completedStepIds.includes(stepId)) return;
await runTable.update(runId, { await runTable.update(runId, {
completedStepIds: [...run.completedStepIds, stepId], completedStepIds: [...run.completedStepIds, stepId],
updatedAt: new Date().toISOString(),
}); });
}, },
@ -203,7 +202,6 @@ export const guidesStore = {
if (!run) return; if (!run) return;
await runTable.update(runId, { await runTable.update(runId, {
completedStepIds: run.completedStepIds.filter((id) => id !== stepId), completedStepIds: run.completedStepIds.filter((id) => id !== stepId),
updatedAt: new Date().toISOString(),
}); });
}, },
@ -212,7 +210,6 @@ export const guidesStore = {
const run = await runTable.get(runId); const run = await runTable.get(runId);
await runTable.update(runId, { await runTable.update(runId, {
completedAt: now, completedAt: now,
updatedAt: now,
}); });
if (run?.timeBlockId) { if (run?.timeBlockId) {
await updateBlock(run.timeBlockId, { endDate: now }); await updateBlock(run.timeBlockId, { endDate: now });
@ -225,6 +222,6 @@ export const guidesStore = {
if (run?.timeBlockId) { if (run?.timeBlockId) {
await deleteBlock(run.timeBlockId); await deleteBlock(run.timeBlockId);
} }
await runTable.update(id, { deletedAt: now, updatedAt: now }); await runTable.update(id, { deletedAt: now });
}, },
}; };

View file

@ -5,6 +5,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types'; import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
@ -28,7 +29,7 @@ export function toHabit(local: LocalHabit): Habit {
// default at create time in habits.svelte.ts. // default at create time in habits.svelte.ts.
visibility: local.visibility ?? 'space', visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -126,7 +126,6 @@ export const habitsStore = {
) { ) {
await habitTable.update(id, { await habitTable.update(id, {
...data, ...data,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -148,7 +147,6 @@ export const habitsStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(), visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
}); });
emitDomainEvent('VisibilityChanged', 'habits', 'habits', id, { emitDomainEvent('VisibilityChanged', 'habits', 'habits', id, {
@ -163,7 +161,6 @@ export const habitsStore = {
const habit = await habitTable.get(id); const habit = await habitTable.get(id);
await habitTable.update(id, { await habitTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('HabitDeleted', 'habits', 'habits', id, { emitDomainEvent('HabitDeleted', 'habits', 'habits', id, {
habitId: id, habitId: id,
@ -318,7 +315,6 @@ export const habitsStore = {
for (let i = 0; i < habitIds.length; i++) { for (let i = 0; i < habitIds.length; i++) {
await habitTable.update(habitIds[i], { await habitTable.update(habitIds[i], {
order: i, order: i,
updatedAt: now,
}); });
} }
}, },
@ -334,7 +330,6 @@ export const habitsStore = {
// Update the habit record // Update the habit record
await habitTable.update(habitId, { await habitTable.update(habitId, {
schedule, schedule,
updatedAt: new Date().toISOString(),
}); });
// Find existing template block for this habit // Find existing template block for this habit

View file

@ -5,6 +5,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
@ -104,7 +105,7 @@ export function toCollection(local: LocalCollection): Collection {
order: local.order, order: local.order,
itemCount: local.itemCount, itemCount: local.itemCount,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -126,7 +127,7 @@ export function toItem(local: LocalItem): Item {
tags: local.tags, tags: local.tags,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -141,7 +142,7 @@ export function toLocation(local: LocalLocation): Location {
depth: local.depth, depth: local.depth,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }
@ -154,7 +155,7 @@ export function toCategory(local: LocalCategory): Category {
color: local.color ?? undefined, color: local.color ?? undefined,
order: local.order, order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -32,7 +32,6 @@ export const categoriesStore = {
async update(id: string, data: Partial<Pick<LocalCategory, 'name' | 'icon' | 'color'>>) { async update(id: string, data: Partial<Pick<LocalCategory, 'name' | 'icon' | 'color'>>) {
await invCategoryTable.update(id, { await invCategoryTable.update(id, {
...data, ...data,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -47,7 +46,7 @@ export const categoriesStore = {
collectIds(id); collectIds(id);
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const deleteId of idsToDelete) { for (const deleteId of idsToDelete) {
await invCategoryTable.update(deleteId, { deletedAt: now, updatedAt: now }); await invCategoryTable.update(deleteId, { deletedAt: now });
} }
InventoryEvents.categoryDeleted(); InventoryEvents.categoryDeleted();
}, },

View file

@ -43,14 +43,12 @@ export const collectionsStore = {
) { ) {
await invCollectionTable.update(id, { await invCollectionTable.update(id, {
...data, ...data,
updatedAt: new Date().toISOString(),
}); });
}, },
async delete(id: string) { async delete(id: string) {
await invCollectionTable.update(id, { await invCollectionTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
InventoryEvents.collectionDeleted(); InventoryEvents.collectionDeleted();
}, },
@ -58,14 +56,13 @@ export const collectionsStore = {
async reorder(orderedIds: string[]) { async reorder(orderedIds: string[]) {
const now = new Date().toISOString(); const now = new Date().toISOString();
for (let i = 0; i < orderedIds.length; i++) { for (let i = 0; i < orderedIds.length; i++) {
await invCollectionTable.update(orderedIds[i], { order: i, updatedAt: now }); await invCollectionTable.update(orderedIds[i], { order: i });
} }
}, },
async updateItemCount(collectionId: string, count: number) { async updateItemCount(collectionId: string, count: number) {
await invCollectionTable.update(collectionId, { await invCollectionTable.update(collectionId, {
itemCount: count, itemCount: count,
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -78,7 +78,6 @@ export const itemsStore = {
) { ) {
const diff: Partial<LocalItem> = { const diff: Partial<LocalItem> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('invItems', diff); await encryptRecord('invItems', diff);
await invItemTable.update(id, diff); await invItemTable.update(id, diff);
@ -88,7 +87,6 @@ export const itemsStore = {
async delete(id: string) { async delete(id: string) {
await invItemTable.update(id, { await invItemTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
InventoryEvents.itemDeleted(); InventoryEvents.itemDeleted();
}, },
@ -98,7 +96,7 @@ export const itemsStore = {
const toDelete = all.filter((i) => !i.deletedAt && i.collectionId === collectionId); const toDelete = all.filter((i) => !i.deletedAt && i.collectionId === collectionId);
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const item of toDelete) { for (const item of toDelete) {
await invItemTable.update(item.id, { deletedAt: now, updatedAt: now }); await invItemTable.update(item.id, { deletedAt: now });
} }
}, },
@ -109,7 +107,6 @@ export const itemsStore = {
const note = { id: crypto.randomUUID(), content, createdAt: now }; const note = { id: crypto.randomUUID(), content, createdAt: now };
await invItemTable.update(itemId, { await invItemTable.update(itemId, {
notes: [...item.notes, note], notes: [...item.notes, note],
updatedAt: now,
}); });
}, },
@ -118,7 +115,6 @@ export const itemsStore = {
if (!item) return; if (!item) return;
await invItemTable.update(itemId, { await invItemTable.update(itemId, {
notes: item.notes.filter((n) => n.id !== noteId), notes: item.notes.filter((n) => n.id !== noteId),
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -49,7 +49,6 @@ export const locationsStore = {
async update(id: string, data: Partial<Pick<LocalLocation, 'name' | 'description' | 'icon'>>) { async update(id: string, data: Partial<Pick<LocalLocation, 'name' | 'description' | 'icon'>>) {
await invLocationTable.update(id, { await invLocationTable.update(id, {
...data, ...data,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -64,7 +63,7 @@ export const locationsStore = {
collectIds(id); collectIds(id);
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const deleteId of idsToDelete) { for (const deleteId of idsToDelete) {
await invLocationTable.update(deleteId, { deletedAt: now, updatedAt: now }); await invLocationTable.update(deleteId, { deletedAt: now });
} }
InventoryEvents.locationDeleted(); InventoryEvents.locationDeleted();
}, },

View file

@ -3,6 +3,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import type { import type {
@ -48,7 +49,7 @@ export function toInvoice(local: LocalInvoice): Invoice {
pdfBlobKey: local.pdfBlobKey ?? null, pdfBlobKey: local.pdfBlobKey ?? null,
totals: local.totals, totals: local.totals,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -137,10 +137,7 @@ export const invoicesStore = {
} }
const wrapped = { ...patch } as Record<string, unknown>; const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('invoices', wrapped); await encryptRecord('invoices', wrapped);
await invoiceTable.update(id, { await invoiceTable.update(id, wrapped as never);
...wrapped,
updatedAt: new Date().toISOString(),
});
}, },
/** /**
@ -159,10 +156,7 @@ export const invoicesStore = {
// `lines` is in the encryption allowlist; `totals` is not. encryptRecord // `lines` is in the encryption allowlist; `totals` is not. encryptRecord
// only touches allowlisted keys, so a single call is correct for both. // only touches allowlisted keys, so a single call is correct for both.
await encryptRecord('invoices', patch); await encryptRecord('invoices', patch);
await invoiceTable.update(id, { await invoiceTable.update(id, patch as never);
...patch,
updatedAt: new Date().toISOString(),
});
}, },
/** /**
@ -178,7 +172,6 @@ export const invoicesStore = {
await invoiceTable.update(id, { await invoiceTable.update(id, {
status: 'sent', status: 'sent',
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('InvoiceSent', 'invoices', 'invoices', id, { emitDomainEvent('InvoiceSent', 'invoices', 'invoices', id, {
invoiceId: id, invoiceId: id,
@ -198,7 +191,6 @@ export const invoicesStore = {
await invoiceTable.update(id, { await invoiceTable.update(id, {
status: 'paid', status: 'paid',
paidAt: stamp, paidAt: stamp,
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('InvoicePaid', 'invoices', 'invoices', id, { emitDomainEvent('InvoicePaid', 'invoices', 'invoices', id, {
invoiceId: id, invoiceId: id,
@ -243,7 +235,6 @@ export const invoicesStore = {
} }
await invoiceTable.update(id, { await invoiceTable.update(id, {
status: 'void', status: 'void',
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('InvoiceVoided', 'invoices', 'invoices', id, { emitDomainEvent('InvoiceVoided', 'invoices', 'invoices', id, {
invoiceId: id, invoiceId: id,
@ -294,7 +285,6 @@ export const invoicesStore = {
} }
await invoiceTable.update(id, { await invoiceTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('InvoiceDeleted', 'invoices', 'invoices', id, { invoiceId: id }); emitDomainEvent('InvoiceDeleted', 'invoices', 'invoices', id, { invoiceId: id });
}, },

View file

@ -134,7 +134,6 @@ export const invoiceSettingsStore = {
out = formatNumber(prefix, nextN, padding); out = formatNumber(prefix, nextN, padding);
await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, { await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, {
nextNumber: nextN + 1, nextNumber: nextN + 1,
updatedAt: new Date().toISOString(),
}); });
}); });
return out; return out;
@ -146,7 +145,6 @@ export const invoiceSettingsStore = {
await encryptRecord('invoiceSettings', wrapped); await encryptRecord('invoiceSettings', wrapped);
await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, { await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, {
...wrapped, ...wrapped,
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('InvoiceSettingsUpdated', 'invoices', 'invoiceSettings', INVOICE_SETTINGS_ID, { emitDomainEvent('InvoiceSettingsUpdated', 'invoices', 'invoiceSettings', INVOICE_SETTINGS_ID, {
fields: Object.keys(patch), fields: Object.keys(patch),

View file

@ -1,4 +1,5 @@
import { formatDate } from '$lib/i18n/format'; import { formatDate } from '$lib/i18n/format';
import { deriveUpdatedAt } from '$lib/data/sync';
/** /**
* Reactive Queries & Pure Helpers for Journal module. * Reactive Queries & Pure Helpers for Journal module.
* *
@ -29,7 +30,7 @@ export function toJournalEntry(local: LocalJournalEntry): JournalEntry {
wordCount: local.wordCount ?? 0, wordCount: local.wordCount ?? 0,
transcriptModel: local.transcriptModel ?? null, transcriptModel: local.transcriptModel ?? null,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -76,7 +76,6 @@ export const journalStore = {
) { ) {
const diff: Partial<LocalJournalEntry> = { const diff: Partial<LocalJournalEntry> = {
...data, ...data,
updatedAt: new Date().toISOString(),
}; };
// Recompute word count when content changes // Recompute word count when content changes
@ -117,7 +116,6 @@ export const journalStore = {
content: transcript, content: transcript,
transcriptModel: result.model, transcriptModel: result.model,
wordCount: countWords(transcript), wordCount: countWords(transcript),
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('journalEntries', diff); await encryptRecord('journalEntries', diff);
await journalEntryTable.update(entryId, diff); await journalEntryTable.update(entryId, diff);
@ -133,7 +131,6 @@ export const journalStore = {
async deleteEntry(id: string) { async deleteEntry(id: string) {
await journalEntryTable.update(id, { await journalEntryTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('JournalEntryDeleted', 'journal', 'journalEntries', id, { entryId: id }); emitDomainEvent('JournalEntryDeleted', 'journal', 'journalEntries', id, { entryId: id });
}, },
@ -143,7 +140,6 @@ export const journalStore = {
if (!entry) return; if (!entry) return;
await journalEntryTable.update(id, { await journalEntryTable.update(id, {
isPinned: !entry.isPinned, isPinned: !entry.isPinned,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -152,14 +148,12 @@ export const journalStore = {
if (!entry) return; if (!entry) return;
await journalEntryTable.update(id, { await journalEntryTable.update(id, {
isFavorite: !entry.isFavorite, isFavorite: !entry.isFavorite,
updatedAt: new Date().toISOString(),
}); });
}, },
async setMood(id: string, mood: JournalMood | null) { async setMood(id: string, mood: JournalMood | null) {
await journalEntryTable.update(id, { await journalEntryTable.update(id, {
mood, mood,
updatedAt: new Date().toISOString(),
}); });
if (mood) if (mood)
emitDomainEvent('JournalMoodSet', 'journal', 'journalEntries', id, { entryId: id, mood }); emitDomainEvent('JournalMoodSet', 'journal', 'journalEntries', id, { entryId: id, mood });
@ -168,7 +162,6 @@ export const journalStore = {
async archiveEntry(id: string) { async archiveEntry(id: string) {
await journalEntryTable.update(id, { await journalEntryTable.update(id, {
isArchived: true, isArchived: true,
updatedAt: new Date().toISOString(),
}); });
}, },
}; };

View file

@ -10,6 +10,7 @@
*/ */
import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { scopedTable } from '$lib/data/scope/scoped-db'; import { scopedTable } from '$lib/data/scope/scoped-db';
import type { KontextDoc, LocalKontextDoc } from './types'; import type { KontextDoc, LocalKontextDoc } from './types';
@ -19,7 +20,7 @@ export function toKontextDoc(local: LocalKontextDoc): KontextDoc {
id: local.id, id: local.id,
content: local.content ?? '', content: local.content ?? '',
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -45,7 +45,6 @@ export const kontextStore = {
const row = await this.ensureDoc(); const row = await this.ensureDoc();
const diff: Partial<LocalKontextDoc> = { const diff: Partial<LocalKontextDoc> = {
content, content,
updatedAt: new Date().toISOString(),
}; };
await encryptRecord('kontextDoc', diff); await encryptRecord('kontextDoc', diff);
await kontextDocTable.update(row.id, diff); await kontextDocTable.update(row.id, diff);

View file

@ -1,4 +1,5 @@
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import type { Last, LastStatus, LocalLast } from './types'; import type { Last, LastStatus, LocalLast } from './types';
@ -34,7 +35,7 @@ export function toLast(local: LocalLast): Last {
unlistedToken: local.unlistedToken ?? '', unlistedToken: local.unlistedToken ?? '',
unlistedExpiresAt: local.unlistedExpiresAt ?? null, unlistedExpiresAt: local.unlistedExpiresAt ?? null,
createdAt: local.createdAt ?? new Date().toISOString(), createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -150,7 +150,6 @@ export const lastsStore = {
whatIKnowNow: data.whatIKnowNow ?? null, whatIKnowNow: data.whatIKnowNow ?? null,
tenderness: data.tenderness ?? null, tenderness: data.tenderness ?? null,
wouldReclaim: data.wouldReclaim ?? null, wouldReclaim: data.wouldReclaim ?? null,
updatedAt: nowIso(),
}; };
await encryptRecord('lasts', diff); await encryptRecord('lasts', diff);
await lastTable.update(id, diff); await lastTable.update(id, diff);
@ -165,7 +164,6 @@ export const lastsStore = {
status: 'reclaimed', status: 'reclaimed',
reclaimedAt: nowIso(), reclaimedAt: nowIso(),
reclaimedNote, reclaimedNote,
updatedAt: nowIso(),
}; };
await encryptRecord('lasts', diff); await encryptRecord('lasts', diff);
await lastTable.update(id, diff); await lastTable.update(id, diff);
@ -198,7 +196,6 @@ export const lastsStore = {
) { ) {
const diff: Partial<LocalLast> = { const diff: Partial<LocalLast> = {
...data, ...data,
updatedAt: nowIso(),
}; };
await encryptRecord('lasts', diff); await encryptRecord('lasts', diff);
await lastTable.update(id, diff); await lastTable.update(id, diff);
@ -207,7 +204,6 @@ export const lastsStore = {
async deleteLast(id: string) { async deleteLast(id: string) {
await lastTable.update(id, { await lastTable.update(id, {
deletedAt: nowIso(), deletedAt: nowIso(),
updatedAt: nowIso(),
}); });
}, },
@ -216,14 +212,12 @@ export const lastsStore = {
if (!last) return; if (!last) return;
await lastTable.update(id, { await lastTable.update(id, {
isPinned: !last.isPinned, isPinned: !last.isPinned,
updatedAt: nowIso(),
}); });
}, },
async archiveLast(id: string) { async archiveLast(id: string) {
await lastTable.update(id, { await lastTable.update(id, {
isArchived: true, isArchived: true,
updatedAt: nowIso(),
}); });
}, },
@ -289,7 +283,6 @@ export const lastsStore = {
async acceptCandidate(id: string) { async acceptCandidate(id: string) {
await lastTable.update(id, { await lastTable.update(id, {
inferredFrom: null, inferredFrom: null,
updatedAt: nowIso(),
}); });
}, },
@ -329,7 +322,6 @@ export const lastsStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId() ?? undefined, visibilityChangedBy: getEffectiveUserId() ?? undefined,
updatedAt: now,
}; };
if (next === 'unlisted') { if (next === 'unlisted') {
@ -407,7 +399,6 @@ export const lastsStore = {
}); });
await lastTable.update(id, { await lastTable.update(id, {
unlistedToken: token, unlistedToken: token,
updatedAt: nowIso(),
}); });
return token; return token;
}, },
@ -438,7 +429,6 @@ export const lastsStore = {
await lastTable.update(id, { await lastTable.update(id, {
unlistedToken: token, unlistedToken: token,
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null, unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null,
updatedAt: nowIso(),
}); });
}, },
}; };

View file

@ -3,6 +3,7 @@
*/ */
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { deriveUpdatedAt } from '$lib/data/sync';
import { decryptRecords } from '$lib/data/crypto'; import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope'; import { scopedForModule } from '$lib/data/scope';
@ -39,7 +40,7 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry {
unlistedToken: local.unlistedToken ?? '', unlistedToken: local.unlistedToken ?? '',
unlistedExpiresAt: local.unlistedExpiresAt ?? null, unlistedExpiresAt: local.unlistedExpiresAt ?? null,
createdAt: local.createdAt ?? now, createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now, updatedAt: deriveUpdatedAt(local),
}; };
} }

View file

@ -132,10 +132,7 @@ export const libraryEntriesStore = {
) { ) {
const wrapped = { ...patch } as Record<string, unknown>; const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('libraryEntries', wrapped); await encryptRecord('libraryEntries', wrapped);
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, wrapped as never);
...wrapped,
updatedAt: new Date().toISOString(),
});
// Keep the share-link snapshot in sync if this entry is unlisted. // Keep the share-link snapshot in sync if this entry is unlisted.
void this.refreshUnlistedSnapshot(id); void this.refreshUnlistedSnapshot(id);
}, },
@ -152,7 +149,6 @@ export const libraryEntriesStore = {
} }
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, {
...patch, ...patch,
updatedAt: new Date().toISOString(),
}); });
if (status === 'completed') { if (status === 'completed') {
emitDomainEvent('LibraryEntryCompleted', 'library', 'libraryEntries', id, { emitDomainEvent('LibraryEntryCompleted', 'library', 'libraryEntries', id, {
@ -178,14 +174,12 @@ export const libraryEntriesStore = {
status: 'active', status: 'active',
startedAt: nowDate, startedAt: nowDate,
completedAt: null, completedAt: null,
updatedAt: new Date().toISOString(),
}); });
}, },
async rate(id: string, rating: number | null) { async rate(id: string, rating: number | null) {
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, {
rating, rating,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -194,7 +188,6 @@ export const libraryEntriesStore = {
if (!existing) return; if (!existing) return;
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, {
isFavorite: !existing.isFavorite, isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
}); });
}, },
@ -220,7 +213,6 @@ export const libraryEntriesStore = {
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}); });
emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id }); emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id });
}, },
@ -241,7 +233,6 @@ export const libraryEntriesStore = {
visibility: next, visibility: next,
visibilityChangedAt: now, visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(), visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
}; };
if (next === 'unlisted') { if (next === 'unlisted') {
@ -274,7 +265,7 @@ export const libraryEntriesStore = {
patch.unlistedExpiresAt = undefined; patch.unlistedExpiresAt = undefined;
} }
await libraryEntryTable.update(id, patch); await libraryEntryTable.update(id, patch as never);
emitDomainEvent('VisibilityChanged', 'library', 'libraryEntries', id, { emitDomainEvent('VisibilityChanged', 'library', 'libraryEntries', id, {
recordId: id, recordId: id,
@ -317,7 +308,6 @@ export const libraryEntriesStore = {
}); });
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, {
unlistedToken: token, unlistedToken: token,
updatedAt: new Date().toISOString(),
}); });
return token; return token;
} catch (e) { } catch (e) {
@ -351,7 +341,6 @@ export const libraryEntriesStore = {
}); });
await libraryEntryTable.update(id, { await libraryEntryTable.update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined, unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
updatedAt: new Date().toISOString(),
}); });
} catch (e) { } catch (e) {
console.error('[library] setUnlistedExpiry failed', e); console.error('[library] setUnlistedExpiry failed', e);

Some files were not shown because too many files have changed in this diff Show more