@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.
- package/README.en.md +512 -0
- package/README.md +579 -2
- package/firebase.config.json +10 -0
- package/index.mjs +328 -0
- package/lib/agentes.mjs +53 -0
- package/lib/archivos.mjs +86 -0
- package/lib/config.mjs +72 -0
- package/lib/envoltorio.mjs +117 -0
- package/lib/escucha.mjs +269 -0
- package/lib/esperar.mjs +131 -0
- package/lib/firestore.mjs +181 -0
- package/lib/lienzo.mjs +1329 -0
- package/lib/secretos.mjs +113 -0
- package/lib/sesion.mjs +449 -0
- package/package.json +45 -4
package/lib/escucha.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/esperar.mjs
ADDED
|
@@ -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 });
|