v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- 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>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user