branches.ts98 lines · main
1import { brivenError, newId } from '@briven/shared';
2
3/**
4 * Branching / preview environments — Phase 4 future-feature SKELETON.
5 *
6 * This file exists today to (a) pin the data model + API shape so the
7 * dashboard and CLI can wire against a stable surface, (b) give an
8 * operator a path to land the full implementation incrementally, and
9 * (c) document what's intentionally NOT in v1.
10 *
11 * The full implementation has three parts:
12 *
13 * 1. Meta-DB row in `project_branches` linking a branch name to its
14 * parent project + parent branch. Cascade-deletes when the parent
15 * project deletes.
16 *
17 * 2. Data-plane work: clone the project's postgres schema into a new
18 * schema named `proj_<projectId>_<branchSlug>`. Empty initially
19 * (structure only). Optional `--copy-data` flag for ~100MB shards.
20 *
21 * 3. Routing layer: `<branchSlug>.<projectSlug>.apps.briven.tech`
22 * resolves via wildcard traefik to the api, which sees the host
23 * header, looks up the branch, and routes the function call to
24 * the cloned schema instead of the parent's.
25 *
26 * Today the code below is the type contract — calls throw
27 * `not_implemented` so the dashboard renders an "available in v2"
28 * banner instead of breaking. When the data-plane clone path lands,
29 * replace the stub bodies; the route handlers and CLI commands don't
30 * need to change.
31 */
32
33export interface ProjectBranch {
34 readonly id: string;
35 readonly projectId: string;
36 readonly name: string;
37 /** Slug derived from name + must be DNS-safe — `^[a-z0-9-]+$`, 1-32 chars. */
38 readonly slug: string;
39 /** Parent branch id. `null` means parent is the project's main schema. */
40 readonly parentBranchId: string | null;
41 /** When the branch was created. */
42 readonly createdAt: Date;
43 /** Soft-delete timestamp. Branches live ~30 days after delete then
44 * the schema is dropped + the row is hard-deleted by a cron. */
45 readonly deletedAt: Date | null;
46 /** Whether `--copy-data` was passed at create time. Drives storage
47 * usage attribution. */
48 readonly hasDataCopy: boolean;
49}
50
51export interface CreateBranchInput {
52 readonly projectId: string;
53 readonly name: string;
54 /** Source branch — defaults to the project's main schema. */
55 readonly fromBranchId?: string;
56 /** When true, the data-plane clone copies all rows. When false (default),
57 * only the schema (tables/indexes/constraints) is cloned. */
58 readonly copyData?: boolean;
59}
60
61export async function createBranch(_input: CreateBranchInput): Promise<ProjectBranch> {
62 throw new brivenError(
63 'not_implemented',
64 'branching is a Phase 4 feature — see docs/BUILD_PLAN.md',
65 { status: 501 },
66 );
67}
68
69export async function listBranches(_projectId: string): Promise<readonly ProjectBranch[]> {
70 // Returns empty until the v2 implementation lands. The dashboard
71 // renders the "no branches yet" state, which is also accurate
72 // because there ARE no branches yet.
73 return [];
74}
75
76export async function deleteBranch(_branchId: string): Promise<void> {
77 throw new brivenError(
78 'not_implemented',
79 'branching is a Phase 4 feature — see docs/BUILD_PLAN.md',
80 { status: 501 },
81 );
82}
83
84/**
85 * Slug derivation matches the data-plane schema name suffix. Lowercase
86 * the input, replace non-DNS chars with `-`, collapse repeats, trim
87 * to 32. ULID for uniqueness guarantee (so two branches with the same
88 * name on the same project don't collide).
89 */
90export function slugifyBranchName(name: string): string {
91 const base = name
92 .toLowerCase()
93 .replace(/[^a-z0-9]+/g, '-')
94 .replace(/^-+|-+$/g, '')
95 .slice(0, 24);
96 if (!base) throw new brivenError('validation_failed', 'branch name produces empty slug', { status: 400 });
97 return `${base}-${newId('br').slice(3, 11).toLowerCase()}`;
98}