@ajuarezso/lienzo-mcp 0.0.0-stage → 0.3.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,269 @@
1
+ // «Timbre» de lienzo_esperar: escucha la sala en tiempo real y solo avisa que ALGO cambió.
2
+ //
3
+ // Por qué: esperar mirando la sala por REST cuesta lecturas aunque no pase nada (cada runQuery cobra
4
+ // al menos 1 lectura, más las de las reglas). Con un listener de Firestore, una sala quieta no cuesta
5
+ // nada: se paga una lectura por documento que cambia. El timbre NO entrega datos: cuando suena,
6
+ // lienzo_esperar hace UNA pasada de novedades() por REST, igual que antes (mismo contrato, mismo sobre
7
+ // de seguridad, mismas marcas). Medido en docs/costo-esperar.md.
8
+ //
9
+ // Qué escucha (tres consultas chicas, nunca la colección entera: el estado inicial se cobra):
10
+ // 1. la tarjeta más nueva orderBy(creadaEn desc) limit 1 → llegó una tarjeta
11
+ // 2. la última edición editadaEn > al abrir, desc, limit 1 → alguien editó
12
+ // 3. mis tareas autorId == yo, tipo == 'tarea' → cambió estado/respuesta/tomadaPor
13
+ // Lo propio (tarjetas o ediciones mías) y lo que solo mueve el lienzo (posicion, marcoId) no suena.
14
+ //
15
+ // Usa el SDK web de Firestore en Node (gRPC) con el idToken de la sesión del puente: se le entrega por
16
+ // el componente interno «auth-internal», el mismo por el que lo recibe de Firebase Auth en la web. Las
17
+ // reglas se aplican igual: el listener es el mismo participante que el REST, sin privilegios.
18
+ // Si algo de esto falla (paquete, red, reglas), el timbre queda «no sano» y lienzo_esperar vuelve a
19
+ // mirar por REST con la espera creciente de siempre.
20
+
21
+ export const ABRIR_TIMEOUT_MS = 15 * 1000;
22
+ /** LIENZO_DEPURAR=1: el timbre cuenta por stderr cada vez que suena y por qué (para medir, no para el día a día). */
23
+ const DEPURAR = process.env.LIENZO_DEPURAR === '1';
24
+ /** Campos cuyo cambio en una tarea mía es novedad (los mismos que mira _cambiosEnMisTareas). */
25
+ const CAMPOS_TAREA = ['estado', 'respuesta', 'tomadaPor'];
26
+
27
+ let siguiente = 0;
28
+
29
+ /** Carga perezosa del SDK: el puente arranca igual de rápido y, si el paquete falta, se sigue por REST. */
30
+ async function cargarSdk() {
31
+ const [app, componente, firestore] = await Promise.all([
32
+ import('@firebase/app'), import('@firebase/component'), import('@firebase/firestore'),
33
+ ]);
34
+ // stdout es del protocolo MCP: el SDK solo puede hablar por stderr (warn/error van ahí)
35
+ firestore.setLogLevel('error');
36
+ return { app, componente, firestore };
37
+ }
38
+
39
+ /** «2026-10-20T15:00:00.123456Z» → Timestamp exacto (Date perdería los microsegundos). */
40
+ function aTimestamp(firestore, iso) {
41
+ const m = /^(.*T\d\d:\d\d:\d\d)(?:\.(\d+))?Z$/.exec(iso || '');
42
+ if (!m) return firestore.Timestamp.fromDate(new Date(iso));
43
+ const segundos = Math.floor(Date.parse(`${m[1]}Z`) / 1000);
44
+ const nanos = Number((m[2] || '').padEnd(9, '0').slice(0, 9));
45
+ return new firestore.Timestamp(segundos, nanos);
46
+ }
47
+
48
+ export class Timbre {
49
+ /**
50
+ * @param {{ config: object, sesion: { uid: string, token: Function, renovar: Function, idToken?: string },
51
+ * salaId: string, miId: string, desde?: string|null, sdk?: object, log?: Function }} o
52
+ * miId: el id de participante de este puente (autorId de lo que publica). desde: marca de edición al abrir.
53
+ */
54
+ constructor(o) {
55
+ this.config = o.config;
56
+ this.sesion = o.sesion;
57
+ this.salaId = o.salaId;
58
+ this.miId = o.miId;
59
+ this.desde = o.desde ?? null;
60
+ this._sdk = o.sdk ?? null;
61
+ this.log = o.log ?? ((m) => console.error(`[lienzo] ${m}`));
62
+ /** Sube con cada cambio que importa. lienzo_esperar consulta cuando no es el que vio la última vez. */
63
+ this.version = 0;
64
+ /** Cuántos cambios de documento llegaron por el listener (cada uno se cobra como 1 lectura). */
65
+ this.cambiosRecibidos = 0;
66
+ this.error = null;
67
+ this._estados = new Map(); // nombre de la consulta → { servidor: bool }
68
+ this._tareas = new Map(); // id → 'estado|respuesta|tomadaPor' visto
69
+ this._ultimaEdicion = null; // 'id@editadaEn' de la última edición vista
70
+ this._bajas = [];
71
+ this._esperando = new Set();
72
+ this._cerrado = false;
73
+ this._db = null;
74
+ this._app = null;
75
+ }
76
+
77
+ /** Sano = las tres consultas tienen respuesta del servidor y ninguna falló. Si no, se mira por REST. */
78
+ get sano() {
79
+ return !this._cerrado && !this.error && this._estados.size === 3 && [...this._estados.values()].every((e) => e.servidor);
80
+ }
81
+
82
+ _sonar(motivo) {
83
+ this.version++;
84
+ if (DEPURAR) this.log(`${new Date().toISOString().slice(11, 19)} timbre: sonó (${motivo}) → versión ${this.version}`);
85
+ for (const r of this._esperando) r(motivo);
86
+ }
87
+
88
+ /** Abre los tres listeners y espera su primera respuesta del servidor. Lanza si no lo logra. */
89
+ async abrir() {
90
+ const sdk = this._sdk ?? await cargarSdk();
91
+ const { initializeApp, _addOrOverwriteComponent } = sdk.app;
92
+ const { Component } = sdk.componente;
93
+ const fs = sdk.firestore;
94
+ const sesion = this.sesion;
95
+
96
+ this._app = initializeApp({ projectId: this.config.projectId, apiKey: this.config.apiKey }, `lienzo-timbre-${++siguiente}`);
97
+ _addOrOverwriteComponent(this._app, new Component('auth-internal', () => ({
98
+ getUid: () => sesion.uid ?? null,
99
+ getToken: async (forzar) => ({ accessToken: forzar ? await sesion.renovar() : await sesion.token() }),
100
+ // Firestore espera un primer aviso con el token actual antes de abrir el stream.
101
+ addAuthTokenListener: (l) => { Promise.resolve().then(() => l(sesion.idToken ?? null)); },
102
+ removeAuthTokenListener: () => {},
103
+ }), 'PRIVATE'));
104
+ this._db = fs.getFirestore(this._app);
105
+ if (this.config.emuladorFirestore) {
106
+ const [host, puerto] = this.config.emuladorFirestore.split(':');
107
+ fs.connectFirestoreEmulator(this._db, host, Number(puerto));
108
+ }
109
+
110
+ const tarjetas = fs.collection(this._db, 'salas', this.salaId, 'tarjetas');
111
+ const consultas = {
112
+ nuevas: fs.query(tarjetas, fs.orderBy('creadaEn', 'desc'), fs.limit(1)),
113
+ ediciones: fs.query(tarjetas, fs.where('editadaEn', '>', aTimestamp(fs, this.desde ?? new Date().toISOString())),
114
+ fs.orderBy('editadaEn', 'desc'), fs.limit(1)),
115
+ tareas: fs.query(tarjetas, fs.where('autorId', '==', this.miId), fs.where('tipo', '==', 'tarea')),
116
+ };
117
+
118
+ const listo = new Promise((resolve, reject) => {
119
+ const t = setTimeout(() => reject(new Error(`el listener no respondió en ${ABRIR_TIMEOUT_MS / 1000} s`)), ABRIR_TIMEOUT_MS);
120
+ this._alListo = () => { if (this.sano) { clearTimeout(t); resolve(); } };
121
+ this._alFallar = (e) => { clearTimeout(t); reject(e); };
122
+ });
123
+
124
+ for (const [nombre, q] of Object.entries(consultas)) {
125
+ this._estados.set(nombre, { servidor: false, primera: true });
126
+ this._bajas.push(fs.onSnapshot(q, { includeMetadataChanges: true },
127
+ (snap) => this._alSnapshot(nombre, snap),
128
+ (e) => this._alError(nombre, e)));
129
+ }
130
+ try {
131
+ await listo;
132
+ } catch (e) {
133
+ await this.cerrar();
134
+ throw e;
135
+ }
136
+ return this;
137
+ }
138
+
139
+ _alSnapshot(nombre, snap) {
140
+ const estado = this._estados.get(nombre);
141
+ const estabaSano = this.sano;
142
+ if (DEPURAR) this.log(`${new Date().toISOString().slice(11, 19)} timbre: snapshot ${nombre} fromCache=${snap.metadata.fromCache} cambios=${snap.docChanges().map((c) => `${c.type}:${c.doc.id}`).join(',') || '-'}`);
143
+ estado.servidor = !snap.metadata.fromCache;
144
+ // «primera» = el estado inicial que manda el servidor (lo que ya había): no es novedad
145
+ const primera = estado.primera;
146
+ if (!snap.metadata.fromCache) estado.primera = false;
147
+ let importa = false;
148
+ for (const c of snap.docChanges()) {
149
+ if (!snap.metadata.fromCache) this.cambiosRecibidos++;
150
+ const d = c.doc.data();
151
+ if (nombre === 'tareas') {
152
+ const huella = CAMPOS_TAREA.map((k) => JSON.stringify(d[k] ?? null)).join('|');
153
+ const antes = this._tareas.get(c.doc.id);
154
+ if (c.type === 'removed') { this._tareas.delete(c.doc.id); continue; }
155
+ this._tareas.set(c.doc.id, huella);
156
+ // una tarea mía nueva la publiqué yo; un movimiento en el lienzo no cambia la huella
157
+ if (!primera && c.type === 'modified' && antes !== huella) importa = true;
158
+ } else if (nombre === 'ediciones') {
159
+ // la misma tarjeta editada dos veces sigue arriba: llega como «modified», no como «added»
160
+ const clave = `${d.editadaEn?.seconds}.${d.editadaEn?.nanoseconds}`;
161
+ const antes = this._ultimaEdicion;
162
+ if (c.type === 'removed') continue;
163
+ this._ultimaEdicion = `${c.doc.id}@${clave}`;
164
+ if (!primera && antes !== this._ultimaEdicion && d.autorId !== this.miId) importa = true;
165
+ } else if (!primera && c.type === 'added' && d.autorId !== this.miId) {
166
+ // la tarjeta más nueva cambió y no es mía; lo mío ya lo sé
167
+ importa = true;
168
+ }
169
+ }
170
+ if (importa) this._sonar(nombre);
171
+ else if (!estabaSano && this.sano) this._sonar('volvió la conexión'); // pudo perderse algo en el corte
172
+ else if (estabaSano && !this.sano) this._sonar('sin conexión'); // despierta a quien espera: pasa a REST
173
+ this._alListo?.();
174
+ }
175
+
176
+ _alError(nombre, e) {
177
+ this.error = e;
178
+ this.log(`el listener «${nombre}» falló (${e.code || e.message}); lienzo_esperar sigue mirando por REST`);
179
+ this._alFallar?.(e);
180
+ this._sonar('error');
181
+ }
182
+
183
+ /**
184
+ * Espera un cambio hasta `ms`. Resuelve true si sonó (o el timbre dejó de estar sano: quien espera
185
+ * tiene que volver a mirar), false si se acabó el tiempo. Una señal abortada resuelve false.
186
+ */
187
+ esperarCambio(ms, signal) {
188
+ return new Promise((resolve) => {
189
+ if (signal?.aborted) return resolve(false);
190
+ let fin = null;
191
+ const listo = (v) => {
192
+ clearTimeout(t);
193
+ this._esperando.delete(fin);
194
+ signal?.removeEventListener('abort', alAbortar);
195
+ resolve(v);
196
+ };
197
+ fin = () => listo(true);
198
+ const alAbortar = () => listo(false);
199
+ const t = setTimeout(() => listo(false), Math.max(0, ms));
200
+ this._esperando.add(fin);
201
+ signal?.addEventListener('abort', alAbortar, { once: true });
202
+ });
203
+ }
204
+
205
+ async cerrar() {
206
+ if (this._cerrado) return;
207
+ this._cerrado = true;
208
+ for (const baja of this._bajas.splice(0)) { try { baja(); } catch { /* ya cerrado */ } }
209
+ for (const r of this._esperando) r('cerrado');
210
+ const sdk = this._sdk;
211
+ try {
212
+ if (this._db) await (sdk?.firestore ?? await import('@firebase/firestore')).terminate(this._db);
213
+ if (this._app) await (sdk?.app ?? await import('@firebase/app')).deleteApp(this._app);
214
+ } catch (e) {
215
+ this.log(`no pude cerrar el listener: ${e.message}`);
216
+ }
217
+ }
218
+ }
219
+
220
+ // ── un timbre por puente, atado a la sala y a la identidad ───────────────────
221
+ export const REINTENTO_MS = 5 * 60 * 1000;
222
+ const timbres = new WeakMap(); // lienzo → { clave, timbre, abriendo, falloEn }
223
+
224
+ /**
225
+ * El timbre de la sala en la que está `lienzo`, abierto la primera vez que se espera y reusado entre
226
+ * llamadas (abrirlo cobra su estado inicial; tenerlo abierto en una sala quieta, nada). Si se cambia de
227
+ * sala o de identidad se cierra y se abre otro. Si no se puede abrir devuelve null (lienzo_esperar mira
228
+ * por REST) y no se reintenta hasta dentro de 5 min.
229
+ * @param {{ salaId: string|null, yo: { id: string }|null, sesion: object, config: object, marca?: string|null, marcaEdicion?: string|null }} lienzo
230
+ */
231
+ export async function timbreDe(lienzo, { crear = (o) => new Timbre(o).abrir(), ahora = Date.now } = {}) {
232
+ if (process.env.LIENZO_SIN_TIMBRE === '1' || !lienzo.salaId || !lienzo.yo?.id) return null;
233
+ const clave = `${lienzo.salaId}|${lienzo.yo.id}|${lienzo.sesion.uid}`;
234
+ let e = timbres.get(lienzo);
235
+ if (e && e.clave !== clave) {
236
+ await e.abriendo; // uno que se está abriendo no se pierde: se espera a que exista para cerrarlo
237
+ await e.timbre?.cerrar();
238
+ e = null;
239
+ }
240
+ if (!e) {
241
+ e = { clave, timbre: null, abriendo: null, falloEn: null };
242
+ timbres.set(lienzo, e);
243
+ }
244
+ if (e.timbre && (e.timbre.error || e.timbre._cerrado)) {
245
+ // un listener que falló (p. ej. reglas) no se recupera solo: se cierra y se reintenta más tarde
246
+ await e.timbre.cerrar();
247
+ e.timbre = null;
248
+ e.falloEn = ahora();
249
+ }
250
+ if (e.timbre) return e.timbre;
251
+ if (e.falloEn != null && ahora() - e.falloEn < REINTENTO_MS) return null;
252
+ e.abriendo ??= crear({
253
+ config: lienzo.config, sesion: lienzo.sesion, salaId: lienzo.salaId, miId: lienzo.yo.id,
254
+ desde: lienzo.marcaEdicion ?? lienzo.marca ?? null,
255
+ }).then((t) => { e.timbre = t; e.falloEn = null; return t; }, (err) => {
256
+ console.error(`[lienzo] no pude escuchar la sala en tiempo real (${err.code || err.message}); lienzo_esperar mira por REST`);
257
+ e.falloEn = ahora();
258
+ return null;
259
+ }).finally(() => { e.abriendo = null; });
260
+ return e.abriendo;
261
+ }
262
+
263
+ /** Cierra el timbre de `lienzo` (al apagar el puente: el stream de gRPC no deja terminar al proceso). */
264
+ export async function cerrarTimbre(lienzo) {
265
+ const e = timbres.get(lienzo);
266
+ timbres.delete(lienzo);
267
+ await e?.abriendo; // si se estaba abriendo, se espera a que exista (no queda huérfano)
268
+ await e?.timbre?.cerrar();
269
+ }
@@ -0,0 +1,131 @@
1
+ // Espera acotada de lienzo_esperar. Nunca es un bucle infinito: el tope duro es MAX_SEGUNDOS, pida lo
2
+ // que pida el agente.
3
+ //
4
+ // Dos modos (costo medido en docs/costo-esperar.md):
5
+ // · con timbre (listener en tiempo real, lib/escucha.mjs): no consulta nada mientras la sala está
6
+ // quieta; cuando el timbre suena hace UNA pasada de `consultar`. Sala quieta = 0 lecturas.
7
+ // · sin timbre (o si el timbre se cae): long-poll con espera creciente, consulta, y si no hay nada espera
8
+ // 2,5 s, luego 3,75 s, 5,6 s… hasta 15 s entre consultas. Cada consulta cuesta lecturas aunque no
9
+ // traiga nada, y el plan gratis (Spark) da 50 000 por día para todo el proyecto.
10
+
11
+ export const MAX_SEGUNDOS = 60;
12
+ export const INTERVALO_MS = 2500;
13
+ export const INTERVALO_MAX_MS = 15000;
14
+ export const FACTOR = 1.5;
15
+ /** Cuando suena el timbre se espera un poco antes de mirar: una ráfaga (una lista de 9) se lee de una vez. */
16
+ export const AGRUPAR_MS = 300;
17
+ /** Si el timbre se cae, cuánto se espera a que vuelva solo antes de pasar a mirar por REST. */
18
+ export const GRACIA_MS = 10 * 1000;
19
+
20
+ const dormirReal = (ms, signal) =>
21
+ new Promise((resolve) => {
22
+ const t = setTimeout(resolve, ms);
23
+ signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
24
+ });
25
+
26
+ /**
27
+ * @param {(signal?: AbortSignal) => Promise<any>} consultar devuelve algo "verdadero" cuando hay novedades
28
+ * @param {{ segundos?: number, intervaloMs?: number, maxIntervaloMs?: number, factor?: number,
29
+ * signal?: AbortSignal, ahora?: () => number, dormir?: Function,
30
+ * timbre?: { sano: boolean, version: number, visto?: number|null, esperarCambio: Function } | null }} o
31
+ * intervaloMs es la primera espera del long-poll (por defecto 2,5 s); cada espera vacía la multiplica por
32
+ * `factor` hasta `maxIntervaloMs`. Quien llama puede guardar `intervalo` y pasarlo en la siguiente espera
33
+ * para no volver a empezar desde 2,5 s si la sala sigue quieta.
34
+ * timbre: si está sano, se espera a que suene en vez de consultar. `timbre.visto` es la versión que ya se
35
+ * miró: si no cambió desde la última consulta vacía, la espera arranca sin consultar.
36
+ * @returns {Promise<{ resultado: any, consultas: number, agotado: boolean, cancelado: boolean, intervalo: number, modo: 'timbre'|'rest', version?: number }>}
37
+ */
38
+ export async function esperarNovedades(consultar, o = {}) {
39
+ const segundos = Math.min(Math.max(Number(o.segundos) || 30, 1), MAX_SEGUNDOS);
40
+ const ahora = o.ahora || Date.now;
41
+ const limite = ahora() + segundos * 1000;
42
+ // un timbre que no está sano igual se usa: abajo se le da un margen para volver antes de mirar por REST
43
+ const timbre = o.timbre ?? null;
44
+ if (!timbre) return sondear(consultar, o, limite, 0);
45
+
46
+ const dormir = o.dormir || dormirReal;
47
+ let consultas = 0;
48
+ const fin = (extra) => ({ resultado: null, consultas, agotado: false, cancelado: false, intervalo: o.intervaloMs ?? INTERVALO_MS, modo: 'timbre', ...extra });
49
+ for (;;) {
50
+ if (o.signal?.aborted) return fin({ cancelado: true });
51
+ if (!timbre.sano) {
52
+ // Un corte corto (el token se renueva a la hora, el servidor recicla el stream) se arregla solo en 1–2 s:
53
+ // se le da un margen antes de mirar por REST. Medido: sin margen, cada corte costaba ~25 lecturas.
54
+ // Si el servidor negó el permiso (te quitaron, saliste, se borró) no hay nada que esperar: se mira por REST ya
55
+ // y el error explica por qué.
56
+ // (o si otra herramienta lo cerró porque ya no estás en la sala)
57
+ const denegado = timbre.error?.code === 'permission-denied' || timbre._cerrado === true;
58
+ const gracia = Math.min(limite, ahora() + (denegado ? 0 : GRACIA_MS));
59
+ while (!timbre.sano && ahora() < gracia && !o.signal?.aborted) await timbre.esperarCambio(gracia - ahora(), o.signal);
60
+ if (o.signal?.aborted) return fin({ cancelado: true });
61
+ if (timbre.sano) continue; // volvió: el timbre ya sonó («volvió la conexión») y se pone al día con una pasada
62
+ // sigue caído: el resto del tiempo se mira por REST, desde 2,5 s
63
+ timbre.visto = null;
64
+ if (ahora() >= limite) return fin({ agotado: true });
65
+ return sondear(consultar, { ...o, intervaloMs: INTERVALO_MS }, limite, consultas);
66
+ }
67
+ if (timbre.version !== timbre.visto) {
68
+ if (timbre.visto != null) await dormir(Math.min(AGRUPAR_MS, Math.max(0, limite - ahora())), o.signal);
69
+ if (o.signal?.aborted) return fin({ cancelado: true });
70
+ const version = timbre.version; // lo que llegue mientras se consulta vuelve a sonar
71
+ consultas++;
72
+ let resultado;
73
+ try {
74
+ resultado = await consultar(o.signal);
75
+ } catch (e) {
76
+ if (o.signal?.aborted) return fin({ cancelado: true });
77
+ throw e;
78
+ }
79
+ if (resultado) {
80
+ // Lo entregado puede salir recortado (tope de 60 000 caracteres): por si acaso la próxima espera mira
81
+ // otra vez. Quien formatea la respuesta y ve que entró entera pone timbre.visto = version (entregado()).
82
+ timbre.visto = null;
83
+ return fin({ resultado, version });
84
+ }
85
+ timbre.visto = version;
86
+ continue; // si sonó durante la consulta, se mira de nuevo
87
+ }
88
+ const resta = limite - ahora();
89
+ if (resta <= 0) return fin({ agotado: true });
90
+ await timbre.esperarCambio(resta, o.signal);
91
+ }
92
+ }
93
+
94
+ /** Long-poll con espera creciente (el modo de siempre, y el respaldo cuando no hay timbre). */
95
+ async function sondear(consultar, o, limite, consultasPrevias) {
96
+ const maximo = Math.max(o.maxIntervaloMs ?? INTERVALO_MAX_MS, 10);
97
+ const factor = Math.max(o.factor ?? FACTOR, 1);
98
+ let intervalo = Math.min(Math.max(o.intervaloMs ?? INTERVALO_MS, 10), maximo);
99
+ const ahora = o.ahora || Date.now;
100
+ const dormir = o.dormir || dormirReal;
101
+ let consultas = consultasPrevias;
102
+ const fin = (extra) => ({ resultado: null, consultas, agotado: false, cancelado: false, intervalo, modo: 'rest', ...extra });
103
+
104
+ for (;;) {
105
+ if (o.signal?.aborted) return fin({ cancelado: true });
106
+ consultas++;
107
+ let resultado;
108
+ try {
109
+ resultado = await consultar(o.signal);
110
+ } catch (e) {
111
+ if (o.signal?.aborted) return fin({ cancelado: true }); // se cortó el fetch en curso
112
+ throw e;
113
+ }
114
+ if (resultado) return fin({ resultado });
115
+ const resta = limite - ahora();
116
+ if (resta <= 0) return fin({ agotado: true });
117
+ await dormir(Math.min(intervalo, resta), o.signal);
118
+ intervalo = Math.min(intervalo * factor, maximo);
119
+ }
120
+ }
121
+
122
+ /** Respuesta de lienzo_leer / lienzo_esperar que no entregó todo (lo dice formatearLote). */
123
+ export const RE_RECORTADA = /Respuesta recortada|Había más de/;
124
+
125
+ /**
126
+ * Tras formatear lo que trajo una espera con timbre: si la respuesta entró entera, lo consultado ya está
127
+ * entregado y la próxima espera no necesita volver a mirar (ahorra una pasada por cada novedad).
128
+ */
129
+ export function entregado(timbre, r, texto) {
130
+ if (timbre && r?.modo === 'timbre' && r.version != null && !RE_RECORTADA.test(texto)) timbre.visto = r.version;
131
+ }
@@ -0,0 +1,181 @@
1
+ // Cliente REST mínimo de Firestore, con el idToken de la cuenta conectada (la de la persona).
2
+ // Las reglas de seguridad se aplican igual que en la web: el MCP no tiene privilegios.
3
+
4
+ // ── conversión de valores ────────────────────────────────────────────────────
5
+ export function aValor(v) {
6
+ if (v === null || v === undefined) return { nullValue: null };
7
+ if (v instanceof Date) return { timestampValue: v.toISOString() };
8
+ if (typeof v === 'string') return { stringValue: v };
9
+ if (typeof v === 'boolean') return { booleanValue: v };
10
+ if (typeof v === 'number') return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v };
11
+ if (Array.isArray(v)) return { arrayValue: { values: v.map(aValor) } };
12
+ if (typeof v === 'object') {
13
+ if (v.__timestamp) return { timestampValue: v.__timestamp };
14
+ return { mapValue: { fields: aCampos(v) } };
15
+ }
16
+ throw new Error(`tipo no soportado: ${typeof v}`);
17
+ }
18
+
19
+ export function aCampos(obj) {
20
+ const fields = {};
21
+ for (const [k, v] of Object.entries(obj)) fields[k] = aValor(v);
22
+ return fields;
23
+ }
24
+
25
+ export function deValor(v) {
26
+ if (!v || 'nullValue' in v) return null;
27
+ if ('stringValue' in v) return v.stringValue;
28
+ if ('integerValue' in v) return Number(v.integerValue);
29
+ if ('doubleValue' in v) return Number(v.doubleValue);
30
+ if ('booleanValue' in v) return v.booleanValue;
31
+ if ('timestampValue' in v) return v.timestampValue; // RFC 3339, se compara tal cual
32
+ if ('mapValue' in v) return deCampos(v.mapValue.fields || {});
33
+ if ('arrayValue' in v) return (v.arrayValue.values || []).map(deValor);
34
+ if ('referenceValue' in v) return v.referenceValue;
35
+ return null;
36
+ }
37
+
38
+ export function deCampos(fields = {}) {
39
+ const o = {};
40
+ for (const [k, v] of Object.entries(fields)) o[k] = deValor(v);
41
+ return o;
42
+ }
43
+
44
+ export function deDocumento(doc) {
45
+ if (!doc?.name) return null;
46
+ return { id: doc.name.split('/').pop(), ruta: doc.name, ...deCampos(doc.fields) };
47
+ }
48
+
49
+ // ── errores ──────────────────────────────────────────────────────────────────
50
+ export class ErrorFirestore extends Error {
51
+ constructor(status, cuerpo) {
52
+ const msg = cuerpo?.error?.message || cuerpo?.[0]?.error?.message || `HTTP ${status}`;
53
+ super(msg);
54
+ this.status = status;
55
+ this.estado = cuerpo?.error?.status || cuerpo?.[0]?.error?.status || null;
56
+ }
57
+ get negado() { return this.status === 403 || this.estado === 'PERMISSION_DENIED'; }
58
+ get noExiste() { return this.status === 404 || this.estado === 'NOT_FOUND'; }
59
+ get yaExiste() { return this.status === 409 || this.estado === 'ALREADY_EXISTS'; }
60
+ }
61
+
62
+ // ── cliente ──────────────────────────────────────────────────────────────────
63
+ export const TIMEOUT_MS = 20 * 1000;
64
+
65
+ export class Firestore {
66
+ constructor(config, sesion, { fetch: f = globalThis.fetch } = {}) {
67
+ this.config = config;
68
+ this.sesion = sesion;
69
+ this.fetch = f;
70
+ const raiz = config.emuladorFirestore ? `http://${config.emuladorFirestore}` : 'https://firestore.googleapis.com';
71
+ this.db = `projects/${config.projectId}/databases/(default)`;
72
+ this.base = `${raiz}/v1/${this.db}`;
73
+ }
74
+
75
+ nombre(ruta) {
76
+ return `${this.db}/documents/${ruta}`;
77
+ }
78
+
79
+ /** Una petición con tope de tiempo; `signal` (opcional) la cancela antes, p. ej. si el agente cancela la espera. */
80
+ async _pedir(metodo, url, cuerpo, { reintento = true, signal } = {}) {
81
+ const token = await this.sesion.token();
82
+ const senales = [AbortSignal.timeout(TIMEOUT_MS), signal].filter(Boolean);
83
+ let r;
84
+ try {
85
+ r = await this.fetch(url, {
86
+ method: metodo,
87
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
88
+ body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
89
+ signal: senales.length > 1 ? AbortSignal.any(senales) : senales[0],
90
+ });
91
+ } catch (e) {
92
+ if (e?.name === 'TimeoutError') throw new Error(`Firestore no respondió en ${TIMEOUT_MS / 1000} s`);
93
+ throw e;
94
+ }
95
+ if (r.status === 401 && reintento) {
96
+ await this.sesion.renovar();
97
+ return this._pedir(metodo, url, cuerpo, { reintento: false, signal });
98
+ }
99
+ const d = await r.json().catch(() => ({}));
100
+ if (!r.ok) throw new ErrorFirestore(r.status, d);
101
+ return d;
102
+ }
103
+
104
+ /** Lee un documento; null si no existe. */
105
+ async leer(ruta) {
106
+ try {
107
+ return deDocumento(await this._pedir('GET', `${this.base}/documents/${ruta}`));
108
+ } catch (e) {
109
+ if (e instanceof ErrorFirestore && e.noExiste) return null;
110
+ throw e;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Arma una escritura para :commit (para poder usar la hora del servidor).
116
+ * opciones: { crear: true } exige que no exista; { existe: true } exige que exista;
117
+ * mascara: [campos] actualiza solo esos; horaServidor: [campos] = REQUEST_TIME;
118
+ * agregar / quitar: { campo: [valores] } = appendMissingElements / removeAllFromArray (atómico en el servidor:
119
+ * dos puentes que agregan a la vez no se pisan).
120
+ */
121
+ escritura(ruta, datos, { crear = false, existe = false, mascara = null, horaServidor = [], agregar = null, quitar = null } = {}) {
122
+ const write = { update: { name: this.nombre(ruta), fields: aCampos(datos) } };
123
+ if (mascara) write.updateMask = { fieldPaths: mascara.map(campoRuta) };
124
+ const transformaciones = [
125
+ ...horaServidor.map((c) => ({ fieldPath: campoRuta(c), setToServerValue: 'REQUEST_TIME' })),
126
+ ...Object.entries(agregar ?? {}).map(([c, v]) => ({ fieldPath: campoRuta(c), appendMissingElements: { values: v.map(aValor) } })),
127
+ ...Object.entries(quitar ?? {}).map(([c, v]) => ({ fieldPath: campoRuta(c), removeAllFromArray: { values: v.map(aValor) } })),
128
+ ];
129
+ if (transformaciones.length) write.updateTransforms = transformaciones;
130
+ if (crear) write.currentDocument = { exists: false };
131
+ else if (existe) write.currentDocument = { exists: true };
132
+ return write;
133
+ }
134
+
135
+ /** Escribe un documento con :commit. Opciones: las de `escritura`. */
136
+ async escribir(ruta, datos, opciones = {}) {
137
+ return this.escribirVarios([{ ruta, datos, opciones }]);
138
+ }
139
+
140
+ /**
141
+ * Varias escrituras en UN :commit: o se aplican todas o ninguna (si una falla, las reglas o una
142
+ * precondición, no se escribe nada). Comparten el mismo REQUEST_TIME.
143
+ * @param {{ ruta: string, datos: object, opciones?: object }[]} escrituras
144
+ */
145
+ async escribirVarios(escrituras) {
146
+ if (!escrituras.length) return null;
147
+ const writes = escrituras.map((e) => this.escritura(e.ruta, e.datos, e.opciones));
148
+ const d = await this._pedir('POST', `${this.base}/documents:commit`, { writes });
149
+ return d.commitTime;
150
+ }
151
+
152
+ async borrar(ruta) {
153
+ await this._pedir('DELETE', `${this.base}/documents/${ruta}`);
154
+ }
155
+
156
+ /** runQuery sobre una colección hija de `padre` (ruta de documento o '' para la raíz). */
157
+ async consultar(padre, consulta, { signal } = {}) {
158
+ const url = padre ? `${this.base}/documents/${padre}:runQuery` : `${this.base}/documents:runQuery`;
159
+ const filas = await this._pedir('POST', url, { structuredQuery: consulta }, { signal });
160
+ return filas.filter((f) => f.document).map((f) => deDocumento(f.document));
161
+ }
162
+
163
+ /** Lee varios documentos de una vez; omite los que no existen. */
164
+ async leerVarios(rutas, { signal } = {}) {
165
+ if (!rutas.length) return [];
166
+ const filas = await this._pedir('POST', `${this.base}/documents:batchGet`, {
167
+ documents: rutas.map((r) => this.nombre(r)),
168
+ }, { signal });
169
+ return filas.filter((f) => f.found).map((f) => deDocumento(f.found));
170
+ }
171
+ }
172
+
173
+ /** Las rutas de campo con caracteres fuera de [A-Za-z_0-9] van entre comillas invertidas. */
174
+ export function campoRuta(campo) {
175
+ return campo.split('.').map((p) => (/^[A-Za-z_][A-Za-z_0-9]*$/.test(p) ? p : `\`${p.replace(/`/g, '\\`')}\``)).join('.');
176
+ }
177
+
178
+ // ayudas para armar consultas
179
+ export const filtro = (campo, op, valor) => ({ fieldFilter: { field: { fieldPath: campo }, op, value: aValor(valor) } });
180
+ export const y = (...filtros) => (filtros.length === 1 ? filtros[0] : { compositeFilter: { op: 'AND', filters: filtros } });
181
+ export const marcaDeTiempo = (iso) => ({ __timestamp: iso });