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.
- package/README.md +173 -0
- package/bin/cli.js +203 -0
- package/package.json +57 -0
- package/src/agent.js +104 -0
- package/src/announce.js +132 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/index.js +3 -0
- package/src/node.js +243 -0
- package/src/ops.js +207 -0
- package/src/public.js +370 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +144 -0
|
@@ -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 }
|
package/src/blobstore.js
ADDED
|
@@ -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