@dotrino/content 0.3.13

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/src/db.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Índice de metadatos (Fase 1, DISENO.md §3): SQLite embebido de Node
3
+ * (`node:sqlite`, sin dependencias). Los bytes viven en el BlobStore;
4
+ * aquí solo `cid, size, mime, createdAt, owner, enc, acl, ttl, pinned…`.
5
+ */
6
+ import { DatabaseSync } from 'node:sqlite'
7
+ import path from 'node:path'
8
+
9
+ export class Index {
10
+ /** @param {string} root directorio raíz de datos */
11
+ constructor (root) {
12
+ this.db = new DatabaseSync(path.join(root, 'index.db'))
13
+ this.db.exec(`
14
+ PRAGMA journal_mode = WAL;
15
+ CREATE TABLE IF NOT EXISTS blobs (
16
+ cid TEXT PRIMARY KEY,
17
+ size INTEGER NOT NULL,
18
+ mime TEXT NOT NULL DEFAULT 'application/octet-stream',
19
+ createdAt INTEGER NOT NULL,
20
+ owner TEXT,
21
+ enc INTEGER NOT NULL DEFAULT 0,
22
+ acl TEXT,
23
+ ttl INTEGER, -- epoch ms de expiración (NULL = no expira)
24
+ pinned INTEGER NOT NULL DEFAULT 0,
25
+ thumbnailCid TEXT,
26
+ meta TEXT -- JSON de presentación: { name, title, description }
27
+ );
28
+ CREATE TABLE IF NOT EXISTS egress (
29
+ day TEXT PRIMARY KEY, -- YYYY-MM-DD (UTC)
30
+ bytes INTEGER NOT NULL DEFAULT 0
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_blobs_ttl ON blobs (ttl) WHERE ttl IS NOT NULL;
33
+ CREATE INDEX IF NOT EXISTS idx_blobs_gc ON blobs (pinned, createdAt);
34
+ CREATE INDEX IF NOT EXISTS idx_blobs_acl ON blobs (acl, createdAt);
35
+ `)
36
+ // Migración de un índice creado antes de que existiera `meta`: añadir la
37
+ // columna a una base ya escrita en disco. `ADD COLUMN` con default NULL es
38
+ // barato y no reescribe la tabla; si ya está, SQLite tira y se ignora.
39
+ try { this.db.exec('ALTER TABLE blobs ADD COLUMN meta TEXT') } catch (_) { /* ya existía */ }
40
+
41
+ // Columnas de la CACHÉ (DISENO.md §15.11). Existen aunque no haya bucket: sin
42
+ // él, `remote` se queda en 0 y `cached` en 1 para siempre, y nada cambia.
43
+ //
44
+ // · `remote` — el bucket YA CONFIRMÓ estos bytes. Se pone a 1 con la
45
+ // confirmación, nunca al lanzar la subida: es el cerrojo que
46
+ // impide desalojar algo cuya única copia sigue siendo esta.
47
+ // · `cached` — los bytes están en el disco de esta máquina. Al desalojar se
48
+ // pone a 0 y la FILA SE QUEDA: si se fuera con los bytes, el
49
+ // blob quedaría en el bucket sin dueño, sin ACL y sin tipo.
50
+ // · `lastRead` — para desalojar por último acceso y no por antigüedad. Un
51
+ // almacén ordena por edad; una caché, por uso.
52
+ for (const col of [
53
+ 'remote INTEGER NOT NULL DEFAULT 0',
54
+ 'cached INTEGER NOT NULL DEFAULT 1',
55
+ 'lastRead INTEGER'
56
+ ]) {
57
+ try { this.db.exec(`ALTER TABLE blobs ADD COLUMN ${col}`) } catch (_) { /* ya existía */ }
58
+ }
59
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_blobs_evict ON blobs (pinned, cached, remote, lastRead)')
60
+ }
61
+
62
+ upsert ({ cid, size, mime, owner = null, enc = 0, acl = null, ttl = null, meta = null }) {
63
+ this.db.prepare(`
64
+ INSERT INTO blobs (cid, size, mime, createdAt, owner, enc, acl, ttl, meta)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
66
+ ON CONFLICT (cid) DO UPDATE SET
67
+ mime = excluded.mime,
68
+ ttl = excluded.ttl,
69
+ -- Volver a subir los mismos bytes NO borra la presentación que ya tenían.
70
+ meta = COALESCE(excluded.meta, blobs.meta)
71
+ `).run(cid, size, mime, Date.now(), owner, enc ? 1 : 0, acl, ttl, meta ? JSON.stringify(meta) : null)
72
+ }
73
+
74
+ get (cid) {
75
+ return this.db.prepare('SELECT * FROM blobs WHERE cid = ?').get(cid) ?? null
76
+ }
77
+
78
+ list () {
79
+ return this.db.prepare(
80
+ 'SELECT cid, size, mime, createdAt, owner, enc, acl, ttl, pinned, meta FROM blobs ORDER BY createdAt DESC'
81
+ ).all()
82
+ }
83
+
84
+ /**
85
+ * Cambia el `acl` de un blob (`public` | `private`). Devuelve false si no existe.
86
+ * Se cambia aparte del `upsert` a propósito: volver a subir los mismos bytes NO
87
+ * debe reabrir ni cerrar un blob por accidente.
88
+ */
89
+ setAcl (cid, acl) {
90
+ const { changes } = this.db.prepare('UPDATE blobs SET acl = ? WHERE cid = ?').run(acl, cid)
91
+ return changes > 0
92
+ }
93
+
94
+ remove (cid) {
95
+ this.db.prepare('DELETE FROM blobs WHERE cid = ?').run(cid)
96
+ }
97
+
98
+ setPinned (cid, pinned) {
99
+ const { changes } = this.db.prepare('UPDATE blobs SET pinned = ? WHERE cid = ?')
100
+ .run(pinned ? 1 : 0, cid)
101
+ return changes > 0
102
+ }
103
+
104
+ /** Suma de bytes indexados. @returns {number} */
105
+ totalBytes () {
106
+ return Number(this.db.prepare('SELECT COALESCE(SUM(size), 0) AS n FROM blobs').get().n)
107
+ }
108
+
109
+ /** @returns {number} */
110
+ count () {
111
+ return Number(this.db.prepare('SELECT COUNT(*) AS n FROM blobs').get().n)
112
+ }
113
+
114
+ /**
115
+ * Blobs con ttl vencido (candidatos a GC incondicional, aunque estén sin pin).
116
+ * @returns {{ cid: string, size: number }[]}
117
+ */
118
+ expired (now = Date.now()) {
119
+ return /** @type {{ cid: string, size: number }[]} */ (this.db.prepare(
120
+ 'SELECT cid, size FROM blobs WHERE ttl IS NOT NULL AND ttl <= ? AND pinned = 0'
121
+ ).all(now))
122
+ }
123
+
124
+ /**
125
+ * Candidatos a liberar espacio por cuota: no pineados, con bytes en disco, y
126
+ * **el menos usado primero** (DISENO.md §15.11). `COALESCE(lastRead, createdAt)`
127
+ * porque lo que nunca se ha leído cuenta por su fecha de subida.
128
+ *
129
+ * @param {{ requireRemote?: boolean }} [opts] `requireRemote` lo pone el backend
130
+ * con bucket: entonces solo se desaloja lo que el bucket YA confirmó, porque
131
+ * desalojar es tirar una copia caliente y sin la otra copia sería destruir.
132
+ * @returns {{ cid: string, size: number }[]}
133
+ */
134
+ evictable ({ requireRemote = false } = {}) {
135
+ return /** @type {{ cid: string, size: number }[]} */ (this.db.prepare(`
136
+ SELECT cid, size FROM blobs
137
+ WHERE pinned = 0 AND cached = 1 ${requireRemote ? 'AND remote = 1' : ''}
138
+ ORDER BY COALESCE(lastRead, createdAt) ASC
139
+ `).all())
140
+ }
141
+
142
+ /** Marca que estos bytes ya están confirmados en el bucket. */
143
+ setRemote (cid, remote = true) {
144
+ const { changes } = this.db.prepare('UPDATE blobs SET remote = ? WHERE cid = ?')
145
+ .run(remote ? 1 : 0, cid)
146
+ return changes > 0
147
+ }
148
+
149
+ /** Marca si los bytes están (o ya no) en el disco de esta máquina. */
150
+ setCached (cid, cached = true) {
151
+ const { changes } = this.db.prepare('UPDATE blobs SET cached = ? WHERE cid = ?')
152
+ .run(cached ? 1 : 0, cid)
153
+ return changes > 0
154
+ }
155
+
156
+ /** Anota que este blob se acaba de leer (es lo que ordena el desalojo). */
157
+ touch (cid, now = Date.now()) {
158
+ this.db.prepare('UPDATE blobs SET lastRead = ? WHERE cid = ?').run(now, cid)
159
+ }
160
+
161
+ /**
162
+ * Lo que aún NO está confirmado en el bucket: la cola de subida pendiente. Que
163
+ * esto sea una consulta y no una lista en memoria es a propósito — un reinicio
164
+ * a media subida no debe perder el pendiente.
165
+ */
166
+ pendingUpload (limit = 100) {
167
+ return /** @type {{ cid: string, size: number }[]} */ (this.db.prepare(
168
+ 'SELECT cid, size FROM blobs WHERE remote = 0 AND cached = 1 ORDER BY createdAt ASC LIMIT ?'
169
+ ).all(limit))
170
+ }
171
+
172
+ /** Bytes que ocupan disco AQUÍ (la cuota es de la caché, no del inventario). */
173
+ cachedBytes () {
174
+ return Number(this.db.prepare('SELECT COALESCE(SUM(size), 0) AS n FROM blobs WHERE cached = 1').get().n)
175
+ }
176
+
177
+ /**
178
+ * Metadatos de PRESENTACIÓN de un blob (nombre, título, descripción): lo único
179
+ * que la vista previa pública (§7.3) tiene para armar la tarjeta. Se guarda
180
+ * aparte de los bytes porque no forma parte del `cid` — dos nombres distintos
181
+ * para el mismo archivo son el mismo blob.
182
+ * @param {string} cid
183
+ * @param {{name?:string,title?:string,description?:string}|null} meta
184
+ */
185
+ setMeta (cid, meta) {
186
+ const { changes } = this.db.prepare('UPDATE blobs SET meta = ? WHERE cid = ?')
187
+ .run(meta ? JSON.stringify(meta) : null, cid)
188
+ return changes > 0
189
+ }
190
+
191
+ /**
192
+ * Enlaza la MINIATURA de un blob (otro blob, con su propio `cid`). El node no
193
+ * genera miniaturas: las hace la app al subir —el aparato pone el trabajo, que
194
+ * es el patrón del ecosistema— y aquí solo se anota cuál es.
195
+ */
196
+ setThumbnail (cid, thumbnailCid) {
197
+ const { changes } = this.db.prepare('UPDATE blobs SET thumbnailCid = ? WHERE cid = ?')
198
+ .run(thumbnailCid, cid)
199
+ return changes > 0
200
+ }
201
+
202
+ /**
203
+ * Blobs servibles al mundo: `acl = public` y EN CLARO. Un blob cifrado no sale
204
+ * por aquí ni marcado público (ops.js ya lo impide al marcarlo, esto es el
205
+ * segundo cerrojo, en el sitio donde los bytes de verdad salen).
206
+ */
207
+ listPublic ({ limit = 100, offset = 0 } = {}) {
208
+ return this.db.prepare(`
209
+ SELECT cid, size, mime, createdAt, meta FROM blobs
210
+ WHERE acl = 'public' AND enc = 0
211
+ ORDER BY createdAt DESC LIMIT ? OFFSET ?
212
+ `).all(limit, offset)
213
+ }
214
+
215
+ /**
216
+ * Contabilidad de EGRESS del modo público (§7.2), por día UTC y persistida: un
217
+ * techo que se reinicia con el proceso no es un techo — el reinicio es
218
+ * exactamente lo que pasa cuando algo se descontrola.
219
+ * @param {number} bytes @param {string} day YYYY-MM-DD
220
+ */
221
+ addEgress (bytes, day) {
222
+ if (!(bytes > 0)) return
223
+ this.db.prepare(`
224
+ INSERT INTO egress (day, bytes) VALUES (?, ?)
225
+ ON CONFLICT (day) DO UPDATE SET bytes = bytes + excluded.bytes
226
+ `).run(day, Math.round(bytes))
227
+ }
228
+
229
+ /** Bytes servidos ese día. @returns {number} */
230
+ egressOn (day) {
231
+ return Number(this.db.prepare('SELECT COALESCE(bytes, 0) AS n FROM egress WHERE day = ?').get(day)?.n || 0)
232
+ }
233
+
234
+ close () {
235
+ this.db.close()
236
+ }
237
+ }
package/src/fetch.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * fetch.js — el node ATIENDE a terceros (DISENO.md §16).
3
+ *
4
+ * Un aparato del acta entra por sesión cifrada (`ops.js`). Cualquier otro —quien
5
+ * recibió un enlace— no tiene sesión ni la tendrá: manda un mensaje suelto por el
6
+ * proxio, `content.fetch { cid }`, y recibe el blob si y solo si está marcado
7
+ * `public` y en claro. Es la misma regla que el modo HTTP (§7.2): sin `public`
8
+ * explícito, no sale nada, y un 'not-found' nunca distingue «no existe» de «no es
9
+ * público» para no confirmar qué guarda el node.
10
+ *
11
+ * Lo que va en la respuesta:
12
+ * - `url`: el atajo del bucket (§15.13) si el node lo tiene y el bucket ya confirmó
13
+ * esos bytes. La app la usa para un `<img>`; si falla, vuelve a pedir por aquí.
14
+ * - `data`: los bytes en base64, salvo `head` o si no caben en un mensaje. Lo que no
15
+ * cabe se sirve solo por URL; sin bucket, 'too-large' — lo grande es P2P (§13).
16
+ *
17
+ * Límite por remitente (token) para que un enlace viral no convierta el node en un
18
+ * CDN por el proxio: el proxio ya tiene el suyo, y este es el del node.
19
+ */
20
+
21
+ import { isValidCid } from '../lib/ref.js'
22
+ import { MSG, FETCH_MAX_BYTES } from '../lib/public.js'
23
+
24
+ export { MSG, FETCH_MAX_BYTES }
25
+
26
+ /**
27
+ * @param {{ client: any, node: import('./node.js').ContentNode, ratePerMin?: number, quiet?: boolean }} opts
28
+ * client: el `WebSocketProxyClient` YA conectado del agente (no se abre otro).
29
+ * @returns {{ close: () => void, served: () => number }}
30
+ */
31
+ export function startPublicFetch ({ client, node, ratePerMin = 60, quiet = false }) {
32
+ if (!client || !node) throw new Error('startPublicFetch: client and node are required')
33
+ /** token → { n, windowStart } */
34
+ const buckets = new Map()
35
+ let served = 0
36
+
37
+ const allowed = (from) => {
38
+ const now = Date.now()
39
+ const b = buckets.get(from) || { n: 0, windowStart: now }
40
+ if (now - b.windowStart >= 60_000) { b.n = 0; b.windowStart = now }
41
+ b.n++
42
+ buckets.set(from, b)
43
+ if (buckets.size > 5000) buckets.clear() // que la tabla no crezca sin techo
44
+ return b.n <= ratePerMin
45
+ }
46
+
47
+ const send = (to, obj) => { try { client.send(to, obj) } catch (e) { if (!quiet) console.error('[content] fetch reply:', e.message) } }
48
+ const fail = (to, rid, code, error) => send(to, { type: MSG.FETCH_ERR, rid, code, error })
49
+
50
+ const handle = async (from, p) => {
51
+ const rid = typeof p.rid === 'string' ? p.rid : null
52
+ if (!rid) return
53
+ if (!quiet) console.log(`[content] fetch ${String(p.cid).slice(0, 20)}… from ${String(from).slice(0, 16)}${p.head ? ' (head)' : ''}`)
54
+ if (!allowed(from)) return fail(from, rid, 'rate-limited', 'too many requests')
55
+ if (!isValidCid(p.cid)) return fail(from, rid, 'bad-input', 'invalid cid')
56
+ const meta = node.stat(p.cid)
57
+ // Mismo 'not-found' para «no existe» y «no es público»: el mismo motivo que §7.2.
58
+ if (!meta || meta.acl !== 'public' || meta.enc) return fail(from, rid, 'not-found', 'no such public cid')
59
+ const url = node.publicUrl(p.cid)
60
+ const out = { type: MSG.FETCH_OK, rid, cid: p.cid, mime: meta.mime, size: meta.size, url }
61
+ // Con atajo, los bytes NO viajan por el proxio salvo que el que pide insista
62
+ // (`full`): cargar por URL es gratis para el node y para la red, y la app ya
63
+ // sabe volver aquí si la URL le falla. Sin atajo, van en el mensaje si caben.
64
+ if (!p.head && (!url || p.full)) {
65
+ if (meta.size > FETCH_MAX_BYTES) {
66
+ if (!url) return fail(from, rid, 'too-large', `${meta.size} bytes do not fit in a message and this node has no public bucket`)
67
+ // Cabe solo por URL: se contesta sin bytes y la app carga por ahí.
68
+ } else {
69
+ const chunks = []
70
+ for await (const c of node.read(p.cid)) chunks.push(c)
71
+ out.data = Buffer.concat(chunks).toString('base64')
72
+ }
73
+ }
74
+ served++
75
+ send(from, out)
76
+ }
77
+
78
+ const off = client.on('message', (from, p) => {
79
+ if (!p || p.type !== MSG.FETCH) return
80
+ handle(from, p).catch((e) => fail(from, p.rid, 'failed', e.message))
81
+ })
82
+
83
+ return {
84
+ served: () => served,
85
+ close () { try { off?.() } catch (_) {} }
86
+ }
87
+ }
88
+
89
+ export default { startPublicFetch, MSG, FETCH_MAX_BYTES }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { ContentNode, isValidCid } from './node.js'
2
+ export { BlobStore } from './blobstore.js'
3
+ export { createServer, parseRange } from './server.js'
package/src/node.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * ContentNode = BlobStore (bytes) + Index (metadatos) + cuota/GC (Fase 1).
3
+ *
4
+ * Sigue sin saber de red: quien la pone es `agent.js` (Fase 2), que además le dice
5
+ * qué `owner` representa —la huella de la maestra del vault, la mitad izquierda de
6
+ * la referencia compartible `ownerId + cid` (DISENO.md §3)— y usa el `acl` para
7
+ * decidir qué puede salir del node cuando llegue el modo público (§7.2).
8
+ */
9
+ import { mkdir } from 'node:fs/promises'
10
+ import { BlobStore, isValidCid } from './blobstore.js'
11
+ import { Index } from './db.js'
12
+
13
+ export { isValidCid }
14
+
15
+ export class ContentNode {
16
+ /**
17
+ * @param {{ dir: string, maxBytes?: number, maxBlobBytes?: number, owner?: string|null,
18
+ * store?: any, log?: (m:string)=>void }} opts
19
+ * dir: raíz de datos · maxBytes: cuota total de disco (0 = sin límite)
20
+ * maxBlobBytes: tamaño máximo por blob (0 = sin límite)
21
+ * owner: `ownerId` de la maestra (lo pone el agente al enlazar; null sin vault)
22
+ * store: almacén ya montado (`storage.js`); por defecto, disco
23
+ */
24
+ constructor (opts) {
25
+ if (!opts?.dir) throw new Error('opts.dir is required')
26
+ this.dir = opts.dir
27
+ this.maxBytes = opts.maxBytes || 0
28
+ this.maxBlobBytes = opts.maxBlobBytes || 0
29
+ this.owner = opts.owner || null
30
+ // El almacén se puede INYECTAR (`storage.js` monta el de bucket cuando toca). Por
31
+ // defecto, disco: sin configuración, un node funciona con lo que hay en la máquina.
32
+ this.store = opts.store || new BlobStore(opts.dir)
33
+ // Base de la URL pública del bucket (§15.13). Sin bucket público es null y lo
34
+ // público viaja por la red, que es el camino que SIEMPRE existe.
35
+ this.publicBase = opts.publicBase || null
36
+ this.index = null
37
+ /** Subidas al bucket en curso, por cid: para no lanzar dos veces la misma. */
38
+ this._uploading = new Map()
39
+ this.log = opts.log || (() => {})
40
+ }
41
+
42
+ async init () {
43
+ await mkdir(this.dir, { recursive: true })
44
+ await this.store.init()
45
+ this.index = new Index(this.dir)
46
+ return this
47
+ }
48
+
49
+ /**
50
+ * Sube un stream. Si la cuota no alcanza, intenta GC de no-pineados; si aun
51
+ * así no cabe, rechaza con code ENOSPC (no borra pineados jamás).
52
+ */
53
+ async put (readable, { mime = 'application/octet-stream', enc = 0, ttl = null, acl = null, meta = null } = {}) {
54
+ const { cid, size, existed } = await this.store.put(readable, {
55
+ maxBytes: this.maxBlobBytes || undefined
56
+ })
57
+ if (this.maxBytes && !existed) {
58
+ // La cuota es del DISCO, así que cuenta lo que está cacheado aquí, no el
59
+ // inventario: con bucket detrás, lo desalojado sigue existiendo (§15.11)
60
+ // pero ya no ocupa nada — contarlo dejaría la cuota excedida para siempre.
61
+ const over = (this.index.cachedBytes() + size) - this.maxBytes
62
+ if (over > 0 && this.gc({ needBytes: over }).freed < over) {
63
+ await this.store.remove(cid)
64
+ throw Object.assign(new Error('disk quota exceeded'), { code: 'ENOSPC' })
65
+ }
66
+ }
67
+ this.index.upsert({ cid, size, mime, owner: this.owner, enc, acl, ttl, meta })
68
+ // Con bucket detrás, la subida va DESPUÉS de responder: quien sube no espera a la
69
+ // red. Hasta que el bucket confirme, este blob no es desalojable (§15.11).
70
+ if (!existed) this.backup(cid, { size, mime, public: acl === 'public' })
71
+ return { cid, size, mime, existed }
72
+ }
73
+
74
+ /** Metadatos de un blob (o null). */
75
+ stat (cid) {
76
+ return isValidCid(cid) ? this.index.get(cid) : null
77
+ }
78
+
79
+ /**
80
+ * La URL pública de un blob, o null. Es un ATAJO (§15.13), nunca el enlace: solo
81
+ * existe si hay bucket público, el blob está marcado público y en claro, y el
82
+ * bucket YA confirmó esos bytes (`remote`) — una URL que todavía da 404 no ayuda.
83
+ */
84
+ publicUrl (cid) {
85
+ if (!this.publicBase || !this.store.backed || typeof this.store.urlFor !== 'function') return null
86
+ const m = this.stat(cid)
87
+ if (!m || m.acl !== 'public' || m.enc || !m.remote) return null
88
+ return this.store.urlFor(cid, this.publicBase)
89
+ }
90
+
91
+ /**
92
+ * Marca un blob como público o privado (`acl`). Es lo único que autoriza a que
93
+ * los bytes salgan del node cuando esté encendido el modo público (DISENO.md
94
+ * §7.2); sin `public` explícito, no sale.
95
+ */
96
+ setAcl (cid, acl) {
97
+ const ok = this.index.setAcl(cid, acl)
98
+ // Publicar mueve los bytes al bucket público, que es OTRO bucket (§15.1). Sin esto
99
+ // el blob quedaría marcado público y sin estar donde se sirve lo público.
100
+ if (ok && acl === 'public' && this.store.backed) {
101
+ const m = this.index.get(cid)
102
+ if (m && !m.enc) this.backup(cid, { size: m.size, mime: m.mime, public: true })
103
+ }
104
+ return ok
105
+ }
106
+
107
+ /**
108
+ * Sube un blob a su bucket y lo marca `remote` **solo cuando el bucket confirma**.
109
+ * Marcar al lanzar la subida es exactamente el error que deja perder contenido: un
110
+ * GC oportuno se llevaría la única copia que existe.
111
+ *
112
+ * No espera nadie a esto (se lanza y se olvida), pero un fallo se REPORTA: lo que no
113
+ * subió sigue siendo la cola pendiente del índice, y se reintenta al arrancar.
114
+ */
115
+ backup (cid, meta) {
116
+ if (!this.store.backed) return null
117
+ // Si ya hay una subida en curso (la privada, típicamente), esta va DETRÁS, no se
118
+ // descarta: publicar a mitad de la subida tiene que terminar en el bucket público.
119
+ const prev = this._uploading.get(cid)
120
+ const p = (prev ? prev.catch(() => {}) : Promise.resolve())
121
+ .then(() => this.store.upload(cid, meta))
122
+ .then(() => { this.index.setRemote(cid, true) })
123
+ .catch((e) => { this.log(`[storage] could not upload ${cid.slice(0, 14)}…: ${e.message}`) })
124
+ .finally(() => this._uploading.delete(cid))
125
+ this._uploading.set(cid, p)
126
+ return p
127
+ }
128
+
129
+ /**
130
+ * Reintenta lo que quedó sin subir. Se llama al arrancar: un reinicio a media subida
131
+ * no debe perder el pendiente, y por eso la cola es una consulta al índice y no una
132
+ * lista en memoria.
133
+ */
134
+ async backupPending (limit = 100) {
135
+ if (!this.store.backed) return { pending: 0 }
136
+ const rows = this.index.pendingUpload(limit)
137
+ for (const r of rows) {
138
+ const m = this.index.get(r.cid)
139
+ if (m) await this.backup(r.cid, { size: m.size, mime: m.mime, public: m.acl === 'public' })
140
+ }
141
+ return { pending: rows.length }
142
+ }
143
+
144
+ /**
145
+ * Metadatos de presentación (nombre/título/descripción). Es lo único con lo que
146
+ * el permalink público arma su tarjeta (DISENO.md §7.3): sin esto una vista
147
+ * previa solo puede decir el tipo y el tamaño.
148
+ */
149
+ setMeta (cid, meta) {
150
+ return this.index.setMeta(cid, meta)
151
+ }
152
+
153
+ /** Enlaza la miniatura pública de un blob (la genera la app, no el node). */
154
+ setThumbnail (cid, thumbnailCid) {
155
+ return this.index.setThumbnail(cid, thumbnailCid)
156
+ }
157
+
158
+ /** Índice de lo servible al mundo (público y en claro). */
159
+ listPublic (opts) {
160
+ return this.index.listPublic(opts)
161
+ }
162
+
163
+ /**
164
+ * ¿Puede este blob salir del node por el HTTP público? Dos condiciones, y las
165
+ * dos se comprueban aquí —en el único sitio por donde salen los bytes— aunque
166
+ * `ops.acl` ya impida marcar público lo cifrado: un índice traído de otra
167
+ * versión, o tocado a mano, no debe poder abrir una puerta.
168
+ * @returns {any|null} los metadatos si es servible, null si no.
169
+ */
170
+ publicStat (cid) {
171
+ const meta = this.stat(cid)
172
+ if (!meta || meta.acl !== 'public' || meta.enc) return null
173
+ return meta
174
+ }
175
+
176
+ /**
177
+ * ReadStream del blob; range = { start, end } inclusivo.
178
+ *
179
+ * Anota el acceso (`lastRead`), que es lo que ordena el desalojo de la caché
180
+ * (§15.11): sin esto, el GC tira lo más antiguo, y lo antiguo y muy pedido es
181
+ * justo lo que hay que conservar.
182
+ */
183
+ read (cid, range) {
184
+ this.index.touch(cid)
185
+ return this.store.read(cid, range)
186
+ }
187
+
188
+ async remove (cid) {
189
+ await this.store.remove(cid)
190
+ this.index.remove(cid)
191
+ }
192
+
193
+ list () {
194
+ return this.index.list()
195
+ }
196
+
197
+ pin (cid, pinned = true) {
198
+ return this.index.setPinned(cid, pinned)
199
+ }
200
+
201
+ stats () {
202
+ const cached = this.index.cachedBytes()
203
+ return {
204
+ blobs: this.index.count(),
205
+ // `bytes` es lo que ocupa el disco (lo que mira la cuota); `totalBytes` es
206
+ // el inventario entero, que con bucket es mayor porque incluye lo desalojado.
207
+ bytes: cached,
208
+ totalBytes: this.index.totalBytes(),
209
+ maxBytes: this.maxBytes || null,
210
+ backed: this.store.backed === true,
211
+ dir: this.dir
212
+ }
213
+ }
214
+
215
+ /**
216
+ * GC: borra vencidos (ttl) siempre; si `needBytes`, además desaloja
217
+ * no-pineados más viejos hasta liberar esa cantidad.
218
+ * Síncrono sobre el índice; el borrado de disco es fire-and-forget seguro
219
+ * (los bytes huérfanos se re-borran en el próximo GC vía índice… el índice
220
+ * es la fuente de verdad de qué existe).
221
+ */
222
+ /**
223
+ * Libera espacio. Hace DOS cosas distintas y conviene no confundirlas (§15.11):
224
+ *
225
+ * · **Caducar** (`ttl` vencido) es borrar de verdad: se va la fila, los bytes
226
+ * locales y —si hay bucket— también los de allí. Es lo que se pidió al subir.
227
+ * · **Desalojar** por cuota es tirar una copia caliente. Con bucket detrás solo
228
+ * toca lo que el bucket YA confirmó (`remote = 1`) y **la fila se queda** con
229
+ * `cached = 0`: si se fuera, el blob quedaría en el bucket sin ACL, sin dueño
230
+ * y sin tipo. Sin bucket no hay segunda copia, así que desalojar ES destruir
231
+ * y se comporta como hasta ahora.
232
+ */
233
+ gc ({ needBytes = 0, now = Date.now() } = {}) {
234
+ let freed = 0
235
+ const backed = this.store.backed === true
236
+
237
+ const destroy = ({ cid, size }) => {
238
+ this.index.remove(cid)
239
+ this.store.remove(cid).catch(() => {})
240
+ freed += size
241
+ }
242
+ const evict = ({ cid, size }) => {
243
+ if (!backed) return destroy({ cid, size })
244
+ this.store.evict(cid).catch(() => {})
245
+ this.index.setCached(cid, false)
246
+ freed += size
247
+ }
248
+
249
+ for (const b of this.index.expired(now)) destroy(b)
250
+ if (needBytes > freed) {
251
+ for (const b of this.index.evictable({ requireRemote: backed })) {
252
+ if (freed >= needBytes) break
253
+ evict(b)
254
+ }
255
+ }
256
+ return { freed }
257
+ }
258
+
259
+ close () {
260
+ this.index?.close()
261
+ }
262
+ }