forked from yvv/decision
- engine/ : parametric (médiane basse, cristallisation, impact linear-share, bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome), settings (resolveSettings + replis), triage (R-U→R6, phrases françaises), impact (concernés calculés), électionResult (blanc, quorum, égalité sans départage machine) — 296 tests vitest verts - moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits), tokens routes/états, socle borderless, print A4, tampon 井 - data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée - Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test - backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer) - test anti-lexique (marqueur ld-v2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
// ─────────────────────────────────────────────────────────────
|
|
// libreDecision v2 — local-first persistence (IndexedDB via idb-keyval).
|
|
// One key per collective: `ld2:<collectiveId>` 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<Bundle, 'schemaVersion' | 'exportedAt'>
|
|
|
|
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<Id[]> {
|
|
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<CollectiveState | undefined> {
|
|
return await get<CollectiveState>(KEY_PREFIX + collectiveId)
|
|
}
|
|
|
|
const pendingSaves = new Map<Id, ReturnType<typeof setTimeout>>()
|
|
|
|
/** 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<void> {
|
|
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<void> {
|
|
await del(KEY_PREFIX + collectiveId)
|
|
const active = await getActiveCollectiveId()
|
|
if (active === collectiveId) await set(ACTIVE_KEY, null)
|
|
}
|
|
|
|
export async function getActiveCollectiveId(): Promise<Id | null> {
|
|
return (await get<Id | null>(ACTIVE_KEY)) ?? null
|
|
}
|
|
|
|
export async function setActiveCollectiveId(id: Id | null): Promise<void> {
|
|
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<string> {
|
|
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<Bundle>
|
|
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<ImportResult> {
|
|
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<T>(value: T): T {
|
|
return JSON.parse(JSON.stringify(value)) as T
|
|
}
|