// ───────────────────────────────────────────────────────────── // libreDecision v2 — local-first persistence (IndexedDB via idb-keyval). // One key per collective: `ld2:` holds a Bundle-shaped state. // Seeds and user imports go through the SAME importBundle() path. // Sync-ready invariants live in types/domain.ts — this layer stays dumb. // ───────────────────────────────────────────────────────────── import { get, set, del, keys } from 'idb-keyval' import type { Bundle, Collective, Id, ISODate } from '~/types/domain' const KEY_PREFIX = 'ld2:' const ACTIVE_KEY = 'ld2-active' /** In-memory state of one collective — exactly a Bundle minus export metadata. */ export type CollectiveState = Omit export function emptyState(collective: Collective): CollectiveState { return { collective, people: [], circles: [], decisions: [], concerns: [], objections: [], advices: [], assents: [], mandates: [], docs: [], clauses: [], versions: [], protocols: [], sessions: [], votes: [], } } // ── Load / save ────────────────────────────────────────────── export async function listCollectiveIds(): Promise { const all = await keys() return all .filter((k): k is string => typeof k === 'string' && k.startsWith(KEY_PREFIX)) .map(k => k.slice(KEY_PREFIX.length)) } export async function loadState(collectiveId: Id): Promise { return await get(KEY_PREFIX + collectiveId) } const pendingSaves = new Map>() /** Debounced write (500 ms) — every mutation calls this; last write wins. */ export function saveStateDebounced(state: CollectiveState): void { const id = state.collective.id const existing = pendingSaves.get(id) if (existing) clearTimeout(existing) pendingSaves.set( id, setTimeout(() => { pendingSaves.delete(id) void set(KEY_PREFIX + id, toRaw(state)) }, 500), ) } export async function saveStateNow(state: CollectiveState): Promise { const id = state.collective.id const existing = pendingSaves.get(id) if (existing) { clearTimeout(existing) pendingSaves.delete(id) } await set(KEY_PREFIX + id, toRaw(state)) } export async function deleteCollective(collectiveId: Id): Promise { await del(KEY_PREFIX + collectiveId) const active = await getActiveCollectiveId() if (active === collectiveId) await set(ACTIVE_KEY, null) } export async function getActiveCollectiveId(): Promise { return (await get(ACTIVE_KEY)) ?? null } export async function setActiveCollectiveId(id: Id | null): Promise { await set(ACTIVE_KEY, id) } // ── Export / import — the single bundle path ───────────────── export function toBundle(state: CollectiveState, exportedAt: ISODate): Bundle { return { schemaVersion: 2, exportedAt, ...toRaw(state) } } export async function sha256Hex(text: string): Promise { const data = new TextEncoder().encode(text) const digest = await crypto.subtle.digest('SHA-256', data) return Array.from(new Uint8Array(digest)) .map(b => b.toString(16).padStart(2, '0')) .join('') } export interface ImportIssue { level: 'error' | 'warning' message: string } /** Structural validation — never throws, returns readable French issues. */ export function validateBundle(raw: unknown): { bundle?: Bundle; issues: ImportIssue[] } { const issues: ImportIssue[] = [] if (typeof raw !== 'object' || raw === null) { return { issues: [{ level: 'error', message: 'Ce fichier ne contient pas un collectif lisible.' }] } } const b = raw as Partial if (b.schemaVersion !== 2) issues.push({ level: 'error', message: 'Version de fichier inconnue (schemaVersion ≠ 2).' }) if (!b.collective?.id || !b.collective?.slug || !b.collective?.name) issues.push({ level: 'error', message: 'Le collectif du fichier est incomplet (id, slug ou nom manquant).' }) for (const key of [ 'people', 'circles', 'decisions', 'concerns', 'objections', 'advices', 'assents', 'mandates', 'docs', 'clauses', 'versions', 'protocols', 'sessions', 'votes', ] as const) { if (!Array.isArray(b[key])) issues.push({ level: 'error', message: `Collection manquante ou invalide : ${key}.` }) } // Invariant: every collective carries a Consent protocol (import validates it). if (Array.isArray(b.protocols) && !b.protocols.some(p => p.method === 'consent')) issues.push({ level: 'warning', message: 'Aucun protocole de consentement — les chemins collectifs retomberont sur « décider sur avis » jusqu\'à sa création.', }) if (issues.some(i => i.level === 'error')) return { issues } return { bundle: b as Bundle, issues } } export interface ImportResult { state?: CollectiveState issues: ImportIssue[] collided: boolean } /** * Import a bundle as a new local collective. * Foreign bundles (exportedAt present, not one of ours) get a lineage stamp — « essaimé de … ». */ export async function importBundle(json: string, opts?: { asSeed?: boolean }): Promise { let raw: unknown try { raw = JSON.parse(json) } catch { return { issues: [{ level: 'error', message: 'Fichier illisible : ce n\'est pas du JSON valide.' }], collided: false } } const { bundle, issues } = validateBundle(raw) if (!bundle) return { issues, collided: false } const existingIds = await listCollectiveIds() const collided = existingIds.includes(bundle.collective.id) const { schemaVersion: _v, exportedAt, ...stateRest } = bundle const state: CollectiveState = stateRest if (!opts?.asSeed) { state.collective = { ...state.collective, lineage: { sourceSlug: bundle.collective.slug, exportedAt, sha256: await sha256Hex(json), }, } } return { state, issues, collided } } /** Deep-clone to plain JSON — strips Pinia/Vue reactivity proxies before IndexedDB. */ function toRaw(value: T): T { return JSON.parse(JSON.stringify(value)) as T }