// 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); }, );