服务器 数据库结构
数据库结构
提示:五张表,无需手动迁移,还提供 Prisma 模型
本页内容
表
共五张,以条目表的名称命名:默认为 rebofiles_entries,除非为 postgresMetadata 传入 { table }。sqliteMetadata 会在一个文件中创建同样的表,命名为 rebofiles_*,文字存放在 SQLite 的全文搜索表(FTS5)中。
| 表 | 每行是 | 写入时机 |
|---|---|---|
| rebofiles_entries | 一个完整的文件夹或文件,以 JSON 形式 | 每次改动,只写入涉及的行 |
| …_settings | 按名称存放的一项设置,以 JSON 形式 | 保存设置时;创建带密码的链接时;查看“与我共享”时 |
| …_revision | 只有一行:一个数字 | 随每次改动递增,与改动在同一条语句中 |
| …_activity | 一条操作记录 | 每次改动后,为其中的每个操作写一行 |
| …_text | 文件中的文字 | 上传文件且页面读取了其中的文字时 |
在 Postgres 中
由 postgresMetadata 创建,期间持有数据库锁,避免两个同时启动的进程重复建表。
-- 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);条目的保存形式
entry 列就是 @rebofiles/core 中的条目本身:文件包含其版本、共享和链接;文件夹包含其颜色。
{
"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
}id和parent同时也是列,用于索引文件夹中的内容;其余字段只存在于 JSON 中。- 版本的键由文件 id 和版本自己的 id 组成,绝不是路径:重命名和移动从不触及存储桶。
- 回收站中文件夹里的内容是随文件夹一起进入回收站的:只有文件夹本身带有
trashed。共享也是如此:共享文件夹中的内容是随文件夹一起共享的。 - 链接的密码不在这里:
locked: true表示它设置了密码,密码保存在设置中。
文件的字段
每个文件和文件夹的元数据就是 rebofiles_entries 的 entry 列:没有其他文件表。它的全部字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| kind | 'file' | 'folder' | 它是什么。 |
| id | string | 它的 id,默认为 UUID,除非 newId 另有规定;同时也是 id 列。 |
| name | string | 用户看到的名称,包含扩展名;同一文件夹中不会重复,不区分大小写和重音。 |
| parent | string | null | 它所在的文件夹;根目录为 null。同时也是 parent 列。 |
| owner | string | identify() 为创建者提供的 id;在根目录时,表示它位于谁的“我的文件”中。 |
| created | ISO 8601 | 创建或首次上传的时间。 |
| modified, modifiedBy | ISO 8601, string | 最近一次修改名称、位置或内容的时间及修改人。更改颜色或共享不计入。 |
| type | string | 文件的媒体类型,与上传时声明的一致(application/pdf、image/png)。 |
| versions | Version[] | 文件的各个版本,最新的在前,永不为空。versions[0] 就是文件的当前状态。 |
| color | FolderColor | 文件夹的颜色:red、orange、yellow、green、teal、blue、purple、pink 或 gray;使用网盘默认颜色时省略。 |
| trashed | { at, by } | 只出现在被直接移入回收站的条目上;其中的内容是随它一起进入回收站的。 |
| shares | Share[] | 还与谁共享:{ with, role, by, at },"*" 表示所有已登录用户。 |
| links | Link[] | 它的链接:{ id, role, expires, locked?, uses?, used?, spent?, by, at }。 |
以及每个版本的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 它的 id。 |
| key | string | 内容在存储桶中的位置:文件的 id 加上版本自己的 id,即 "<file>/<version>"。绝不是路径。 |
| size | number | 字节数,以上传完成时服务器核对的结果为准。 |
| at, by | ISO 8601, string | 上传时间及上传人。 |
| sha256 | string | 内容的 SHA-256(十六进制),由页面计算(适用于 64 MiB 以内的文件):用于识别再次上传的同一文件。 |
| placeholder | string | 图片或 Word 文档的占位图:几百字节的 data: URL,加载时以模糊方式显示。 |
| renditions | Rendition[] | 缩小的副本:{ size, width, height, key, type, bytes },键为 "<file>/<version>@<size>"。 |
| restoredFrom | string | 通过恢复旧版本创建的版本:恢复自哪个版本。 |
| note | string | 版本备注(如果有)。 |
目前尚未保存: 原图的宽度和高度(只有缩小副本的)、相机写入照片的信息(EXIF)、视频或音乐的时长,以及文档的页数。你可以在旁边建一张自己的表,按文件 id 保存这些信息。
按名称保存的设置
| 名称 | 值 |
|---|---|
| drive | { language, theme, brand, images: { enabled, sizes, format, quality }, quota, trashDays, company, requestTemplates }:网盘的设置,由有权配置的人保存。 |
| person:<id> | { language, theme }:某个人自己的设置,优先于网盘设置。 |
| link:<link id> | "salt:hash":经 scrypt 处理的链接密码(32 字节,16 字节盐值),绝不保存密码原文。 |
| seen-shared:<id> | 此人上次查看“与我共享”的时间(ISO 8601):比它新的就是新内容。 |
一条动态记录
line 列;旁边还有 entry、parent 和 origin 列,用于查找某个文件以及某个文件夹内的记录。
{
"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
}为什么这样设计
- 一行一个条目。网盘一次性完整读取并保存在内存中,只有当修订号表明有改动时才重新读取,因此对数据库只按 id 查询,从不做连接:列出 20,000 个条目不到一毫秒。
- 只写入改动的部分。重命名只写一行;移动文件夹只写文件夹本身,不写其中的内容,因为子项是按 id 引用它的。
- 要么全部完成,要么完全不做。在 Postgres 中,一次改动就是一条语句:删除的行、写入的行和修订号一起提交。传入连接池时,每次改动还会持有
pg_advisory_xact_lock,因此两台服务器永远不会同时修改网盘。 - 没有外键。模型在写入之前检查目录树(文件夹不会位于自身之内,同一文件夹中不会有重名);数据库只负责保存。
- 足以支撑一个团队乃至更大规模;但不适用于数百万个条目,那需要按文件夹查询存储。
配合 Prisma ORM
适用于用 Prisma 7 管理自己数据表的应用:把网盘的表作为 schema.prisma 中的模型,用 Prisma 读取,并避免它的迁移触及这些表。
// 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")
}@@map保留表本身的名称;如果使用postgresMetadata(pool, { table: 'acme_files' }),请改为映射到那些表名。words是tsvector类型,Prisma 将其识别为Unsupported:文字表的模型需要用 SQL 读取,并且只由网盘写入。prisma db pull会提示修订表上的CHECK (id = 1):它仍保留在表中,只是 Prisma 不对其建模。
Prisma 与网盘共用一个连接池
Prisma 7 通过 @prisma/adapter-pg 连接 Postgres,它接受一个 pg.Pool:把同一个连接池也传给 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',
});使用 Prisma 迁移
Prisma Migrate 会把它不认识的表视为偏差(drift),并提议重置数据库、清空其中的一切:请把网盘的表写进 schema,并将它们标记为已创建。
# 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 deploy两种顺序都可以。 ReboFiles 用 IF NOT EXISTS 建表,用 ON CONFLICT DO NOTHING 写入修订表的唯一一行:Prisma 先建好的表会被原样沿用。
使用 Prisma 查询
用 Prisma 客户端和 JSON 路径读取网盘,无法表达的部分用 SQL。修改请通过网盘的路由进行,这样修订号会递增,目录树也保持完整。
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'})`);你自己的查询
用于报表、导出或后台任务:随意读取。修改请通过服务器或 @rebofiles/core 进行,这样修订号会递增,目录树也保持完整。
-- 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'));手动写入表 在修订号递增之前,已在内存中持有网盘的进程是看不到的:请在同一事务中递增修订号,或通过路由修改网盘。
自行建表
- 服务器连接数据库所用的用户,第一次需要有建表权限,之后需要读写权限。如果你想用自己的迁移来建表,请先执行上面的 DDL:
IF NOT EXISTS会让已有的表保持不变。 postgresMetadata(pool, { table: 'acme_files' })会把表命名为acme_files、acme_files_settings……与你应用自己的表放在同一个 schema 中。- 如果 ReboFiles 的新版本需要新增表或列,也会在首次启动时以同样的方式创建。
你自己的存储
换一种数据库,或以不同方式组织你自己的表:只需两个方法,其余都是可选的。
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 会收到每次改动中写入的条目和删除的 id,来自 core 的 changes(before, after)。元数据一页介绍了每种存储的用途。