// Vectrum's schema. PocketBase applies this on start. The Next.js server is the only client and
// works as a superuser, so API rules are null (superusers only), except that a signed-in user may
// read their own record.
//
// Times are milliseconds since the epoch in number fields. PocketBase can't store null in number,
// text or relation fields, so 0 and "" stand for "not set".
const MS_PER_DAY = 86400e3;
const text = (name, opts = {}) => ({ type: "text", name, max: 1000, ...opts });
const number = (name, opts = {}) => ({ type: "number", name, onlyInt: true, ...opts });
const time = (name, opts = {}) => number(name, { min: 0, ...opts });
const select = (name, values, opts = {}) => ({ type: "select", name, values, maxSelect: 1, required: true, ...opts });
const json = (name, opts = {}) => ({ type: "json", name, maxSize: 1024 * 1024, ...opts });
const relation = (name, collection, opts = {}) => ({
type: "relation",
name,
collectionId: collection.id,
maxSelect: 1,
cascadeDelete: false,
...opts,
});
/** A required link to a parent record that is deleted along with it. */
const parent = (name, collection) => relation(name, collection, { required: true, cascadeDelete: true });
function create(app, name, fields, indexes = []) {
const collection = new Collection({
type: "base",
name,
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields,
indexes,
});
app.save(collection);
return collection;
}
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
users.listRule = null;
// Lets a user's own token read their record, which is how the server checks a session.
users.viewRule = "id = @request.auth.id";
users.createRule = null;
users.updateRule = null;
users.deleteRule = null;
users.authToken.duration = (30 * MS_PER_DAY) / 1000;
users.fields.add(new TextField({ name: "username", required: true, max: 39 }));
users.fields.add(new TextField({ name: "bio", max: 300 }));
users.indexes.push("CREATE UNIQUE INDEX idx_users_username ON users (username COLLATE NOCASE)");
app.save(users);
const repos = create(
app,
"repos",
[
parent("owner", users),
text("name", { required: true, max: 100 }),
text("description", { max: 350 }),
select("visibility", ["public", "private"]),
time("created_at", { required: true }),
time("pushed_at"),
// pushed_at, or created_at before the first push; lists are sorted by it.
time("activity_at"),
// An uploaded image; without one, the owner's avatar is shown.
{
type: "file",
name: "image",
maxSelect: 1,
maxSize: 1024 * 1024,
mimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
protected: true,
},
text("image_type", { max: 20 }),
time("image_updated_at"),
],
[
"CREATE UNIQUE INDEX idx_repos_owner_name ON repos (owner, name COLLATE NOCASE)",
"CREATE INDEX idx_repos_activity ON repos (activity_at)",
],
);
create(
app,
"collaborators",
[parent("repo", repos), parent("user", users), time("created_at", { required: true })],
["CREATE UNIQUE INDEX idx_collaborators ON collaborators (repo, user)", "CREATE INDEX idx_collaborators_user ON collaborators (user)"],
);
create(
app,
"ssh_keys",
[
parent("user", users),
text("title", { required: true, max: 100 }),
text("key_type", { required: true, max: 100 }),
// "ssh-ed25519 AAAA..." without the comment
text("public_key", { required: true, max: 20000 }),
// SHA256:...
text("fingerprint", { required: true, max: 100 }),
time("created_at", { required: true }),
time("last_used_at"),
],
["CREATE UNIQUE INDEX idx_ssh_keys_fingerprint ON ssh_keys (fingerprint)", "CREATE INDEX idx_ssh_keys_user ON ssh_keys (user)"],
);
create(
app,
"api_tokens",
[
parent("user", users),
text("name", { required: true, max: 100 }),
// sha256 of the token
text("token_hash", { required: true, max: 64 }),
// last 4 characters, shown in the UI
text("hint", { required: true, max: 10 }),
select("permission", ["read", "write"]),
{ type: "bool", name: "all_repos" },
// The repositories it covers, unless all_repos.
relation("repos", repos, { maxSelect: 1000 }),
time("expires_at"),
time("created_at", { required: true }),
time("last_used_at"),
],
["CREATE UNIQUE INDEX idx_api_tokens_hash ON api_tokens (token_hash)", "CREATE INDEX idx_api_tokens_user ON api_tokens (user)"],
);
const labels = create(
app,
"labels",
[
parent("repo", repos),
text("name", { required: true, max: 50 }),
// #rrggbb
text("color", { required: true, max: 7, pattern: "^#[0-9a-f]{6}$" }),
],
["CREATE UNIQUE INDEX idx_labels_repo_name ON labels (repo, name COLLATE NOCASE)"],
);
const issues = create(
app,
"issues",
[
parent("repo", repos),
number("number", { required: true, min: 1 }),
text("title", { required: true, max: 256 }),
text("body", { max: 65536 }),
parent("author", users),
// Follows status: done and canceled issues are closed.
select("state", ["open", "closed"]),
select("status", ["backlog", "todo", "in_progress", "in_review", "done", "canceled"]),
// 0 = no priority, 1 = urgent … 4 = low
number("priority", { min: 0, max: 4 }),
relation("assignee", users),
// 0 = no estimate
number("estimate", { min: 0 }),
// YYYY-MM-DD
text("due_date", { max: 10 }),
// Manual order on the board and in lists; new issues go to the top.
{ type: "number", name: "sort_order" },
relation("labels", labels, { maxSelect: 1000 }),
number("comment_count", { min: 0 }),
time("created_at", { required: true }),
time("updated_at", { required: true }),
],
["CREATE UNIQUE INDEX idx_issues_repo_number ON issues (repo, number)"],
);
// Property changes, shown in an issue's activity feed. Values are stored as they read at the
// time (status ids, priority numbers, usernames, label names).
create(
app,
"issue_events",
[
parent("issue", issues),
parent("actor", users),
text("field", { required: true, max: 50 }),
text("old_value", { max: 1000 }),
text("new_value", { max: 1000 }),
time("created_at", { required: true }),
],
["CREATE INDEX idx_issue_events_issue ON issue_events (issue)"],
);
create(
app,
"issue_comments",
[parent("issue", issues), parent("author", users), text("body", { required: true, max: 65536 }), time("created_at", { required: true })],
["CREATE INDEX idx_issue_comments_issue ON issue_comments (issue)"],
);
// Self-hosted runners that pick up workflow jobs. A runner serves one repository, or (with
// user) every repository that user owns.
const runners = create(
app,
"runners",
[
relation("repo", repos, { cascadeDelete: true }),
relation("user", users, { cascadeDelete: true }),
text("name", { required: true, max: 100 }),
// The labels set in the UI
json("labels"),
// sha256 of the runner token
text("token_hash", { required: true, max: 64 }),
text("hint", { required: true, max: 10 }),
// Reported by the runner when it polls
text("hostname", { max: 100 }),
text("os", { max: 20 }),
text("arch", { max: 20 }),
text("version", { max: 20 }),
time("created_at", { required: true }),
time("last_seen_at"),
],
[
"CREATE UNIQUE INDEX idx_runners_hash ON runners (token_hash)",
"CREATE INDEX idx_runners_repo ON runners (repo)",
"CREATE INDEX idx_runners_user ON runners (user)",
],
);
const runs = create(
app,
"workflow_runs",
[
parent("repo", repos),
number("number", { required: true, min: 1 }),
text("workflow_path", { required: true, max: 1000 }),
text("workflow_name", { required: true, max: 1000 }),
select("event", ["push", "workflow_dispatch"]),
// refs/heads/main, refs/tags/v1
text("ref", { required: true, max: 1000 }),
text("sha", { required: true, max: 64 }),
// The commit's subject
text("title", { max: 300 }),
relation("actor", users),
select("status", ["queued", "in_progress", "success", "failure", "cancelled"]),
// Why the workflow file couldn't run
text("error", { max: 10000 }),
time("created_at", { required: true }),
time("started_at"),
time("finished_at"),
],
[
"CREATE UNIQUE INDEX idx_workflow_runs_repo_number ON workflow_runs (repo, number)",
"CREATE INDEX idx_workflow_runs_repo_sha ON workflow_runs (repo, sha)",
],
);
const jobs = create(
app,
"workflow_jobs",
[
parent("run", runs),
// Where the job is defined in its workflow, for display order
number("position", { min: 0 }),
text("key", { required: true, max: 100 }),
text("name", { required: true, max: 1000 }),
// Required labels
json("runs_on"),
// Keys of the jobs it waits for
json("needs"),
// env, steps and timeout, as parsed from the workflow
json("spec", { maxSize: 4 * 1024 * 1024 }),
select("status", ["waiting", "queued", "in_progress", "success", "failure", "cancelled", "skipped"]),
relation("runner", runners),
text("runner_name", { max: 100 }),
// sha256 of the job's checkout token while it runs
text("token_hash", { max: 64 }),
{ type: "bool", name: "cancel_requested" },
time("created_at", { required: true }),
time("started_at"),
time("finished_at"),
time("heartbeat_at"),
],
[
"CREATE UNIQUE INDEX idx_workflow_jobs_run_key ON workflow_jobs (run, key)",
"CREATE UNIQUE INDEX idx_workflow_jobs_token ON workflow_jobs (token_hash) WHERE token_hash != ''",
"CREATE INDEX idx_workflow_jobs_status ON workflow_jobs (status)",
"CREATE INDEX idx_workflow_jobs_runner ON workflow_jobs (runner)",
],
);
create(
app,
"job_steps",
[
parent("job", jobs),
// 0 is the checkout
number("idx", { min: 0 }),
text("name", { required: true, max: 1000 }),
select("status", ["pending", "in_progress", "success", "failure", "cancelled", "skipped"]),
// A number, or null until the step exits
json("exit_code", { maxSize: 100 }),
time("started_at"),
time("finished_at"),
// Characters of output stored for the step
number("log_size", { min: 0 }),
],
["CREATE UNIQUE INDEX idx_job_steps_job_idx ON job_steps (job, idx)"],
);
// Step output as the runner sends it, in chunks.
create(
app,
"job_logs",
[parent("job", jobs), number("step", { min: 0 }), text("text", { required: true, max: 4 * 1024 * 1024 + 1000 })],
["CREATE INDEX idx_job_logs_job ON job_logs (job, step)"],
);
// Creating a run with its jobs and steps is one batch request.
const settings = app.settings();
settings.batch.enabled = true;
settings.batch.maxRequests = 5000;
settings.batch.timeout = 30;
app.save(settings);
},
(app) => {
for (const name of [
"job_logs",
"job_steps",
"workflow_jobs",
"workflow_runs",
"runners",
"issue_comments",
"issue_events",
"issues",
"labels",
"api_tokens",
"ssh_keys",
"collaborators",
"repos",
]) {
app.delete(app.findCollectionByNameOrId(name));
}
const users = app.findCollectionByNameOrId("users");
users.fields.removeByName("username");
users.fields.removeByName("bio");
users.indexes = users.indexes.filter((i) => !i.includes("idx_users_username"));
app.save(users);
},
);