Vectrum
Sign In
345 lines · 12.2 KB · JavaScript
// 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);
  },
);