g1t/services/projects/src/transfer.ts
| 1 | /** |
| 2 | * A repository moved: transferred to another workspace, or renamed within |
| 3 | * its own. Its projects go with it. |
| 4 | * |
| 5 | * A project lives in the workspace its repository is in, so each of the |
| 6 | * repository's projects under a stale path's workspace moves to the |
| 7 | * workspace the repository is in now, keeping its id (deployments, |
| 8 | * secrets and the rest are keyed by it) and its slug. Projects elsewhere |
| 9 | * that build from the repository only learn its new path. Dependencies are |
| 10 | * within a workspace, so those between a moved project and one that stayed |
| 11 | * behind are dropped. |
| 12 | * |
| 13 | * Renamed, the repository's own project (its primary one, named after it) |
| 14 | * takes the new name as its slug, so its pages at `/<workspace>/<name>` |
| 15 | * follow, unless the workspace already has a project of that slug; it |
| 16 | * keeps its old slug then. Its display name follows when it was still the |
| 17 | * repository's old name. |
| 18 | * |
| 19 | * Each statement changes nothing once the rows have moved, so a repeated |
| 20 | * or late delivery is harmless. |
| 21 | */ |
| 22 | |
| 23 | import type { Statement } from "./rename"; |
| 24 | |
| 25 | /** A name as an address: lowercase letters, digits, `-`, `_` and `.`. */ |
| 26 | export function slugOf(name: string): string { |
| 27 | return name |
| 28 | .trim() |
| 29 | .toLowerCase() |
| 30 | .replace(/[^a-z0-9._-]+/g, "-") |
| 31 | .replace(/^[-.]+|[-.]+$/g, "") |
| 32 | .slice(0, 100); |
| 33 | } |
| 34 | |
| 35 | /** `stale` and `current` are `namespace/name` paths. */ |
| 36 | export function moveStatements(stale: string[], current: string, repoId: string): Statement[] { |
| 37 | const [workspace, name] = current.split("/") as [string, string]; |
| 38 | const moves = [...new Set(stale.map((path) => path.split("/")[0]!))] |
| 39 | .filter((from) => from !== workspace) |
| 40 | .map((from) => ({ |
| 41 | // A project of the same slug already in the destination keeps it; the |
| 42 | // moving one is given a free slug afterwards (see `strandedQuery`). |
| 43 | sql: "UPDATE OR IGNORE projects SET workspace = ?, repo_namespace = ? WHERE repo_id = ? AND workspace = ?", |
| 44 | params: [workspace, workspace, repoId, from], |
| 45 | })); |
| 46 | const renames = [...new Set(stale.map((path) => path.split("/")[1]!))] |
| 47 | .filter((old) => old !== name) |
| 48 | .flatMap((old) => { |
| 49 | const statements: Statement[] = []; |
| 50 | const [from, to] = [slugOf(old), slugOf(name)]; |
| 51 | if (from && to && from !== to) { |
| 52 | // Ignored when the workspace already has a project called `to`. |
| 53 | statements.push({ |
| 54 | sql: "UPDATE OR IGNORE projects SET slug = ? WHERE repo_id = ? AND is_primary = 1 AND workspace = ? AND slug = ?", |
| 55 | params: [to, repoId, workspace, from], |
| 56 | }); |
| 57 | } |
| 58 | statements.push({ |
| 59 | sql: "UPDATE projects SET name = ? WHERE repo_id = ? AND is_primary = 1 AND name = ?", |
| 60 | params: [name, repoId, old], |
| 61 | }); |
| 62 | return statements; |
| 63 | }); |
| 64 | return [ |
| 65 | ...moves, |
| 66 | ...renames, |
| 67 | { |
| 68 | sql: "UPDATE projects SET repo_namespace = ?, repo_name = ? WHERE repo_id = ?", |
| 69 | params: [workspace, name, repoId], |
| 70 | }, |
| 71 | { |
| 72 | sql: `DELETE FROM dependencies |
| 73 | WHERE (project_id IN (SELECT id FROM projects WHERE repo_id = ?) |
| 74 | AND depends_on_id IN (SELECT id FROM projects WHERE workspace <> ?)) |
| 75 | OR (depends_on_id IN (SELECT id FROM projects WHERE repo_id = ?) |
| 76 | AND project_id IN (SELECT id FROM projects WHERE workspace <> ?))`, |
| 77 | params: [repoId, workspace, repoId, workspace], |
| 78 | }, |
| 79 | ]; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * The repository's projects still under a stale workspace after the moves: |
| 84 | * those whose slug the destination already had. Null for a rename within |
| 85 | * the workspace, which moves no project between workspaces. |
| 86 | */ |
| 87 | export function strandedQuery(stale: string[], current: string, repoId: string): Statement | null { |
| 88 | const workspace = current.split("/")[0]!; |
| 89 | const from = [...new Set(stale.map((path) => path.split("/")[0]!))].filter((ns) => ns !== workspace); |
| 90 | if (from.length === 0) return null; |
| 91 | return { |
| 92 | sql: `SELECT * FROM projects WHERE repo_id = ? AND workspace IN (${from.map(() => "?").join(", ")})`, |
| 93 | params: [repoId, ...from], |
| 94 | }; |
| 95 | } |