@nyx5/nyx5 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ // Nyx5/1 — Libro: el ledger de doble entrada de una casa (dominio).
2
+ //
3
+ // El Libro es el segundo componente del sistema; el primero es el Correo. No tiene login propio:
4
+ // toda operación llega como un sobre firmado a `libro@<dominio>`, y la identidad del remitente ya
5
+ // viene verificada por la cadena de confianza del Correo. El Libro solo decide si la operación es
6
+ // válida (partes, estado, saldo) y ejecuta el asiento.
7
+ //
8
+ // Kernel: siete primitivas y nada más. Los contratos (contratos.js) se componen encima.
9
+ // 1. cotizar -> no toca el libro: es un documento firmado por el vendedor (verifyQuote lo valida)
10
+ // 2. cobrar -> transfer(de, a, monto) con reparto de fee a la casa
11
+ // 3. retener -> hold(de, escrow, monto)
12
+ // 4. liberar -> release(escrow, a, monto) con fee
13
+ // 5. devolver -> refund(escrow, a, monto) sin fee
14
+ // 6. repartir -> post() con N líneas que suman cero (el fee de la casa es un reparto)
15
+ // 7. afianzar -> hold() con condición de salida distinta (forfeit / release)
16
+ // Transversales: idempotencia (por id de sobre) y meta (contexto legible por máquina en cada asiento).
17
+ //
18
+ // Cuentas: `agente@dominio` (cualquier agente verificable, de esta casa o de otra), `casa@<dominio>`
19
+ // (la distribuidora: emite tokens, cobra fees; es la única que puede quedar en negativo) y
20
+ // `escrow:<contrato>` (fondos retenidos). Un asiento es una lista de líneas {cuenta, delta} que suman 0.
21
+ //
22
+ // ESCRITURAS: cada operación acumula sus escrituras en una transacción (this.tx) y el llamador la
23
+ // sella con commit() -> store.libroCommit(bundle). En FileStore el commit es secuencial (proceso
24
+ // único); en D1 es un batch atómico donde el número de asiento y el id de la op son claves únicas:
25
+ // dos operaciones concurrentes no pueden duplicar un asiento ni descuadrar los saldos — la segunda
26
+ // falla cerrado y el correo la reintenta.
27
+
28
+ import { signObject, verifyObject, canonical, sha256hex, uuid } from '../nucleo/crypto.js';
29
+ import { Resolver, parseAddress } from '../correo/resolver.js';
30
+ import { CONTRATOS } from './contratos.js';
31
+ import { LibroError } from './errores.js';
32
+ export { LibroError };
33
+
34
+ export const MEDIA = {
35
+ op: 'application/nyx5.libro+json',
36
+ cotizacion: 'application/nyx5.cotizacion+json',
37
+ recibo: 'application/nyx5.recibo+json',
38
+ };
39
+
40
+ const iso = () => new Date().toISOString();
41
+
42
+ export class Libro {
43
+ // feeBps: fee de la casa en basis points enteros (1000 = 10%). `feePct` sigue aceptándose
44
+ // como azúcar (0.10 -> 1000) pero el cálculo es siempre entero: sin punto flotante en el dinero.
45
+ constructor({ domain, store, keys, resolver, feeBps = null, feePct = null, welcome = 0, log = () => {} }) {
46
+ this.domain = domain; this.store = store; this.keys = keys; this.resolver = resolver;
47
+ this.feeBps = feeBps ?? (feePct != null ? Math.round(feePct * 10_000) : 1000);
48
+ this.welcome = welcome; this.log = log;
49
+ this.casa = `casa@${domain}`;
50
+ this.address = `libro@${domain}`;
51
+ this.tx = null; // transacción en curso: { state, asientos, contracts: Map, mandates: Map, op }
52
+ this._lock = Promise.resolve(); // serializa las transacciones DENTRO de esta instancia; entre
53
+ // procesos/isolates protegen las constraints de D1 (fallar cerrado)
54
+ }
55
+ _serial(fn) { const run = this._lock.then(fn); this._lock = run.catch(() => {}); return run; }
56
+ get feePct() { return this.feeBps / 10_000; } // compat de lectura (tarjeta de libro@)
57
+
58
+ // ---------- transacción ----------
59
+ // El estado se lee UNA vez al abrir y no se vuelve a leer: si otra operación comete mientras
60
+ // esta decide, el número de asiento que ésta reservó ya estará tomado y su commit falla cerrado.
61
+ // Releer aquí sería el bug: dos cobros que leyeron el mismo mandato tomarían números distintos
62
+ // y ambos entrarían, superando el tope.
63
+ async _begin(concepto = 'control', refs = {}) {
64
+ const base = await this.store.libroState();
65
+ this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), op: null, concepto, refs };
66
+ }
67
+ _bundle() {
68
+ const t = this.tx;
69
+ return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], op: t.op };
70
+ }
71
+ async _commit() {
72
+ const t = this.tx;
73
+ // Una operación que muta el Libro sin mover dinero (revocar, entregar, sub-delegar) también
74
+ // consume su número de asiento: es la única forma de que choque con quien corre en paralelo.
75
+ // El asiento de control tiene lines vacío, así que sigue cuadrando en cero y queda auditable.
76
+ if (!t.asientos.length && (t.contracts.size || t.mandates.size)) {
77
+ const st = t.state ?? t.base;
78
+ t.asientos.push(signObject({ id: uuid(), n: st.seq + 1, at: iso(), house: this.domain, concept: t.concepto, lines: [], meta: { kind: 'control' }, refs: t.refs }, this.keys));
79
+ t.state = { seq: st.seq + 1, balances: st.balances };
80
+ }
81
+ const b = this._bundle();
82
+ this.tx = null;
83
+ await this.store.libroCommit(b);
84
+ return b;
85
+ }
86
+ _abort() { this.tx = null; }
87
+ // Lecturas que ven las escrituras pendientes de la propia transacción:
88
+ async _state() { return this.tx ? (this.tx.state ?? this.tx.base) : await this.store.libroState(); }
89
+ async getContract(id) { return this.tx?.contracts.get(id) ?? await this.store.libroGetContract(id); }
90
+ putContract(c) { if (!this.tx) throw new LibroError(500, 'putContract fuera de transacción'); this.tx.contracts.set(c.id, c); }
91
+ async getMandate(id) { return this.tx?.mandates.get(id) ?? await this.store.libroGetMandate(id); }
92
+ putMandate(m) { if (!this.tx) throw new LibroError(500, 'putMandate fuera de transacción'); this.tx.mandates.set(m.id, m); }
93
+
94
+ // ---------- consultas ----------
95
+ get ops() { return Object.keys(CONTRATOS.ops); }
96
+ async balance(account) { return (await this.store.libroState()).balances[account] || 0; }
97
+ fee(amount) { return Math.floor((amount * this.feeBps) / 10_000); }
98
+ async account(address) {
99
+ const contracts = (await this.store.libroListContracts()).filter((c) => [c.seller, c.buyer, c.verifier, c.arbiter].includes(address));
100
+ const mandates = (await this.store.libroListMandates()).filter((m) => m.grantor === address || m.grantee === address);
101
+ return { account: address, balance: await this.balance(address), contracts, mandates };
102
+ }
103
+ async journal() { return this.store.libroJournal(); }
104
+
105
+ // ---------- el kernel: un asiento ----------
106
+ // lines: [{ account, delta }]; suma cero; nadie salvo la casa queda negativo.
107
+ async post(concept, lines, meta = {}, refs = {}) {
108
+ const total = lines.reduce((s, l) => s + l.delta, 0);
109
+ if (total !== 0) throw new LibroError(500, `asiento descuadrado (${total})`);
110
+ for (const l of lines) if (!Number.isInteger(l.delta)) throw new LibroError(400, 'los montos son enteros (tokens)');
111
+ const state = await this._state();
112
+ const next = { ...state.balances };
113
+ for (const l of lines) {
114
+ next[l.account] = (next[l.account] || 0) + l.delta;
115
+ if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `saldo insuficiente en ${l.account} (tiene ${state.balances[l.account] || 0}, necesita ${-l.delta})`);
116
+ }
117
+ const asiento = signObject({ id: uuid(), n: state.seq + 1, at: iso(), house: this.domain, concept, lines, meta, refs }, this.keys);
118
+ if (this.tx) {
119
+ this.tx.asientos.push(asiento);
120
+ this.tx.state = { seq: state.seq + 1, balances: next };
121
+ } else {
122
+ // asiento suelto (topup administrativo): transacción propia, con el mismo candado
123
+ await this.store.libroCommit({ base: state, state: { seq: state.seq + 1, balances: next }, asientos: [asiento], contracts: [], mandates: [], op: null });
124
+ }
125
+ return asiento;
126
+ }
127
+
128
+ // Primitivas construidas sobre post()
129
+ async topup(account, amount, concept = 'carga', meta = {}) {
130
+ parseAddress(account); this._amount(amount);
131
+ const asentar = () => this.post(concept, [{ account: this.casa, delta: -amount }, { account, delta: amount }], { kind: 'topup', ...meta });
132
+ return this.tx ? asentar() : this._serial(asentar);
133
+ }
134
+ // referrer opcional { address, share (bps) }: la comisión sale del monto (la recibe el vendedor),
135
+ // no se suma al precio. El asiento pasa a 4 líneas y sigue cuadrando en cero.
136
+ async transfer(from, to, amount, concept, meta = {}, refs = {}, referrer = null) {
137
+ this._amount(amount);
138
+ const fee = this.fee(amount);
139
+ const com = referrer ? Math.floor((amount * referrer.share) / 10_000) : 0;
140
+ const lines = [{ account: from, delta: -amount }, { account: to, delta: amount - fee - com }];
141
+ if (fee) lines.push({ account: this.casa, delta: fee });
142
+ if (com) lines.push({ account: referrer.address, delta: com });
143
+ return this.post(concept, lines, { kind: 'charge', fee, ...(com ? { referrer: referrer.address, commission: com } : {}), ...meta }, refs);
144
+ }
145
+ async hold(from, contractId, amount, concept, meta = {}, refs = {}) {
146
+ this._amount(amount);
147
+ return this.post(concept, [{ account: from, delta: -amount }, { account: `escrow:${contractId}`, delta: amount }], { kind: 'hold', ...meta }, refs);
148
+ }
149
+ async release(contractId, to, amount, concept, meta = {}, refs = {}, referrer = null) {
150
+ this._amount(amount);
151
+ const fee = this.fee(amount);
152
+ const com = referrer ? Math.floor((amount * referrer.share) / 10_000) : 0;
153
+ const lines = [{ account: `escrow:${contractId}`, delta: -amount }, { account: to, delta: amount - fee - com }];
154
+ if (fee) lines.push({ account: this.casa, delta: fee });
155
+ if (com) lines.push({ account: referrer.address, delta: com });
156
+ return this.post(concept, lines, { kind: 'release', fee, ...(com ? { referrer: referrer.address, commission: com } : {}), ...meta }, refs);
157
+ }
158
+ async refund(contractId, to, amount, concept, meta = {}, refs = {}) {
159
+ this._amount(amount);
160
+ return this.post(concept, [{ account: `escrow:${contractId}`, delta: -amount }, { account: to, delta: amount }], { kind: 'refund', ...meta }, refs);
161
+ }
162
+ _amount(a) { if (!Number.isInteger(a) || a <= 0) throw new LibroError(400, `monto inválido: ${a}`); }
163
+
164
+ // ---------- cotizaciones: documentos firmados por el vendedor ----------
165
+ // Una cotización viaja adentro de un sobre (cifrado si se quiere) y se presenta al Libro al aceptar.
166
+ static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null }, sellerKeys) {
167
+ if (!CONTRATOS[contract]?.quoteable) throw new LibroError(400, `contrato no cotizable: ${contract}`);
168
+ if (arbiter) parseAddress(arbiter);
169
+ // Comisión de referido: el vendedor firma en su cotización que le paga `share` (en basis points)
170
+ // a quien trajo el trato. La comisión sale de LO QUE RECIBE el vendedor, no se suma al precio:
171
+ // el comprador paga igual y la casa cobra igual. verifyQuote valida los límites al aceptar.
172
+ if (referrer != null) {
173
+ parseAddress(referrer.address);
174
+ if (!Number.isInteger(referrer.share) || referrer.share <= 0) throw new LibroError(400, 'referrer.share debe ser un entero de basis points > 0');
175
+ }
176
+ return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, issued: iso(), expires: expires || null }, sellerKeys);
177
+ }
178
+ async verifyQuote(q, buyer) {
179
+ if (q?.tipo !== 'cotizacion' || !q.id || !q.seller || !q.signature) throw new LibroError(400, 'cotización malformada');
180
+ // Solo tipos cotizables: una cotización firmada a mano con un kind inexistente (o no cotizable)
181
+ // se rechaza limpio aquí, no explota en onAccept.
182
+ if (!CONTRATOS[q.contract]?.quoteable) throw new LibroError(400, `contrato no cotizable: ${q.contract}`);
183
+ if (q.house !== this.domain) throw new LibroError(400, `la cotización es para la casa ${q.house}, no ${this.domain}`);
184
+ if (q.buyer !== buyer) throw new LibroError(403, 'la cotización no está dirigida a quien la acepta');
185
+ if (q.expires && Date.parse(q.expires) < Date.now()) throw new LibroError(410, 'cotización vencida');
186
+ if (q.arbiter) { try { parseAddress(q.arbiter); } catch { throw new LibroError(400, 'árbitro inválido en la cotización'); } }
187
+ if (q.referrer != null) {
188
+ try { parseAddress(q.referrer.address); } catch { throw new LibroError(400, 'referidor inválido en la cotización'); }
189
+ if (!Number.isInteger(q.referrer.share) || q.referrer.share <= 0) throw new LibroError(400, 'referrer.share debe ser un entero de basis points > 0');
190
+ // El fee de la casa y la comisión salen ambos del monto: juntos no pueden dejar al vendedor en negativo.
191
+ if (this.feeBps + q.referrer.share > 10_000) throw new LibroError(400, `fee (${this.feeBps} bps) + comisión (${q.referrer.share} bps) supera el 100% del precio`);
192
+ if (q.referrer.address === q.seller) throw new LibroError(400, 'el vendedor no puede ser su propio referidor');
193
+ }
194
+ this._amount(q.price);
195
+ let card;
196
+ try { card = await this.resolver.agentCardForKid(q.seller, q.signature.kid); } catch (e) { throw new LibroError(e.permanent ? 403 : 421, `no se pudo verificar al vendedor: ${e.message}`); }
197
+ if (!Resolver.acceptedKids(card).includes(q.signature.kid) || !verifyObject(q, q.signature.kid)) throw new LibroError(403, 'firma de la cotización inválida');
198
+ if (await this.store.libroFindContractByQuote(q.id)) throw new LibroError(409, 'cotización ya aceptada');
199
+ return card;
200
+ }
201
+
202
+ // ---------- entrada: un sobre dirigido a libro@<dominio> ----------
203
+ // Devuelve { ok, code, reason, result, recibos: [{ to: [...], body }] }. Idempotente por id de sobre.
204
+ async handle(env, senderCard) {
205
+ const prev = await this.store.libroGetOp(env.id);
206
+ if (prev) return { ...prev, duplicate: true };
207
+ if (!env.content || env.content.media !== MEDIA.op) return { ok: false, code: 400, reason: `el Libro solo acepta content.media = ${MEDIA.op} (sin cifrar: la casa debe leerlo)` };
208
+ const body = env.content.body || {};
209
+ const op = CONTRATOS.ops[body.op];
210
+ if (!op) return { ok: false, code: 400, reason: `operación desconocida: ${body.op}. Válidas: ${Object.keys(CONTRATOS.ops).join(', ')}` };
211
+ const ctx = { libro: this, env, from: env.from, body, senderCard, opHash: sha256hex(canonical(env)), scope: senderCard?.delegation?.scope || null };
212
+ return this._serial(async () => {
213
+ await this._begin(`op ${body.op}`, { op: env.id, op_sha256: ctx.opHash });
214
+ let result;
215
+ try { result = await op(ctx); }
216
+ catch (e) {
217
+ this._abort();
218
+ if (e instanceof LibroError) return { ok: false, code: e.code, reason: e.message };
219
+ throw e;
220
+ }
221
+ const out = { ok: true, code: 202, result: result.result, recibos: (result.recibos || []).map((r) => ({ ...r, body: { ...r.body, of: env.id, op: body.op, op_sha256: ctx.opHash, from: env.from } })), avisos: result.avisos || [] };
222
+ this.tx.op = { id: env.id, result: out };
223
+ try { await this._commit(); }
224
+ catch (e) {
225
+ // Conflicto de concurrencia (asiento u op duplicados en D1): la op ya corrió en paralelo.
226
+ const cached = await this.store.libroGetOp(env.id);
227
+ if (cached) return { ...cached, duplicate: true };
228
+ throw e;
229
+ }
230
+ return out;
231
+ });
232
+ }
233
+
234
+ // Estampilla: un sobre con `stamp` hacia un buzón con política `stamp` paga al llegar.
235
+ // NO comete: devuelve { asiento, bundle } para que la estafeta lo selle JUNTO con el buzón y el
236
+ // dedupe del sobre (inboundCommit) — así una reentrega jamás cobra la estampilla dos veces.
237
+ async stamp(env, recipient, price) {
238
+ const s = env.stamp;
239
+ if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `este buzón exige estampilla de ${price} tok en la casa ${this.domain} (campo stamp: {house, amount})`);
240
+ return this._serial(async () => {
241
+ await this._begin('estampilla', { envelope: env.id });
242
+ try {
243
+ const asiento = await this.transfer(env.from, recipient, s.amount, `estampilla ${env.from} -> ${recipient}`, { kind: 'stamp' }, { envelope: env.id, envelope_sha256: sha256hex(canonical(env)) });
244
+ const bundle = this._bundle();
245
+ this.tx = null;
246
+ return { asiento, bundle };
247
+ } catch (e) { this._abort(); throw e; }
248
+ });
249
+ }
250
+ }
@@ -0,0 +1,223 @@
1
+ // Nyx5/1 — D1Store: la misma interfaz que FileStore, sobre Cloudflare D1 (o el emulador
2
+ // d1-local.js en tests). Async real, y la atomicidad que FileStore le debe al proceso único
3
+ // aquí la dan las transacciones (db.batch) y las constraints del esquema (0002_nyx5.sql):
4
+ // - un asiento concurrente con el mismo n -> el batch entero falla cerrado (PK diario.n)
5
+ // - una op reentregada -> INSERT choca con PK ops.id y el Libro responde el resultado cacheado
6
+ // - una cotización re-aceptada -> UNIQUE contratos.quote_id
7
+ // Los documentos se guardan como JSON íntegro (columna doc): los campos desconocidos sobreviven
8
+ // el roundtrip y las firmas siguen verificando (invariante 7).
9
+
10
+ import { LibroError } from '../libro/errores.js';
11
+
12
+ const j = (v) => JSON.stringify(v);
13
+ const p = (row, col = 'doc') => (row ? JSON.parse(row[col]) : null);
14
+ const iso = () => new Date().toISOString();
15
+
16
+ export class D1Store {
17
+ constructor(db) { this.db = db; }
18
+
19
+ // --- dominio ---
20
+ async getDomain() { return p(await this.db.prepare('SELECT doc FROM nyx5_domain WHERE id = 1').first()); }
21
+ async putDomain(v) { await this.db.prepare('INSERT INTO nyx5_domain (id, doc) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(j(v)).run(); }
22
+ async putDomainIfAbsent(v) {
23
+ const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_domain (id, doc) VALUES (1, ?)').bind(j(v)).run();
24
+ return r.meta.changes === 1;
25
+ }
26
+
27
+ // --- agentes ---
28
+ async getAgent(local) { return p(await this.db.prepare('SELECT doc FROM nyx5_agents WHERE local = ?').bind(local).first()); }
29
+ async putAgent(local, v) { await this.db.prepare('INSERT INTO nyx5_agents (local, doc, updated) VALUES (?, ?, ?) ON CONFLICT(local) DO UPDATE SET doc = excluded.doc, updated = excluded.updated').bind(local, j(v), iso()).run(); }
30
+ // Alta atómica: true si este llamador creó el nombre. Dos altas concurrentes del mismo nombre
31
+ // ya no se pisan la clave (ni duplican el regalo de bienvenida).
32
+ async putAgentIfAbsent(local, v) {
33
+ const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_agents (local, doc, updated) VALUES (?, ?, ?)').bind(local, j(v), iso()).run();
34
+ return r.meta.changes === 1;
35
+ }
36
+ async listAgents() { return (await this.db.prepare('SELECT local FROM nyx5_agents ORDER BY local').all()).results.map((r) => r.local); }
37
+
38
+ // --- invitaciones ---
39
+ async getInvite(code) { return p(await this.db.prepare('SELECT doc FROM nyx5_invitations WHERE code = ?').bind(code).first()); }
40
+ async putInvite(inv) { await this.db.prepare('INSERT INTO nyx5_invitations (code, doc) VALUES (?, ?) ON CONFLICT(code) DO UPDATE SET doc = excluded.doc').bind(inv.code, j(inv)).run(); }
41
+ async listInvites() { return (await this.db.prepare('SELECT doc FROM nyx5_invitations').all()).results.map((r) => JSON.parse(r.doc)); }
42
+ // Consumo atómico de un uso: la condición viaja EN el UPDATE, así que diez canjes en paralelo
43
+ // de una invitación de un uso dejan pasar exactamente uno.
44
+ async consumeInvite(code, atIso) {
45
+ const r = await this.db.prepare(`
46
+ UPDATE nyx5_invitations
47
+ SET doc = json_set(json_set(doc, '$.used', json_extract(doc, '$.used') + 1), '$.last_used', ?)
48
+ WHERE code = ? AND json_extract(doc, '$.used') < json_extract(doc, '$.uses')`).bind(atIso, code).run();
49
+ if (r.meta.changes !== 1) return null;
50
+ return this.getInvite(code);
51
+ }
52
+
53
+ // --- deduplicación ---
54
+ async getSeen(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_seen WHERE id = ?').bind(id).first()); }
55
+ async putSeen(id, rec) { await this.db.prepare('INSERT INTO nyx5_seen (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(id, j({ id, at: iso(), ...rec })).run(); }
56
+ async markSeenIfNew(id, meta = {}) {
57
+ const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_seen (id, doc) VALUES (?, ?)').bind(id, j({ id, at: iso(), ...meta })).run();
58
+ return r.meta.changes === 1;
59
+ }
60
+
61
+ // --- buzones ---
62
+ async putMail(local, envelope, meta = {}) {
63
+ const received = iso();
64
+ await this.db.prepare('INSERT INTO nyx5_mailbox (local, id, doc, received) VALUES (?, ?, ?, ?) ON CONFLICT(local, id) DO NOTHING')
65
+ .bind(local, envelope.id, j({ ...meta, received, envelope }), received).run();
66
+ }
67
+ async listMail(local) {
68
+ return (await this.db.prepare('SELECT doc FROM nyx5_mailbox WHERE local = ? AND acked IS NULL ORDER BY received, id').bind(local).all()).results.map((r) => JSON.parse(r.doc));
69
+ }
70
+ async ackMail(local, id) {
71
+ const r = await this.db.prepare('UPDATE nyx5_mailbox SET acked = ? WHERE local = ? AND id = ? AND acked IS NULL').bind(iso(), local, id).run();
72
+ return r.meta.changes === 1;
73
+ }
74
+
75
+ // --- cola de salida ---
76
+ _jobRow(job) { return [job.id, j(job), job.next_attempt, job.status, job.claimed_until || 0]; }
77
+ async enqueue(job) {
78
+ await this.db.prepare('INSERT INTO nyx5_queue (id, doc, next_attempt, status, claimed_until) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc, next_attempt = excluded.next_attempt, status = excluded.status, claimed_until = excluded.claimed_until')
79
+ .bind(...this._jobRow(job)).run();
80
+ }
81
+ async listQueue() { return (await this.db.prepare('SELECT doc FROM nyx5_queue').all()).results.map((r) => JSON.parse(r.doc)); }
82
+ async updateJob(job) { await this.enqueue(job); }
83
+ async removeJob(id) { await this.db.prepare('DELETE FROM nyx5_queue WHERE id = ?').bind(id).run(); }
84
+ async claimDueJobs(nowMs, max = 20) {
85
+ const nowIso = new Date(nowMs).toISOString();
86
+ const until = nowMs + 60_000;
87
+ // Reclamo atómico: un solo UPDATE marca y devuelve; dos ticks concurrentes no comparten trabajo.
88
+ const r = await this.db.prepare(`
89
+ UPDATE nyx5_queue SET status = 'inflight', claimed_until = ?
90
+ WHERE id IN (
91
+ SELECT id FROM nyx5_queue
92
+ WHERE next_attempt <= ? AND (status IN ('queued','retrying') OR (status = 'inflight' AND claimed_until < ?))
93
+ LIMIT ?
94
+ ) RETURNING doc`).bind(until, nowIso, nowMs, max).all();
95
+ return r.results.map((row) => { const job = JSON.parse(row.doc); job.status = 'inflight'; job.claimed_until = until; return job; });
96
+ }
97
+
98
+ // --- outbox ---
99
+ async putOutbox(local, entry) {
100
+ await this.db.prepare('INSERT INTO nyx5_outbox (local, job, doc) VALUES (?, ?, ?) ON CONFLICT(local, job) DO UPDATE SET doc = excluded.doc').bind(local, entry.job, j(entry)).run();
101
+ }
102
+ async listOutbox(local) { return (await this.db.prepare('SELECT doc FROM nyx5_outbox WHERE local = ?').bind(local).all()).results.map((r) => JSON.parse(r.doc)); }
103
+
104
+ // --- nonces ---
105
+ async useNonce(key, tsMs) {
106
+ const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_nonces (key, ts) VALUES (?, ?)').bind(key, tsMs).run();
107
+ return r.meta.changes === 1;
108
+ }
109
+ async pruneNonces(beforeMs) { await this.db.prepare('DELETE FROM nyx5_nonces WHERE ts < ?').bind(beforeMs).run(); }
110
+
111
+ // --- pins (una fila por dominio: el primero gana y ningún isolate pisa lo que otro aprendió) ---
112
+ async getPins() {
113
+ const r = await this.db.prepare('SELECT domain, kid FROM nyx5_pin').all();
114
+ return Object.fromEntries(r.results.map((x) => [x.domain, x.kid]));
115
+ }
116
+ async putPin(domain, kid) {
117
+ const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_pin (domain, kid, at) VALUES (?, ?, ?)').bind(domain, kid, iso()).run();
118
+ return r.meta.changes === 1;
119
+ }
120
+ // Compat: recibe el mapa completo pero NUNCA borra; cada pin se agrega si no existía.
121
+ async putPins(v) { for (const [d, k] of Object.entries(v || {})) await this.putPin(d, k); }
122
+
123
+ // ===== Libro =====
124
+ async libroState() {
125
+ const row = await this.db.prepare('SELECT seq, balances FROM nyx5_libro_state WHERE id = 1').first();
126
+ return row ? { seq: row.seq, balances: JSON.parse(row.balances) } : { seq: 0, balances: {} };
127
+ }
128
+ async libroPutState(v) { await this.db.prepare('UPDATE nyx5_libro_state SET seq = ?, balances = ? WHERE id = 1').bind(v.seq, j(v.balances)).run(); }
129
+ async libroAppend(asiento) { await this.db.batch(this._asientoStmts(asiento)); }
130
+ _asientoStmts(asiento) {
131
+ return [
132
+ this.db.prepare('INSERT INTO nyx5_libro_diario (n, id, doc) VALUES (?, ?, ?)').bind(asiento.n, asiento.id, j(asiento)),
133
+ ...asiento.lines.map((l) => this.db.prepare('INSERT INTO nyx5_libro_lineas (n, account, delta) VALUES (?, ?, ?)').bind(asiento.n, l.account, l.delta)),
134
+ ];
135
+ }
136
+ async libroJournal() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_diario ORDER BY n').all()).results.map((r) => JSON.parse(r.doc)); }
137
+ async libroGetContract(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_contratos WHERE id = ?').bind(id).first()); }
138
+ _contractStmt(c) {
139
+ return this.db.prepare('INSERT INTO nyx5_libro_contratos (id, quote_id, doc) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc, quote_id = excluded.quote_id').bind(c.id, c.quote_id || null, j(c));
140
+ }
141
+ async libroPutContract(c) { await this._contractStmt(c).run(); }
142
+ async libroListContracts() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_contratos').all()).results.map((r) => JSON.parse(r.doc)); }
143
+ async libroFindContractByQuote(quoteId) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_contratos WHERE quote_id = ?').bind(quoteId).first()); }
144
+ async libroGetMandate(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos WHERE id = ?').bind(id).first()); }
145
+ _mandateStmt(m) { return this.db.prepare('INSERT INTO nyx5_libro_mandatos (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(m.id, j(m)); }
146
+ async libroPutMandate(m) { await this._mandateStmt(m).run(); }
147
+ async libroListMandates() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos').all()).results.map((r) => JSON.parse(r.doc)); }
148
+ async libroGetOp(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_ops WHERE id = ?').bind(id).first()); }
149
+ async libroPutOp(id, v) { await this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(id, j(v)).run(); }
150
+ async libroStatement(account, limit) {
151
+ const r = await this.db.prepare(`
152
+ SELECT doc FROM nyx5_libro_diario WHERE n IN (SELECT DISTINCT n FROM nyx5_libro_lineas WHERE account = ?)
153
+ ORDER BY n DESC LIMIT ?`).bind(account, limit).all();
154
+ return r.results.map((row) => JSON.parse(row.doc)).reverse();
155
+ }
156
+
157
+ // Un movimiento del Libro, atómico. El orden importa: el diario va primero (PK n = candado
158
+ // optimista del ledger entero) para que un conflicto aborte el batch antes de tocar nada más.
159
+ _libroCommitStmts(bundle) {
160
+ const stmts = [];
161
+ for (const a of bundle.asientos || []) stmts.push(...this._asientoStmts(a));
162
+ if (bundle.state) stmts.push(this.db.prepare('UPDATE nyx5_libro_state SET seq = ?, balances = ? WHERE id = 1').bind(bundle.state.seq, j(bundle.state.balances)));
163
+ for (const c of bundle.contracts || []) stmts.push(this._contractStmt(c));
164
+ for (const m of bundle.mandates || []) stmts.push(this._mandateStmt(m));
165
+ if (bundle.op) stmts.push(this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?)').bind(bundle.op.id, j(bundle.op.result)));
166
+ return stmts;
167
+ }
168
+ async libroCommit(bundle) {
169
+ const stmts = this._libroCommitStmts(bundle);
170
+ if (!stmts.length) return;
171
+ try { await this.db.batch(stmts); }
172
+ catch (e) { throw this._traducirConflicto(e); }
173
+ }
174
+
175
+ // La entrega de un sobre entera, en un batch: dedupe + buzones + estampillas.
176
+ async inboundCommit(bundle) {
177
+ const stmts = [];
178
+ for (const lb of bundle.libro || []) stmts.push(...this._libroCommitStmts(lb));
179
+ for (const m of bundle.mails || []) {
180
+ const received = iso();
181
+ stmts.push(this.db.prepare('INSERT INTO nyx5_mailbox (local, id, doc, received) VALUES (?, ?, ?, ?) ON CONFLICT(local, id) DO NOTHING')
182
+ .bind(m.local, m.envelope.id, j({ ...m.meta, received, envelope: m.envelope }), received));
183
+ }
184
+ if (bundle.seen) stmts.push(this.db.prepare('INSERT INTO nyx5_seen (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc')
185
+ .bind(bundle.seen.id, j({ id: bundle.seen.id, at: iso(), ...bundle.seen.rec })));
186
+ if (!stmts.length) return;
187
+ try { await this.db.batch(stmts); }
188
+ catch (e) { throw this._traducirConflicto(e); }
189
+ }
190
+
191
+ _traducirConflicto(e) {
192
+ // Un choque de constraint es concurrencia legítima, no un rechazo del contenido: se responde
193
+ // 421 (temporal, en la lista RETRYABLE de la estafeta) para que el correo lo reintente y la
194
+ // segunda pasada gane o vea el resultado ya cacheado. Con 409 el sobre se rebotaba como
195
+ // fallo permanente y la operación válida se perdía.
196
+ if (/UNIQUE|PRIMARY KEY|constraint/i.test(String(e?.message))) {
197
+ return Object.assign(new LibroError(421, `conflicto de concurrencia en el ledger, reintentable: ${e.message}`), { transient: true });
198
+ }
199
+ return e;
200
+ }
201
+
202
+ // ===== Índice federado =====
203
+ async indexGetHouse(domain) { return p(await this.db.prepare('SELECT doc FROM nyx5_indice_casas WHERE domain = ?').bind(domain).first()); }
204
+ async indexPutHouse(h) { await this.db.prepare('INSERT INTO nyx5_indice_casas (domain, doc) VALUES (?, ?) ON CONFLICT(domain) DO UPDATE SET doc = excluded.doc').bind(h.domain, j(h)).run(); }
205
+ async indexListHouses() { return (await this.db.prepare('SELECT doc FROM nyx5_indice_casas').all()).results.map((r) => JSON.parse(r.doc)); }
206
+ async indexReplaceAgents(domain, cards) {
207
+ await this.db.batch([
208
+ this.db.prepare('DELETE FROM nyx5_indice_agentes WHERE house = ?').bind(domain),
209
+ ...cards.map((c) => this.db.prepare('INSERT INTO nyx5_indice_agentes (house, address, doc) VALUES (?, ?, ?)').bind(domain, c.address, j(c))),
210
+ ]);
211
+ }
212
+ async indexSearch({ q, capability, accepts, house, limit = 50, offset = 0 } = {}) {
213
+ const where = []; const binds = [];
214
+ if (house) { where.push('house = ?'); binds.push(house); }
215
+ if (capability) { where.push("json_extract(doc, '$.capabilities.' || ?) IS NOT NULL"); binds.push(capability); }
216
+ if (accepts) { where.push("EXISTS (SELECT 1 FROM json_each(doc, '$.capabilities.accepts') WHERE value = ?)"); binds.push(accepts); }
217
+ if (q) { where.push('lower(doc) LIKE ?'); binds.push(`%${String(q).toLowerCase()}%`); }
218
+ const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
219
+ const total = (await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_indice_agentes ${w}`).bind(...binds).first()).c;
220
+ const rows = await this.db.prepare(`SELECT doc FROM nyx5_indice_agentes ${w} ORDER BY house, address LIMIT ? OFFSET ?`).bind(...binds, limit, offset).all();
221
+ return { total, offset, agents: rows.results.map((r) => JSON.parse(r.doc)) };
222
+ }
223
+ }