Projects: pinned and recent projects, per person per workspace
Each person pins up to eight projects in a workspace, in an order they choose, and the projects they open are remembered (their latest 100), so a workspace's sidebar can show their pins and then what they opened last instead of every project. Kept by project id in the projects service's D1 (migration 0008_pins.sql), so renames and transfers keep them; projects that are hidden or gone are left out, and purged ones take their pins and visits with them. New RPC methods: shortcuts, pin, unpin, reorder_pins and visited. A person's own only: workspace and agent tokens are refused.
6 files+287−00/6 viewed
| 638 | 638 | addDependency: (actor, workspace, slug, on, as) => call("add_dependency", { actor, workspace, slug, on, as }), | |
| 639 | 639 | removeDependency: (actor, workspace, slug, on) => call("remove_dependency", { actor, workspace, slug, on }), | |
| 640 | 640 | graph: (projectId) => call("graph", { projectId }), | |
| 641 | + | shortcuts: (workspace, viewer) => call("shortcuts", { workspace, viewer }), | |
| 642 | + | pin: (actor, workspace, slug, position) => call("pin", { actor, workspace, slug, position: position ?? null }), | |
| 643 | + | unpin: (actor, workspace, slug) => call("unpin", { actor, workspace, slug }), | |
| 644 | + | reorderPins: (actor, workspace, slugs) => call("reorder_pins", { actor, workspace, slugs }), | |
| 645 | + | visited: (actor, projectId) => call("visited", { actor, projectId }), | |
| 641 | 646 | }; | |
| 642 | 647 | } |
| 100 | 100 | usedBy: { id: string; slug: string; workspace: string; as: string | null }[]; | |
| 101 | 101 | }; | |
| 102 | 102 | ||
| 103 | + | /** | |
| 104 | + | * What a person keeps at hand in a workspace: the projects they pinned, in | |
| 105 | + | * their order, then the ones they opened last that they have not pinned. | |
| 106 | + | * Only projects they can still see. | |
| 107 | + | */ | |
| 108 | + | export type ProjectShortcuts = { pinned: Project[]; recent: Project[] }; | |
| 109 | + | ||
| 103 | 110 | export interface ProjectsApi { | |
| 104 | 111 | /** A workspace's projects, by name. Members, or anyone for public repositories. */ | |
| 105 | 112 | list(workspace: string, viewer: Viewer): Promise<Result<Project[]>>; | |
| 128 | 135 | removeDependency(actor: User, workspace: string, slug: string, on: string): Promise<Result<Dependencies>>; | |
| 129 | 136 | /** For services: a project's dependencies by id. */ | |
| 130 | 137 | graph(projectId: string): Promise<ProjectGraph>; | |
| 138 | + | /** A person's pinned and recent projects in a workspace. Empty for anyone else. */ | |
| 139 | + | shortcuts(workspace: string, viewer: Viewer): Promise<ProjectShortcuts>; | |
| 140 | + | /** | |
| 141 | + | * Pins a project the person can see, at `position` (0 first) or at the | |
| 142 | + | * end; pinning one already pinned moves it. A person's own, at most 8 a | |
| 143 | + | * workspace. Returns their pins, in order. | |
| 144 | + | */ | |
| 145 | + | pin(actor: User, workspace: string, slug: string, position?: number | null): Promise<Result<Project[]>>; | |
| 146 | + | /** Unpins it. Returns their pins, in order. */ | |
| 147 | + | unpin(actor: User, workspace: string, slug: string): Promise<Result<Project[]>>; | |
| 148 | + | /** Puts their pins in this order: every pinned project's slug, once. */ | |
| 149 | + | reorderPins(actor: User, workspace: string, slugs: string[]): Promise<Result<Project[]>>; | |
| 150 | + | /** The person opened the project: it leads their recent ones. */ | |
| 151 | + | visited(actor: User, projectId: string): Promise<void>; | |
| 131 | 152 | } |
| 1 | + | -- What each person keeps at hand in a workspace: the projects they pinned, | |
| 2 | + | -- in the order they put them, and the ones they opened last. Keyed by the | |
| 3 | + | -- project's id, so a rename or a transfer keeps them; the workspace is | |
| 4 | + | -- the project's own, read through `projects`. | |
| 5 | + | ||
| 6 | + | CREATE TABLE pins ( | |
| 7 | + | user_id TEXT NOT NULL, | |
| 8 | + | project_id TEXT NOT NULL, | |
| 9 | + | -- 0 first, within the person's pins in the project's workspace. | |
| 10 | + | position INTEGER NOT NULL, | |
| 11 | + | pinned_at TEXT NOT NULL, | |
| 12 | + | PRIMARY KEY (user_id, project_id) | |
| 13 | + | ); | |
| 14 | + | CREATE INDEX pins_by_project ON pins (project_id); | |
| 15 | + | ||
| 16 | + | -- The last time each person opened each project: kept for their latest | |
| 17 | + | -- few, written after the page is sent. | |
| 18 | + | CREATE TABLE visits ( | |
| 19 | + | user_id TEXT NOT NULL, | |
| 20 | + | project_id TEXT NOT NULL, | |
| 21 | + | visited_at TEXT NOT NULL, | |
| 22 | + | PRIMARY KEY (user_id, project_id) | |
| 23 | + | ); | |
| 24 | + | CREATE INDEX visits_by_user ON visits (user_id, visited_at DESC); | |
| 25 | + | CREATE INDEX visits_by_project ON visits (project_id); |
| 17 | 17 | import { NEEDS, repoRef } from "./access"; | |
| 18 | 18 | import { effectiveDescription, ownDescription } from "./description"; | |
| 19 | 19 | import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind"; | |
| 20 | + | import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins"; | |
| 20 | 21 | import { renameStatements } from "./rename"; | |
| 21 | 22 | import { moveStatements, slugOf, strandedQuery } from "./transfer"; | |
| 22 | 23 | ||
| 46 | 47 | type Project, | |
| 47 | 48 | type ProjectEcosystem, | |
| 48 | 49 | type ProjectGraph, | |
| 50 | + | type ProjectShortcuts, | |
| 49 | 51 | type Repo, | |
| 50 | 52 | type Result, | |
| 51 | 53 | type ServiceBinding, | |
| 435 | 437 | await this.db.prepare("UPDATE projects SET deployments_on = ? WHERE id = ?").bind(a.enabled ? 1 : 0, a.projectId).run(); | |
| 436 | 438 | } | |
| 437 | 439 | ||
| 440 | + | // ---- Pinned and recent --------------------------------------------------- | |
| 441 | + | ||
| 442 | + | /** The person's pins in the workspace, in order, as rows: hidden projects left out. */ | |
| 443 | + | private async pinRows(userId: string, workspace: string): Promise<Row[]> { | |
| 444 | + | const rows = await this.db | |
| 445 | + | .prepare( | |
| 446 | + | `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id | |
| 447 | + | WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`, | |
| 448 | + | ) | |
| 449 | + | .bind(userId, workspace.toLowerCase()) | |
| 450 | + | .all<Row>(); | |
| 451 | + | return rows.results; | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | /** Writes the person's pins in the workspace in this order, by project id. */ | |
| 455 | + | private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> { | |
| 456 | + | const at = now(); | |
| 457 | + | await this.db.batch([ | |
| 458 | + | ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)), | |
| 459 | + | ...order.map((id, position) => | |
| 460 | + | this.db | |
| 461 | + | .prepare( | |
| 462 | + | `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?) | |
| 463 | + | ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`, | |
| 464 | + | ) | |
| 465 | + | .bind(userId, id, position, at), | |
| 466 | + | ), | |
| 467 | + | ]); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | /** The person behind a call about their own pins, or why there is none. */ | |
| 471 | + | private person(viewer: Viewer): Result<User> { | |
| 472 | + | if (!viewer) return fail("unauthenticated", "Sign in to pin projects."); | |
| 473 | + | if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token."); | |
| 474 | + | return ok(viewer); | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | /** A project the person can see, for pinning. */ | |
| 478 | + | private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> { | |
| 479 | + | const row = await this.row(workspace, slug); | |
| 480 | + | if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project."); | |
| 481 | + | return ok(row); | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> { | |
| 485 | + | const who = this.person(a.viewer); | |
| 486 | + | if (!who.ok) return { pinned: [], recent: [] }; | |
| 487 | + | const workspace = a.workspace.toLowerCase(); | |
| 488 | + | const [pinned, visited] = await Promise.all([ | |
| 489 | + | this.pinRows(who.value.id, workspace), | |
| 490 | + | this.db | |
| 491 | + | .prepare( | |
| 492 | + | `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id | |
| 493 | + | WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`, | |
| 494 | + | ) | |
| 495 | + | .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT) | |
| 496 | + | .all<Row>(), | |
| 497 | + | ]); | |
| 498 | + | const shown = (row: Row) => this.visible(row, who.value); | |
| 499 | + | const pins = pinned.filter(shown); | |
| 500 | + | return { | |
| 501 | + | pinned: pins.map(toProject), | |
| 502 | + | recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject), | |
| 503 | + | }; | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> { | |
| 507 | + | const who = this.person(a.actor); | |
| 508 | + | if (!who.ok) return who; | |
| 509 | + | const found = await this.pinnable(who.value, a.workspace, a.slug); | |
| 510 | + | if (!found.ok) return found; | |
| 511 | + | const current = await this.pinRows(who.value.id, found.value.workspace); | |
| 512 | + | const order = placePin( | |
| 513 | + | current.map((row) => row.id), | |
| 514 | + | found.value.id, | |
| 515 | + | a.position, | |
| 516 | + | ); | |
| 517 | + | if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`); | |
| 518 | + | await this.writePins(who.value.id, order); | |
| 519 | + | return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject)); | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> { | |
| 523 | + | const who = this.person(a.actor); | |
| 524 | + | if (!who.ok) return who; | |
| 525 | + | const row = await this.row(a.workspace, a.slug); | |
| 526 | + | if (!row) return fail("not_found", "There is no such project."); | |
| 527 | + | const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id); | |
| 528 | + | await this.writePins(who.value.id, rest, [row.id]); | |
| 529 | + | return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject)); | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> { | |
| 533 | + | const who = this.person(a.actor); | |
| 534 | + | if (!who.ok) return who; | |
| 535 | + | if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order."); | |
| 536 | + | const workspace = a.workspace.toLowerCase(); | |
| 537 | + | const current = await this.pinRows(who.value.id, workspace); | |
| 538 | + | const idOf = new Map(current.map((row) => [row.slug, row.id])); | |
| 539 | + | const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug)); | |
| 540 | + | const order = reorderPins( | |
| 541 | + | current.map((row) => row.id), | |
| 542 | + | wanted, | |
| 543 | + | ); | |
| 544 | + | if (!order.ok) return fail("invalid", order.message); | |
| 545 | + | await this.writePins(who.value.id, order.order); | |
| 546 | + | return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject)); | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | async visited(a: { actor: User; projectId: string }): Promise<void> { | |
| 550 | + | const who = this.person(a.actor); | |
| 551 | + | if (!who.ok || !a.projectId) return; | |
| 552 | + | await this.db.batch([ | |
| 553 | + | this.db | |
| 554 | + | .prepare( | |
| 555 | + | `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?) | |
| 556 | + | ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`, | |
| 557 | + | ) | |
| 558 | + | .bind(who.value.id, a.projectId, now()), | |
| 559 | + | // Only their latest few are kept. | |
| 560 | + | this.db | |
| 561 | + | .prepare( | |
| 562 | + | `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN | |
| 563 | + | (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`, | |
| 564 | + | ) | |
| 565 | + | .bind(who.value.id, MAX_VISITS), | |
| 566 | + | ]); | |
| 567 | + | } | |
| 568 | + | ||
| 438 | 569 | // ---- App or library -------------------------------------------------- | |
| 439 | 570 | ||
| 440 | 571 | /** | |
| 768 | 899 | this.db | |
| 769 | 900 | .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`) | |
| 770 | 901 | .bind(event.data.repoId), | |
| 902 | + | // Pins and visits of what is gone go with it. | |
| 903 | + | this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId), | |
| 904 | + | this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId), | |
| 771 | 905 | this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId), | |
| 772 | 906 | ]); | |
| 773 | 907 | return; | |
| 849 | 983 | const ids = "SELECT id FROM projects WHERE workspace = ?1"; | |
| 850 | 984 | await this.db.batch([ | |
| 851 | 985 | this.db.prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`).bind(slug), | |
| 986 | + | this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug), | |
| 987 | + | this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug), | |
| 852 | 988 | this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug), | |
| 853 | 989 | this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug), | |
| 854 | 990 | ]); | |
| 896 | 1032 | return Response.json(await service.removeDependency(args)); | |
| 897 | 1033 | case "graph": | |
| 898 | 1034 | return Response.json(await service.graph(args)); | |
| 1035 | + | case "shortcuts": | |
| 1036 | + | return Response.json(await service.shortcuts(args)); | |
| 1037 | + | case "pin": | |
| 1038 | + | return Response.json(await service.pin(args)); | |
| 1039 | + | case "unpin": | |
| 1040 | + | return Response.json(await service.unpin(args)); | |
| 1041 | + | case "reorder_pins": | |
| 1042 | + | return Response.json(await service.reorderPins(args)); | |
| 1043 | + | case "visited": | |
| 1044 | + | await service.visited(args); | |
| 1045 | + | return Response.json(null); | |
| 899 | 1046 | case "context_for_repo": | |
| 900 | 1047 | return Response.json(await service.contextForRepo(args)); | |
| 901 | 1048 | default: |
| 1 | + | import { test } from "node:test"; | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | ||
| 4 | + | import { MAX_PINS, placePin, recentAfterPins, reorderPins } from "./pins.ts"; | |
| 5 | + | ||
| 6 | + | test("a pin goes at the end, or where it is asked for", () => { | |
| 7 | + | assert.deepEqual(placePin([], "a"), ["a"]); | |
| 8 | + | assert.deepEqual(placePin(["a", "b"], "c"), ["a", "b", "c"]); | |
| 9 | + | assert.deepEqual(placePin(["a", "b"], "c", 0), ["c", "a", "b"]); | |
| 10 | + | assert.deepEqual(placePin(["a", "b"], "c", 1), ["a", "c", "b"]); | |
| 11 | + | // Past either end lands at that end. | |
| 12 | + | assert.deepEqual(placePin(["a", "b"], "c", 99), ["a", "b", "c"]); | |
| 13 | + | assert.deepEqual(placePin(["a", "b"], "c", -3), ["c", "a", "b"]); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("pinning one already pinned moves it, never twice", () => { | |
| 17 | + | assert.deepEqual(placePin(["a", "b", "c"], "c", 0), ["c", "a", "b"]); | |
| 18 | + | assert.deepEqual(placePin(["a", "b", "c"], "a"), ["b", "c", "a"]); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | test("a workspace holds a limited number of pins", () => { | |
| 22 | + | const full = Array.from({ length: MAX_PINS }, (_, i) => `p${i}`); | |
| 23 | + | assert.equal(placePin(full, "new"), null); | |
| 24 | + | // Moving one that is already there is still fine. | |
| 25 | + | assert.deepEqual(placePin(full, "p3", 0)?.[0], "p3"); | |
| 26 | + | assert.equal(placePin(full.slice(1), "new")?.length, MAX_PINS); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("reordering names every pin once and nothing else", () => { | |
| 30 | + | assert.deepEqual(reorderPins(["a", "b", "c"], ["c", "a", "b"]), { ok: true, order: ["c", "a", "b"] }); | |
| 31 | + | assert.equal(reorderPins(["a", "b"], ["a", "a"]).ok, false); | |
| 32 | + | assert.equal(reorderPins(["a", "b"], ["a"]).ok, false); | |
| 33 | + | assert.equal(reorderPins(["a", "b"], ["a", "b", "z"]).ok, false); | |
| 34 | + | assert.deepEqual(reorderPins([], []), { ok: true, order: [] }); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("recent leaves out what is pinned and keeps the latest few", () => { | |
| 38 | + | const visited = ["a", "b", "c", "d", "e", "f", "g"].map((id) => ({ id })); | |
| 39 | + | assert.deepEqual( | |
| 40 | + | recentAfterPins(visited, ["b", "d"]).map((p) => p.id), | |
| 41 | + | ["a", "c", "e", "f", "g"], | |
| 42 | + | ); | |
| 43 | + | assert.deepEqual(recentAfterPins(visited, [], 2).map((p) => p.id), ["a", "b"]); | |
| 44 | + | }); |
| 1 | + | /** | |
| 2 | + | * Pinned and recent projects: what each person keeps at hand in a | |
| 3 | + | * workspace. Pins are theirs to order, up to `MAX_PINS` a workspace; | |
| 4 | + | * recent is the projects they opened last that they have not pinned. | |
| 5 | + | * Pure, so the rules are tested apart from the service. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | /** Pins a person may have in one workspace: what its sidebar shows. */ | |
| 9 | + | export const MAX_PINS = 8; | |
| 10 | + | /** Recent projects shown, after the pins. */ | |
| 11 | + | export const MAX_RECENT = 5; | |
| 12 | + | /** Visits kept per person, across their workspaces. */ | |
| 13 | + | export const MAX_VISITS = 100; | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * The pins' order after pinning `id`: at `position` (0 first) when given, | |
| 17 | + | * else at the end. Pinning one already pinned moves it. Null when the | |
| 18 | + | * workspace's pins are full. | |
| 19 | + | */ | |
| 20 | + | export function placePin(order: string[], id: string, position?: number | null): string[] | null { | |
| 21 | + | const rest = order.filter((pinned) => pinned !== id); | |
| 22 | + | if (rest.length >= MAX_PINS) return null; | |
| 23 | + | const at = position == null || !Number.isFinite(position) ? rest.length : Math.max(0, Math.min(rest.length, Math.trunc(position))); | |
| 24 | + | return [...rest.slice(0, at), id, ...rest.slice(at)]; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /** | |
| 28 | + | * The pins in the order asked for. Every pin is named once and nothing | |
| 29 | + | * else, or the reason it cannot be. | |
| 30 | + | */ | |
| 31 | + | export function reorderPins(order: string[], wanted: string[]): { ok: true; order: string[] } | { ok: false; message: string } { | |
| 32 | + | const pinned = new Set(order); | |
| 33 | + | const named = new Set(wanted); | |
| 34 | + | if (named.size !== wanted.length) return { ok: false, message: "Name each pinned project once." }; | |
| 35 | + | const stranger = wanted.find((id) => !pinned.has(id)); | |
| 36 | + | if (stranger) return { ok: false, message: `${stranger} is not pinned.` }; | |
| 37 | + | if (named.size !== pinned.size) return { ok: false, message: "Name every pinned project, in the order you want them." }; | |
| 38 | + | return { ok: true, order: [...wanted] }; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** The recent projects to show: latest first, leaving out the pinned, at most `MAX_RECENT`. */ | |
| 42 | + | export function recentAfterPins<T extends { id: string }>(visited: T[], pinned: Iterable<string>, limit = MAX_RECENT): T[] { | |
| 43 | + | const skip = new Set(pinned); | |
| 44 | + | return visited.filter((project) => !skip.has(project.id)).slice(0, limit); | |
| 45 | + | } |