El servidor Esquema de la base de datos
Esquema de la base de datos
consejo: cinco tablas, nada que migrar a mano, y modelos para Prisma
En esta página
- 1Las tablas
- 2En Postgres
- 3Una entrada, tal como se guarda
- 4Los campos de un archivo
- 5Ajustes, por nombre
- 6Una línea de actividad
- 7Por qué está organizado así
- 8Con Prisma ORM
- 9Un solo pool para Prisma y el drive
- 10Migraciones con Prisma
- 11Consultas con Prisma
- 12Consultas propias
- 13Crearlas tú mismo
- 14Un almacén propio
Las tablas
Cinco, con el nombre de la tabla de entradas: rebofiles_entries salvo que postgresMetadata reciba { table }. sqliteMetadata crea las mismas en un archivo, llamadas rebofiles_*, con el texto en una tabla de búsqueda de texto completo de SQLite (FTS5).
| Tabla | Una fila es | Se escribe cuando |
|---|---|---|
| rebofiles_entries | Una carpeta o un archivo, entero, como JSON | Cada cambio, solo las filas que toca |
| …_settings | Un ajuste por su nombre, como JSON | Se guardan ajustes; se crea un enlace con contraseña; se mira Compartido conmigo |
| …_revision | Una fila: un número | Sube con cada cambio, en la misma sentencia |
| …_activity | Una línea de lo que se hizo | Tras cada cambio, una línea por cada cosa que hizo |
| …_text | Lo que está escrito en un archivo | Se sube un archivo y la página leyó su texto |
En Postgres
Tal como las crea postgresMetadata, bajo un bloqueo de la base de datos para que dos procesos que arrancan a la vez no lo intenten ambos.
-- The entries: one row each, the whole entry as JSON.
CREATE TABLE rebofiles_entries (
id text PRIMARY KEY,
parent text, -- the folder it is in; null at the top
entry jsonb NOT NULL -- the entry itself: see "An entry, as it is kept"
);
CREATE INDEX rebofiles_entries_parent ON rebofiles_entries (parent);
-- Settings by name: the drive's, each person's, the passwords of links.
CREATE TABLE rebofiles_entries_settings (
name text PRIMARY KEY,
value jsonb NOT NULL
);
-- One row, one number, one more at every change by any process.
CREATE TABLE rebofiles_entries_revision (
id int PRIMARY KEY CHECK (id = 1),
n bigint NOT NULL
);
INSERT INTO rebofiles_entries_revision (id, n) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
-- What was done: a line each, found by the entry and the folders it is about.
CREATE TABLE rebofiles_entries_activity (
seq bigserial PRIMARY KEY,
entry text NOT NULL, -- what it was done to
parent text, -- the folder it is in (for one moved, the one it went to)
origin text, -- for one moved, the folder it left
line jsonb NOT NULL
);
CREATE INDEX rebofiles_entries_activity_entry ON rebofiles_entries_activity (entry, seq);
CREATE INDEX rebofiles_entries_activity_parent ON rebofiles_entries_activity (parent, seq);
CREATE INDEX rebofiles_entries_activity_origin ON rebofiles_entries_activity (origin, seq);
-- What is written in each file, to find it by: the version it is of, and its words.
CREATE TABLE rebofiles_entries_text (
id text PRIMARY KEY, -- the file
version text NOT NULL, -- the version the text is of; another one's is not found
body text NOT NULL, -- the first 100,000 characters
words tsvector NOT NULL -- to_tsvector('simple', …) of the words, without case or accents
);
CREATE INDEX rebofiles_entries_text_words ON rebofiles_entries_text USING gin (words);Una entrada, tal como se guarda
La columna entry es la entrada de @rebofiles/core tal cual: un archivo con sus versiones, comparticiones y enlaces; una carpeta con su color.
{
"kind": "file",
"id": "5b0c…",
"name": "contract.docx",
"parent": "a1f3…", // the folder; null at the top
"owner": "ana", // the id identify() gave
"created": "2026-10-20T09:12:00.000Z",
"modified": "2026-10-26T16:40:00.000Z",
"modifiedBy": "bea",
"type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"versions": [ // the newest first; never empty
{
"id": "v2…", "key": "5b0c…/v2…", // where the bytes are in the bucket
"size": 48213, "at": "2026-10-26T16:40:00.000Z", "by": "bea",
"sha256": "9f86d0…", // to tell the same file uploaded again
"placeholder": "data:image/jpeg;base64,/9j/4AAQ…",
"renditions": [ // its first page, or a picture, made smaller
{ "size": 480, "width": 480, "height": 679, "key": "5b0c…/v2…@480", "type": "image/jpeg", "bytes": 31877 }
]
},
{ "id": "v1…", "key": "5b0c…/v1…", "size": 47990, "at": "2026-10-20T09:12:00.000Z", "by": "ana" }
],
"shares": [{ "with": "bea", "role": "editor", "by": "ana", "at": "2026-10-21T10:00:00.000Z" }],
"links": [
{ "id": "l9…", "role": "viewer", "expires": "2026-11-02T00:00:00.000Z", "locked": true, "uses": 1, "used": 0, "by": "ana", "at": "2026-10-26T17:00:00.000Z" }
]
}{
"kind": "folder", "id": "a1f3…", "name": "Clients", "parent": null, "color": "teal",
"owner": "ana", "created": "…", "modified": "…", "modifiedBy": "ana",
"trashed": { "at": "2026-10-26T18:00:00.000Z", "by": "ana" } // only on what was put in the trash itself
}idyparenttambién son columnas, para el índice de lo que hay en una carpeta; el resto solo está en el JSON.- La clave de una versión es el id de su archivo y el suyo, nunca una ruta: renombrar y mover nunca tocan el bucket.
- Lo que hay dentro de una carpeta en la papelera está en la papelera a través de ella: solo la carpeta tiene
trashed. Con las comparticiones pasa lo mismo: lo que hay dentro de una carpeta compartida se comparte a través de ella. - La contraseña de un enlace no está aquí:
locked: truedice que tiene una, y la guardan los ajustes.
Los campos de un archivo
Los metadatos de cada archivo y carpeta son la columna entry de rebofiles_entries: no hay otra tabla de archivos. Todos sus campos:
| Campo | Tipo | Qué es |
|---|---|---|
| kind | 'file' | 'folder' | Qué es. |
| id | string | Su id, un UUID salvo que newId diga otra cosa; también la columna id. |
| name | string | Como lo lee la persona, con la extensión; nunca dos veces en una carpeta, sin importar mayúsculas ni acentos. |
| parent | string | null | La carpeta en la que está; null arriba del todo. También la columna parent. |
| owner | string | El id que identify() dio a quien lo creó; arriba del todo, en los Mis archivos de quién está. |
| created | ISO 8601 | Cuándo se creó o se subió por primera vez. |
| modified, modifiedBy | ISO 8601, string | Su último cambio de nombre, de lugar o de bytes, y de quién. Un color o una compartición no cuentan. |
| type | string | De un archivo: su tipo de medio, como lo dijo la subida (application/pdf, image/png). |
| versions | Version[] | De un archivo: sus versiones, la más nueva primero, nunca vacío. versions[0] es el archivo tal como está ahora. |
| color | FolderColor | De una carpeta: red, orange, yellow, green, teal, blue, purple, pink o gray; se omite para el propio del drive. |
| trashed | { at, by } | Solo en lo que se mandó a la papelera directamente; lo que tiene dentro está en la papelera a través de ello. |
| shares | Share[] | Con quién más se comparte: { with, role, by, at }, con "*" para todos los que tienen sesión. |
| links | Link[] | Sus enlaces: { id, role, expires, locked?, uses?, used?, spent?, by, at }. |
Y de cada una de sus versiones:
| Campo | Tipo | Qué es |
|---|---|---|
| id | string | Su id. |
| key | string | Dónde están sus bytes en el bucket: el id del archivo y el suyo, "<file>/<version>". Nunca una ruta. |
| size | number | En bytes, como los comprobó el servidor al terminar la subida. |
| at, by | ISO 8601, string | Cuándo se subió, y quién. |
| sha256 | string | El SHA-256 de sus bytes en hex, como lo dijo la página (archivos de hasta 64 MiB): para reconocer el mismo archivo subido otra vez. |
| placeholder | string | De una imagen o un documento de Word: una URL data: de unos cientos de bytes, mostrada difuminada mientras llega. |
| renditions | Rendition[] | Copias más pequeñas: { size, width, height, key, type, bytes }, con la clave "<file>/<version>@<size>". |
| restoredFrom | string | De una hecha al recuperar una versión antigua: cuál. |
| note | string | Una nota sobre ella, si se dio. |
Aún no se guarda: el ancho y el alto de la imagen original (solo los de sus copias reducidas), lo que una cámara escribe en una foto (EXIF), la duración de un vídeo o una canción, y las páginas de un documento. Una tabla propia al lado puede guardarlos, por el id del archivo.
Ajustes, por nombre
| Nombre | Valor |
|---|---|
| drive | { language, theme, brand, images: { enabled, sizes, format, quality }, quota, trashDays, company, requestTemplates }: los del drive, tal como los guardó quien puede configurarlo. |
| person:<id> | { language, theme }: los propios de una persona, por encima de los del drive. |
| link:<link id> | "salt:hash": la contraseña de un enlace, tal como la hace scrypt (32 bytes, una sal de 16), nunca la contraseña. |
| seen-shared:<id> | Cuándo miró la persona Compartido conmigo por última vez, en ISO 8601: lo más reciente es nuevo. |
Una línea de actividad
La columna line; entry, parent y origin van al lado, para encontrar las líneas de un archivo y de lo que hay en una carpeta.
{
"at": "2026-10-26T16:41:00.000Z", "by": "bea",
"action": "renamed", // created, uploaded, version, renamed, moved, trashed, restored,
// deleted, shared, unshared, linked, unlinked, colored
"entry": "5b0c…", "name": "contract-final.docx", "kind": "file",
"parent": "a1f3…",
"was": "contract.docx" // for one renamed; "from" for one moved; "with", "role" for one shared
}Por qué está organizado así
- Una fila, una entrada. El drive se lee entero una vez y se guarda en memoria, y solo se vuelve a leer cuando la revisión dice que cambió, así que a la base de datos se le pregunta por id, nunca con joins: un listado de 20 000 entradas tarda menos de un milisegundo.
- Solo se escribe lo que cambió. Renombrar escribe una fila; mover una carpeta escribe la carpeta, no lo que contiene, porque sus hijos la nombran por id.
- Todo o nada. En Postgres un cambio es una sola sentencia: las filas quitadas, las puestas y la revisión, juntas. Con un pool, cada cambio también mantiene
pg_advisory_xact_lock, así que dos servidores nunca cambian el drive a la vez. - Sin claves foráneas. El modelo comprueba el árbol (una carpeta nunca dentro de sí misma, un nombre nunca dos veces en una carpeta) antes de escribir nada; la base de datos solo lo guarda.
- Suficiente para un equipo y mucho más; no para millones de entradas, que necesitarían preguntar al almacén por carpeta.
Con Prisma ORM
Para una aplicación que lleva sus propias tablas con Prisma 7: las del drive, como modelos de su schema.prisma, para leerlas con Prisma y que sus migraciones no las toquen.
// What `prisma db pull` reads from the tables ReboFiles made, named as an application names its models.
// `prisma migrate diff` between it and the database: an empty migration.
/// A folder or a file of the drive: the whole entry, as @rebofiles/core has it, in `entry`.
model DriveEntry {
id String @id
parent String?
entry Json
@@index([parent], map: "rebofiles_entries_parent")
@@map("rebofiles_entries")
}
/// The drive's settings, each person's, the passwords of links: by name.
model DriveSetting {
name String @id
value Json
@@map("rebofiles_entries_settings")
}
/// One row, one number: one more at every change. The table has CHECK (id = 1), which Prisma does not model.
model DriveRevision {
id Int @id
n BigInt
@@map("rebofiles_entries_revision")
}
/// What was done, a line each.
model DriveActivity {
seq BigInt @id @default(autoincrement())
entry String
parent String?
origin String?
line Json
@@index([entry, seq], map: "rebofiles_entries_activity_entry")
@@index([parent, seq], map: "rebofiles_entries_activity_parent")
@@index([origin, seq], map: "rebofiles_entries_activity_origin")
@@map("rebofiles_entries_activity")
}
/// What is written in each file, to find it by.
model DriveText {
id String @id
version String
body String
words Unsupported("tsvector")
@@index([words], map: "rebofiles_entries_text_words", type: Gin)
@@map("rebofiles_entries_text")
}@@mapconserva los nombres propios de las tablas; conpostgresMetadata(pool, { table: 'acme_files' }), mapea a esos en su lugar.wordses untsvector, que Prisma lee comoUnsupported: el modelo del texto se lee en SQL, y solo lo escribe el drive.prisma db pullavisa delCHECK (id = 1)de la revisión: sigue en la tabla, solo que Prisma no lo modela.
Un solo pool para Prisma y el drive
Prisma 7 habla con Postgres a través de @prisma/adapter-pg, que recibe un pg.Pool: dale el mismo a postgresMetadata.
// lib/db.ts: one pool, for your Prisma and for the drive.
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { PrismaClient } from '@/lib/generated/prisma/client';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
// app/api/files/[...path]/route.ts
import { byRoles, createDriveHandler, postgresMetadata, s3OptionsFrom, s3Storage } from '@rebofiles/server';
import { pool } from '@/lib/db';
const handle = createDriveHandler({
storage: s3Storage(s3OptionsFrom(process.env)),
metadata: await postgresMetadata(pool), // a pool: each change holds a lock of the database
identify, authorize: byRoles, secret: process.env.REBOFILES_SECRET!, base: '/api/files',
});Migraciones con Prisma
Prisma Migrate toma las tablas que no conoce por drift, y ofrece reiniciar la base de datos, borrándolo todo: pon las tablas del drive en el esquema, y márcalas como creadas.
# The tables are there already, made by ReboFiles: add the models, then tell Prisma they exist.
npx prisma db pull # or paste the models above into schema.prisma
mkdir -p prisma/migrations/0_rebofiles
npx prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script > prisma/migrations/0_rebofiles/migration.sql
npx prisma migrate resolve --applied 0_rebofiles
# From then on, migrate dev leaves them as they are: its next migration about them is empty.
npx prisma migrate dev# A new database: your migrations make the tables first, ReboFiles finds them when it starts.
npx prisma migrate deployFunciona en cualquier orden. ReboFiles crea sus tablas con IF NOT EXISTS y su única fila de la revisión con ON CONFLICT DO NOTHING: las tablas que Prisma creó antes se toman tal como están.
Consultas con Prisma
Lee el drive con el cliente de Prisma y rutas JSON, y en SQL para lo que no pueden expresar. Cámbialo a través de las rutas del drive, para que la revisión suba y el árbol siga entero.
import { Prisma } from '@/lib/generated/prisma/client';
import type { Entry } from '@rebofiles/core';
import { prisma } from '@/lib/db';
// Ana's files and folders.
const anas = await prisma.driveEntry.findMany({ where: { entry: { path: ['owner'], equals: 'ana' } } });
const names = anas.map((row) => (row.entry as unknown as Entry).name);
// What is in a folder; the folders at the top.
const inside = await prisma.driveEntry.findMany({ where: { parent: folderId } });
const top = await prisma.driveEntry.findMany({ where: { parent: null, entry: { path: ['kind'], equals: 'folder' } } });
// What was done to it, and in it.
const lines = await prisma.driveActivity.findMany({
where: { OR: [{ entry: folderId }, { parent: folderId }] },
orderBy: { seq: 'desc' },
take: 20,
});
// A person's own settings.
const mine = await prisma.driveSetting.findUnique({ where: { name: 'person:ana' } });
// The bytes each person keeps: what JSON paths cannot say, in SQL.
const bytes = await prisma.$queryRaw<{ owner: string; bytes: number }[]>(Prisma.sql`
SELECT entry->>'owner' AS owner, sum((v->>'size')::bigint)::float8 AS bytes
FROM rebofiles_entries, jsonb_array_elements(entry->'versions') v
GROUP BY 1`);
// Files by what is written in them: words is Unsupported("tsvector"), so in SQL too.
const found = await prisma.$queryRaw<{ id: string; body: string }[]>(Prisma.sql`
SELECT id, body FROM rebofiles_entries_text WHERE words @@ to_tsquery('simple', ${'payment & 30'})`);Consultas propias
Para un informe, una exportación o un job: léelo como quieras. Cámbialo a través del servidor o de @rebofiles/core, para que la revisión suba y el árbol siga entero.
-- Each person's files, and the bytes they keep (every version counts, as the quota counts them).
SELECT entry->>'owner' AS owner,
count(*) AS files,
sum((SELECT sum((v->>'size')::bigint) FROM jsonb_array_elements(entry->'versions') v)) AS bytes
FROM rebofiles_entries
WHERE entry->>'kind' = 'file'
GROUP BY 1 ORDER BY bytes DESC;
-- What is shared with Bea, directly.
SELECT entry->>'name', s->>'role'
FROM rebofiles_entries, jsonb_array_elements(entry->'shares') s
WHERE s->>'with' IN ('bea', '*');
-- What was put in the trash before a date.
SELECT id, entry->>'name' FROM rebofiles_entries WHERE (entry->'trashed'->>'at') < '2026-10-01';
-- What Bea did this week.
SELECT line FROM rebofiles_entries_activity WHERE line->>'by' = 'bea' AND line->>'at' > '2026-10-20' ORDER BY seq DESC;
-- An index for a query you make often, as for the owner:
CREATE INDEX rebofiles_entries_owner ON rebofiles_entries ((entry->>'owner'));Escribir en las tablas a mano no lo ve un proceso que tiene el drive en memoria hasta que sube la revisión: súbela en la misma transacción, o cambia el drive a través de las rutas.
Crearlas tú mismo
- El usuario con el que se conecta el servidor necesita crear tablas la primera vez, y leerlas y escribirlas después. Para crearlas tú, con tus propias migraciones, ejecuta antes el DDL de arriba:
IF NOT EXISTSlas deja como están. postgresMetadata(pool, { table: 'acme_files' })las llamaacme_files,acme_files_settings… junto a las tablas propias de tu aplicación, en su esquema.- Una nueva versión de ReboFiles que necesite una tabla o una columna más la crea igual, en su primer arranque.
Un almacén propio
Otra base de datos, o tablas propias organizadas de otra forma: dos métodos, y el resto opcional.
import type { MetadataStore } from '@rebofiles/server';
export const myStore: MetadataStore = {
async load() { /* every entry, as driveOf(entries) */ },
async save({ put, removed }) { /* upsert put by id, delete removed: in one transaction */ },
// Optional; without them the server keeps each in the process.
async revision() { /* a value that changes with every save, by any process */ },
async lock(work) { /* work() while no other process saves */ },
async getSetting(name) {}, async setSetting(name, value) {},
async record(lines) {}, async activity(id, limit) {},
async keepText(id, version, text) {}, async findText(query, limit) {},
};save recibe las entradas puestas y los ids quitados en cada cambio, de changes(before, after) de core. Metadatos explica para qué sirve cada almacén.