Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/cloud/src/api/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DbService } from "../services/db";
import { TelemetryLive } from "../services/telemetry";
import { OrgHttpApi } from "../org/compose";
import { OrgHandlers } from "../org/handlers";
import { WorkspacesHandlers } from "../workspaces/handlers";

import { CoreSharedServices } from "./core-shared-services";
import { ProtectedCloudApi, RouterConfig } from "./protected-layers";
Expand Down Expand Up @@ -66,7 +67,7 @@ export const makeOrgApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService>,
) =>
HttpApiBuilder.layer(OrgHttpApi).pipe(
Layer.provide(OrgHandlers),
Layer.provide(Layer.mergeAll(OrgHandlers, WorkspacesHandlers)),
Layer.provide(requestScopedMiddleware(rsLive).layer),
Layer.provideMerge(OrgAuthLive),
);
Expand Down
6 changes: 5 additions & 1 deletion apps/cloud/src/org/compose.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { HttpApi } from "effect/unstable/httpapi";
import { OrgAuth } from "../auth/middleware";
import { WorkspacesApi } from "../workspaces/api";
import { OrgApi } from "./api";

/** Org API with org-level auth — requires authenticated session with an org. */
export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth);
export const OrgHttpApi = HttpApi.make("org")
.add(OrgApi)
.add(WorkspacesApi)
.middleware(OrgAuth);
121 changes: 121 additions & 0 deletions apps/cloud/src/services/workspace-store.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Workspace store + handle-derivation tests.

import { describe, expect, it } from "@effect/vitest";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

import { combinedSchema } from "./db";
import { organizations } from "./schema";

Check warning on line 8 in apps/cloud/src/services/workspace-store.node.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Identifier 'organizations' is imported but never used.
import { makeWorkspaceStore } from "./workspace-store";
import { makeUserStore } from "./user-store";

const url =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@127.0.0.1:5434/postgres";

const withDb = async <T>(fn: (db: ReturnType<typeof drizzle>) => Promise<T>) => {
const sql = postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 30 });
try {
return await fn(drizzle(sql, { schema: combinedSchema }));
} finally {
await sql.end({ timeout: 0 }).catch(() => undefined);
}
};

const seedOrg = async (db: ReturnType<typeof drizzle>) => {
const id = `org_${crypto.randomUUID()}`;
await makeUserStore(db).upsertOrganization({ id, name: `Test ${id}` });
return id;
};

describe("workspace-store", () => {
it("creates a workspace with slugified id and slug from name", async () => {
await withDb(async (db) => {
const orgId = await seedOrg(db);
const ws = await makeWorkspaceStore(db).create({
organizationId: orgId,
name: "Billing API",
});
expect(ws.organizationId).toBe(orgId);
expect(ws.slug).toBe("billing-api");
expect(ws.id.startsWith("workspace_")).toBe(true);
expect(ws.name).toBe("Billing API");
});
});

it("disambiguates slug collisions within an org with -2, -3, …", async () => {
await withDb(async (db) => {
const orgId = await seedOrg(db);
const store = makeWorkspaceStore(db);
const a = await store.create({ organizationId: orgId, name: "Edge Cases" });
const b = await store.create({ organizationId: orgId, name: "Edge cases" });
const c = await store.create({ organizationId: orgId, name: "EDGE-cases" });
expect(a.slug).toBe("edge-cases");
expect(b.slug).toBe("edge-cases-2");
expect(c.slug).toBe("edge-cases-3");
});
});

it("allows the same slug across different orgs", async () => {
await withDb(async (db) => {
const o1 = await seedOrg(db);
const o2 = await seedOrg(db);
const a = await makeWorkspaceStore(db).create({
organizationId: o1,
name: "Common",
});
const b = await makeWorkspaceStore(db).create({
organizationId: o2,
name: "Common",
});
expect(a.slug).toBe("common");
expect(b.slug).toBe("common");
});
});

it("respects an explicit slug override", async () => {
await withDb(async (db) => {
const orgId = await seedOrg(db);
const ws = await makeWorkspaceStore(db).create({
organizationId: orgId,
name: "Anything",
slug: "custom-handle",
});
expect(ws.slug).toBe("custom-handle");
});
});

it("list returns workspaces in creation order", async () => {
await withDb(async (db) => {
const orgId = await seedOrg(db);
const store = makeWorkspaceStore(db);
await store.create({ organizationId: orgId, name: "First" });
await store.create({ organizationId: orgId, name: "Second" });
const rows = await store.list(orgId);
expect(rows.map((r) => r.slug)).toEqual(["first", "second"]);
});
});

it("getBySlug returns null for an unknown slug", async () => {
await withDb(async (db) => {
const orgId = await seedOrg(db);
const missing = await makeWorkspaceStore(db).getBySlug(orgId, "nope");
expect(missing).toBeNull();
});
});

it("getBySlug scopes to the org", async () => {
await withDb(async (db) => {
const o1 = await seedOrg(db);
const o2 = await seedOrg(db);
const a = await makeWorkspaceStore(db).create({
organizationId: o1,
name: "Shared",
});
const fromO2 = await makeWorkspaceStore(db).getBySlug(o2, a.slug);
expect(fromO2).toBeNull();
const fromO1 = await makeWorkspaceStore(db).getBySlug(o1, a.slug);
expect(fromO1?.id).toBe(a.id);
});
});
});
113 changes: 113 additions & 0 deletions apps/cloud/src/services/workspace-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// ---------------------------------------------------------------------------
// Workspace storage — local Drizzle-backed CRUD
// ---------------------------------------------------------------------------
//
// Workspaces are an org-local entity. Org membership grants access to every
// workspace in the org (no per-workspace ACLs in v1), and workspace deletion
// is intentionally out of scope. The store exposes only the surface the API
// + UI need today: create, list, get-by-slug, get-by-id.

import { and, asc, desc, eq, like } from "drizzle-orm";

import { newId, slugifyHandle, withHandleSuffix } from "./ids";
import { workspaces } from "./schema";
import type { DrizzleDb } from "./db";

export type Workspace = typeof workspaces.$inferSelect;

const SLUG_MAX_ATTEMPTS = 100;

const pickFreeSlug = async (
db: DrizzleDb,
organizationId: string,
base: string,
): Promise<string> => {
const existing = await db
.select({ slug: workspaces.slug })
.from(workspaces)
.where(
and(
eq(workspaces.organizationId, organizationId),
like(workspaces.slug, `${base}%`),
),
);
const taken = new Set(existing.map((r) => r.slug));
if (!taken.has(base)) return base;
for (let n = 2; n < SLUG_MAX_ATTEMPTS; n++) {
const candidate = withHandleSuffix(base, n);
if (!taken.has(candidate)) return candidate;
}
throw new Error(
`could not allocate workspace slug for org ${organizationId} (base "${base}")`,
);
};

export const makeWorkspaceStore = (db: DrizzleDb) => ({
/**
* Create a workspace inside an org. Slug is auto-generated from `name`
* with collision suffixes; caller can override by passing `slug` explicitly.
*/
create: async (input: {
organizationId: string;
name: string;
slug?: string;
}): Promise<Workspace> => {
const base = input.slug ?? slugifyHandle(input.name);
const slug = await pickFreeSlug(db, input.organizationId, base);
const [row] = await db
.insert(workspaces)
.values({
id: newId("workspace"),
organizationId: input.organizationId,
slug,
name: input.name,
})
.returning();
return row!;
},

list: async (organizationId: string): Promise<Workspace[]> =>
db
.select()
.from(workspaces)
.where(eq(workspaces.organizationId, organizationId))
.orderBy(asc(workspaces.createdAt)),

getBySlug: async (
organizationId: string,
slug: string,
): Promise<Workspace | null> => {
const rows = await db
.select()
.from(workspaces)
.where(
and(
eq(workspaces.organizationId, organizationId),
eq(workspaces.slug, slug),
),
);
return rows[0] ?? null;
},

getById: async (id: string): Promise<Workspace | null> => {
const rows = await db
.select()
.from(workspaces)
.where(eq(workspaces.id, id));
return rows[0] ?? null;
},

/** Most-recently-created first. Used by the switcher to suggest defaults. */
listMostRecent: async (
organizationId: string,
limit = 50,
): Promise<Workspace[]> =>
db
.select()
.from(workspaces)
.where(eq(workspaces.organizationId, organizationId))
.orderBy(desc(workspaces.createdAt))
.limit(limit),
});

export type WorkspaceStore = ReturnType<typeof makeWorkspaceStore>;
66 changes: 66 additions & 0 deletions apps/cloud/src/workspaces/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// ---------------------------------------------------------------------------
// Workspaces HTTP API — schemas + endpoint definitions
// ---------------------------------------------------------------------------
//
// Workspace context is org-scoped; the existing OrgAuth middleware (org
// membership check on the active session) covers authorization. v1 surface:
// create, list, get-by-slug. Slug is auto-generated from `name`; the client
// can override by passing `slug` explicitly.

import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
import { Schema } from "effect";

const Workspace = Schema.Struct({
id: Schema.String,
organizationId: Schema.String,
slug: Schema.String,
name: Schema.String,
createdAt: Schema.Date,
updatedAt: Schema.Date,
});

export class WorkspaceNotFound extends Schema.TaggedErrorClass<WorkspaceNotFound>()(
"WorkspaceNotFound",
{},
{ httpApiStatus: 404 },
) {}

export class InvalidWorkspaceName extends Schema.TaggedErrorClass<InvalidWorkspaceName>()(
"InvalidWorkspaceName",
{ reason: Schema.String },
{ httpApiStatus: 400 },
) {}

const CreateWorkspaceBody = Schema.Struct({
name: Schema.String,
slug: Schema.optional(Schema.String),
});

const SlugParam = { slug: Schema.String };

const ListResponse = Schema.Struct({
workspaces: Schema.Array(Workspace),
});

export class WorkspacesApi extends HttpApiGroup.make("workspaces")
.add(
HttpApiEndpoint.get("listWorkspaces", "/workspaces", {
success: ListResponse,
}),
)
.add(
HttpApiEndpoint.post("createWorkspace", "/workspaces", {
payload: CreateWorkspaceBody,
success: Workspace,
error: InvalidWorkspaceName,
}),
)
.add(
HttpApiEndpoint.get("getWorkspace", "/workspaces/:slug", {
params: SlugParam,
success: Workspace,
error: WorkspaceNotFound,
}),
) {}

export { Workspace };
Loading
Loading