dotrino-content 0.2.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,177 @@
1
+ /**
2
+ * El almacén con BUCKET detrás (DISENO.md §15).
3
+ *
4
+ * Misma interfaz que `BlobStore` —`put`, `read`, `sizeOf`, `remove`— para que ni la API
5
+ * local, ni el plano de control, ni el puerto de vistas previas se enteren de dónde
6
+ * salen los bytes. Lo que cambia es de dónde salen:
7
+ *
8
+ * disco (caché) → si está, sale de aquí, y así es como se sirve casi siempre
9
+ * bucket → si no, se jala mientras se sirve y se deja la copia
10
+ *
11
+ * Tres cosas que no son detalles:
12
+ *
13
+ * · **Se escribe primero en local y se responde.** La subida al bucket va detrás, y
14
+ * hasta que el bucket confirma, ese blob NO es desalojable (`remote`, §15.11). Un
15
+ * pendiente que no logra subir se reintenta y se reporta; no se olvida.
16
+ * · **Público y privado son buckets distintos** y con credenciales distintas (§15.1).
17
+ * Cuál toca lo dice el ACL del blob, y quien lo sabe es el índice — por eso `put` y
18
+ * `upload` reciben `isPublic` en vez de adivinarlo.
19
+ * · **Una lectura parcial NO puebla la caché.** Cachear trozos sueltos dejaría
20
+ * agujeros que luego parecen un blob completo.
21
+ */
22
+ import { createWriteStream } from 'node:fs'
23
+ import { mkdir, rename, rm } from 'node:fs/promises'
24
+ import path from 'node:path'
25
+ import { Readable } from 'node:stream'
26
+ import { pipeline } from 'node:stream/promises'
27
+ import { PassThrough } from 'node:stream'
28
+ import { BlobStore, isValidCid } from './blobstore.js'
29
+ import { S3Bucket, S3Error } from './s3.js'
30
+
31
+ /** Lo que se le pone a un objeto público: es inmutable por construcción (§15.2). */
32
+ const IMMUTABLE = 'public, max-age=31536000, immutable'
33
+
34
+ /** Del `cid` a la clave del objeto. Sin prefijos: el bucket ya separa público de privado. */
35
+ const keyOf = (cid) => {
36
+ if (!isValidCid(cid)) throw new Error(`cid inválido: ${cid}`)
37
+ return cid
38
+ }
39
+
40
+ export class S3BlobStore {
41
+ /**
42
+ * @param {object} o
43
+ * @param {string} o.root directorio de la CACHÉ (el mismo árbol `blobs/` de siempre)
44
+ * @param {S3Bucket} o.priv bucket privado (obligatorio: es donde va todo lo cifrado)
45
+ * @param {S3Bucket|null} [o.pub] bucket público; sin él, lo público viaja por la red (§15.3)
46
+ * @param {(m:string)=>void} [o.log]
47
+ */
48
+ constructor ({ root, priv, pub = null, log = () => {} }) {
49
+ if (!priv) throw new Error('S3BlobStore: falta el bucket privado')
50
+ this.cache = new BlobStore(root)
51
+ this.priv = priv
52
+ this.pub = pub
53
+ this.log = log
54
+ /** Hay una segunda copia detrás: el GC puede desalojar en vez de destruir (§15.11). */
55
+ this.backed = true
56
+ }
57
+
58
+ async init () {
59
+ await this.cache.init()
60
+ return this
61
+ }
62
+
63
+ /** El bucket que le toca a un blob según su ACL. */
64
+ bucketFor (isPublic) {
65
+ if (!isPublic) return this.priv
66
+ if (!this.pub) throw new Error('este node no tiene bucket público (CONTENT_S3_BUCKET_PUBLIC)')
67
+ return this.pub
68
+ }
69
+
70
+ /** La URL pública de un blob, o `null` si este node no publica por bucket. */
71
+ urlFor (cid, baseUrl) {
72
+ return baseUrl ? `${String(baseUrl).replace(/\/+$/, '')}/${keyOf(cid)}` : null
73
+ }
74
+
75
+ pathFor (cid) { return this.cache.pathFor(cid) }
76
+
77
+ /** Guarda en la caché. La subida al bucket la lanza el node después (`upload`). */
78
+ async put (readable, opts) {
79
+ return this.cache.put(readable, opts)
80
+ }
81
+
82
+ /**
83
+ * Sube al bucket que le toca y **confirma**. Quien llama es el único que puede marcar
84
+ * `remote`, y solo con lo que devuelve esto: marcar al lanzar la subida es
85
+ * exactamente el error que deja perder contenido.
86
+ *
87
+ * @param {string} cid
88
+ * @param {{ size: number, mime?: string, public?: boolean }} meta
89
+ */
90
+ async upload (cid, { size, mime, public: isPublic = false }) {
91
+ const bucket = this.bucketFor(isPublic)
92
+ const body = this.cache.read(cid)
93
+ await bucket.put(keyOf(cid), Readable.toWeb(body), {
94
+ sha256: cid.slice('sha256-'.length),
95
+ size,
96
+ contentType: mime || 'application/octet-stream',
97
+ // Solo lo público lleva cabecera de caché: es lo único que sirve un CDN.
98
+ ...(isPublic ? { cacheControl: IMMUTABLE } : {})
99
+ })
100
+ return { cid, remote: true }
101
+ }
102
+
103
+ /** ¿Está en la caché de esta máquina? (no pregunta al bucket: eso cuesta una petición) */
104
+ async sizeOf (cid) {
105
+ return this.cache.sizeOf(cid)
106
+ }
107
+
108
+ /**
109
+ * Lee. Si está en la caché sale de ahí; si no, se jala del bucket **mientras se
110
+ * sirve** y se deja la copia — salvo que sea una lectura parcial.
111
+ *
112
+ * Devuelve un stream, como el de disco, para que quien llama no cambie.
113
+ *
114
+ * @param {string} cid
115
+ * @param {{ start: number, end: number }} [range] inclusivo
116
+ * @param {{ public?: boolean }} [opts] de qué bucket, si hay que ir a buscarlo
117
+ */
118
+ read (cid, range, { public: isPublic = false } = {}) {
119
+ const out = new PassThrough()
120
+ this.cache.sizeOf(cid).then(async (size) => {
121
+ if (size !== null) return pipeline(this.cache.read(cid, range), out)
122
+
123
+ const bucket = this.bucketFor(isPublic)
124
+ const { body } = await bucket.get(keyOf(cid), range || null)
125
+ const src = Readable.fromWeb(/** @type {any} */ (body))
126
+
127
+ // Parcial: se sirve y se olvida. Guardar un trozo dejaría en la caché algo que
128
+ // parece el blob entero y no lo es.
129
+ if (range) { src.pipe(out); return }
130
+
131
+ // Entero: el mismo stream va a DOS destinos —quien pidió y el disco—, y solo al
132
+ // terminar bien se pone en su sitio. Si la descarga se corta, no queda medio blob
133
+ // con el nombre bueno, que luego se serviría como si estuviera completo.
134
+ const dest = this.cache.pathFor(cid)
135
+ const tmp = dest + '.dl'
136
+ await mkdir(path.dirname(dest), { recursive: true })
137
+ const disk = createWriteStream(tmp)
138
+ src.pipe(out)
139
+ src.pipe(disk)
140
+ disk.on('finish', () => { rename(tmp, dest).catch(() => rm(tmp, { force: true })) })
141
+ src.on('error', () => { disk.destroy(); rm(tmp, { force: true }).catch(() => {}) })
142
+ }).catch((e) => out.destroy(e))
143
+ return out
144
+ }
145
+
146
+ /**
147
+ * Los primeros `n` bytes. Es lo que necesita el puerto público para comprobar el tipo
148
+ * REAL de una imagen por sus bytes mágicos (§7.2) — y existe en la interfaz porque
149
+ * con bucket detrás no hay ninguna ruta de disco que abrir.
150
+ */
151
+ async readHead (cid, n, { public: isPublic = false } = {}) {
152
+ const local = await this.cache.sizeOf(cid)
153
+ if (local !== null) return this.cache.readHead(cid, n)
154
+ return this.bucketFor(isPublic).head(keyOf(cid), n)
155
+ }
156
+
157
+ /** Suelta la copia local y deja la del bucket (desalojar, §15.11). */
158
+ async evict (cid) {
159
+ await this.cache.remove(cid)
160
+ }
161
+
162
+ /** Borra de verdad: de la caché y del bucket. Caducar y despublicar pasan por aquí. */
163
+ async remove (cid, { public: isPublic = null } = {}) {
164
+ await this.cache.remove(cid)
165
+ // Sin saber su ACL se borra de los dos: es idempotente y borrar lo que no está no
166
+ // es un error. Más vale una petición de más que dejar un objeto huérfano pagando.
167
+ const buckets = isPublic === null ? [this.priv, this.pub] : [this.bucketFor(isPublic)]
168
+ for (const b of buckets) {
169
+ if (!b) continue
170
+ try { await b.remove(keyOf(cid)) } catch (e) {
171
+ if (!(e instanceof S3Error) || e.status !== 404) throw e
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ export default { S3BlobStore }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Almacén de blobs direccionado por contenido (Fase 1, DISENO.md §3).
3
+ *
4
+ * - `cid = sha256-<hex>` (prefijo de algoritmo → extensible a blake3 después).
5
+ * Se usa SHA-256 de `node:crypto` porque no requiere dependencias nativas
6
+ * (el `.npmrc` del ecosistema bloquea los build scripts de npm).
7
+ * - Disco: `blobs/<aa>/<bb>/<cid>` (sharding por los 4 primeros hex del hash).
8
+ * - Escritura por streaming: se hashea MIENTRAS se escribe a un tmp y al final
9
+ * se renombra al path definitivo (dedup gratis: si ya existe, se descarta).
10
+ */
11
+ import { createHash, randomBytes } from 'node:crypto'
12
+ import { createWriteStream, createReadStream } from 'node:fs'
13
+ import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
14
+ import { pipeline } from 'node:stream/promises'
15
+ import { Transform } from 'node:stream'
16
+ import path from 'node:path'
17
+
18
+ const CID_RE = /^sha256-[0-9a-f]{64}$/
19
+
20
+ export function isValidCid (cid) {
21
+ return typeof cid === 'string' && CID_RE.test(cid)
22
+ }
23
+
24
+ export class BlobStore {
25
+ /** @param {string} root directorio raíz de datos (contiene blobs/ y tmp/) */
26
+ constructor (root) {
27
+ this.root = root
28
+ this.blobsDir = path.join(root, 'blobs')
29
+ this.tmpDir = path.join(root, 'tmp')
30
+ /**
31
+ * ¿Hay una segunda copia detrás (un bucket)? Aquí no: el disco es la única
32
+ * copia, así que soltar un blob es destruirlo y el GC se comporta como
33
+ * siempre (DISENO.md §15.11).
34
+ */
35
+ this.backed = false
36
+ }
37
+
38
+ /** Prepara los directorios. Devuelve `this` para poder encadenar, como el resto. */
39
+ async init () {
40
+ await mkdir(this.blobsDir, { recursive: true })
41
+ await mkdir(this.tmpDir, { recursive: true })
42
+ return this
43
+ }
44
+
45
+ /** Path en disco de un cid (no comprueba existencia). */
46
+ pathFor (cid) {
47
+ if (!isValidCid(cid)) throw new Error(`cid inválido: ${cid}`)
48
+ const hex = cid.slice('sha256-'.length)
49
+ return path.join(this.blobsDir, hex.slice(0, 2), hex.slice(2, 4), cid)
50
+ }
51
+
52
+ /** ¿Existe el blob en disco? → size o null. */
53
+ async sizeOf (cid) {
54
+ try {
55
+ const st = await stat(this.pathFor(cid))
56
+ return st.size
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Guarda un stream hasheando al vuelo.
64
+ * @param {import('node:stream').Readable} readable
65
+ * @param {{ maxBytes?: number }} [opts] límite duro de tamaño (corta el stream)
66
+ * @returns {Promise<{ cid: string, size: number, existed: boolean }>}
67
+ */
68
+ async put (readable, opts = {}) {
69
+ const tmp = path.join(this.tmpDir, `up-${randomBytes(8).toString('hex')}`)
70
+ const hash = createHash('sha256')
71
+ let size = 0
72
+ const meter = new Transform({
73
+ transform (chunk, _enc, cb) {
74
+ size += chunk.length
75
+ if (opts.maxBytes && size > opts.maxBytes) {
76
+ cb(Object.assign(new Error('blob demasiado grande'), { code: 'ETOOBIG' }))
77
+ return
78
+ }
79
+ hash.update(chunk)
80
+ cb(null, chunk)
81
+ }
82
+ })
83
+ try {
84
+ await pipeline(readable, meter, createWriteStream(tmp))
85
+ const cid = `sha256-${hash.digest('hex')}`
86
+ const dest = this.pathFor(cid)
87
+ if (await this.sizeOf(cid) !== null) {
88
+ // dedup: ya lo teníamos, descartar el tmp
89
+ await rm(tmp, { force: true })
90
+ return { cid, size, existed: true }
91
+ }
92
+ await mkdir(path.dirname(dest), { recursive: true })
93
+ await rename(tmp, dest)
94
+ return { cid, size, existed: false }
95
+ } catch (err) {
96
+ await rm(tmp, { force: true })
97
+ throw err
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Stream de lectura, con rango opcional [start, end] inclusivo.
103
+ * @returns {import('node:fs').ReadStream}
104
+ */
105
+ read (cid, range) {
106
+ const p = this.pathFor(cid)
107
+ return range
108
+ ? createReadStream(p, { start: range.start, end: range.end })
109
+ : createReadStream(p)
110
+ }
111
+
112
+ /**
113
+ * Los primeros `n` bytes de un blob. Existe para que el puerto público compruebe el
114
+ * tipo REAL de una imagen por sus bytes mágicos (§7.2) **sin abrir el archivo por su
115
+ * ruta**: con un bucket detrás no hay ruta que abrir, así que olfatear tiene que ser
116
+ * una operación del almacén y no de quien lo usa.
117
+ */
118
+ async readHead (cid, n) {
119
+ const fh = await open(this.pathFor(cid), 'r')
120
+ try {
121
+ const buf = Buffer.alloc(n)
122
+ const { bytesRead } = await fh.read(buf, 0, n, 0)
123
+ return buf.subarray(0, bytesRead)
124
+ } finally { await fh.close() }
125
+ }
126
+
127
+ async remove (cid) {
128
+ await rm(this.pathFor(cid), { force: true })
129
+ }
130
+
131
+ /**
132
+ * Suelta la copia LOCAL y deja la de atrás. Sin bucket detrás no hay copia de
133
+ * atrás, así que esto es exactamente `remove` — y por eso el node comprueba
134
+ * `backed` antes de llamarlo: con este backend, desalojar es destruir.
135
+ */
136
+ async evict (cid) {
137
+ return this.remove(cid)
138
+ }
139
+ }
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/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'