@camox/api 0.2.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ import { ORPCError } from "@orpc/server";
2
+ import { and, eq } from "drizzle-orm";
3
+
4
+ import type { Database } from "../db";
5
+ import {
6
+ blockDefinitions,
7
+ blocks,
8
+ environments,
9
+ files,
10
+ layouts,
11
+ pages,
12
+ repeatableItems,
13
+ } from "../schema";
14
+
15
+ export async function resolveEnvironment(
16
+ db: Database,
17
+ projectId: number,
18
+ environmentName: string,
19
+ options?: { autoCreate?: boolean },
20
+ ) {
21
+ let environment = await db
22
+ .select()
23
+ .from(environments)
24
+ .where(and(eq(environments.projectId, projectId), eq(environments.name, environmentName)))
25
+ .get();
26
+
27
+ let created = false;
28
+
29
+ if (!environment && options?.autoCreate) {
30
+ const now = Date.now();
31
+ environment = await db
32
+ .insert(environments)
33
+ .values({
34
+ projectId,
35
+ name: environmentName,
36
+ type: "development",
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ })
40
+ .returning()
41
+ .get();
42
+
43
+ await forkProductionContent(db, projectId, environment.id);
44
+ created = true;
45
+ }
46
+
47
+ if (!environment) {
48
+ throw new ORPCError("NOT_FOUND", {
49
+ message: `Environment "${environmentName}" not found`,
50
+ });
51
+ }
52
+ return { ...environment, created };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Fork production content into a new environment
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function remapFileIds(content: unknown, fileIdMap: Map<number, number>): unknown {
60
+ if (content === null || content === undefined) return content;
61
+ if (Array.isArray(content)) {
62
+ return content.map((item) => remapFileIds(item, fileIdMap));
63
+ }
64
+ if (typeof content === "object") {
65
+ const obj = content as Record<string, unknown>;
66
+ if ("_fileId" in obj && typeof obj._fileId === "number") {
67
+ const newId = fileIdMap.get(obj._fileId);
68
+ if (newId !== undefined) {
69
+ return { ...obj, _fileId: newId };
70
+ }
71
+ }
72
+ const result: Record<string, unknown> = {};
73
+ for (const [key, value] of Object.entries(obj)) {
74
+ result[key] = remapFileIds(value, fileIdMap);
75
+ }
76
+ return result;
77
+ }
78
+ return content;
79
+ }
80
+
81
+ async function forkProductionContent(db: Database, projectId: number, newEnvironmentId: number) {
82
+ const prodEnv = await db
83
+ .select()
84
+ .from(environments)
85
+ .where(and(eq(environments.projectId, projectId), eq(environments.type, "production")))
86
+ .get();
87
+ if (!prodEnv) return;
88
+
89
+ const now = Date.now();
90
+
91
+ // 1. Block definitions
92
+ const prodDefs = await db
93
+ .select()
94
+ .from(blockDefinitions)
95
+ .where(eq(blockDefinitions.environmentId, prodEnv.id));
96
+ for (const def of prodDefs) {
97
+ const { id: _, ...rest } = def;
98
+ await db
99
+ .insert(blockDefinitions)
100
+ .values({ ...rest, environmentId: newEnvironmentId, createdAt: now, updatedAt: now });
101
+ }
102
+
103
+ // 2. Files (same blob/URL, new environment) — build ID mapping
104
+ const fileIdMap = new Map<number, number>();
105
+ const prodFiles = await db.select().from(files).where(eq(files.environmentId, prodEnv.id));
106
+ for (const file of prodFiles) {
107
+ const { id: _, ...rest } = file;
108
+ const newFile = await db
109
+ .insert(files)
110
+ .values({ ...rest, environmentId: newEnvironmentId, createdAt: now, updatedAt: now })
111
+ .returning()
112
+ .get();
113
+ fileIdMap.set(file.id, newFile.id);
114
+ }
115
+
116
+ // 3. Layouts — build ID mapping
117
+ const layoutIdMap = new Map<number, number>();
118
+ const prodLayouts = await db.select().from(layouts).where(eq(layouts.environmentId, prodEnv.id));
119
+ for (const layout of prodLayouts) {
120
+ const { id: _, ...rest } = layout;
121
+ const newLayout = await db
122
+ .insert(layouts)
123
+ .values({ ...rest, environmentId: newEnvironmentId, createdAt: now, updatedAt: now })
124
+ .returning()
125
+ .get();
126
+ layoutIdMap.set(layout.id, newLayout.id);
127
+ }
128
+
129
+ // 4. Layout blocks + their repeatable items
130
+ const blockIdMap = new Map<number, number>();
131
+ for (const [oldLayoutId, newLayoutId] of layoutIdMap) {
132
+ const layoutBlocks = await db.select().from(blocks).where(eq(blocks.layoutId, oldLayoutId));
133
+ for (const block of layoutBlocks) {
134
+ const { id: _, ...rest } = block;
135
+ const newBlock = await db
136
+ .insert(blocks)
137
+ .values({
138
+ ...rest,
139
+ layoutId: newLayoutId,
140
+ content: remapFileIds(rest.content, fileIdMap),
141
+ createdAt: now,
142
+ updatedAt: now,
143
+ })
144
+ .returning()
145
+ .get();
146
+ blockIdMap.set(block.id, newBlock.id);
147
+ }
148
+ }
149
+
150
+ // 5. Pages — sort by id so parents are inserted before children
151
+ const pageIdMap = new Map<number, number>();
152
+ const prodPages = await db.select().from(pages).where(eq(pages.environmentId, prodEnv.id));
153
+ prodPages.sort((a, b) => a.id - b.id);
154
+
155
+ for (const page of prodPages) {
156
+ const { id: _, ...rest } = page;
157
+ const newPage = await db
158
+ .insert(pages)
159
+ .values({
160
+ ...rest,
161
+ environmentId: newEnvironmentId,
162
+ layoutId: layoutIdMap.get(page.layoutId) ?? page.layoutId,
163
+ parentPageId: page.parentPageId ? (pageIdMap.get(page.parentPageId) ?? null) : null,
164
+ createdAt: now,
165
+ updatedAt: now,
166
+ })
167
+ .returning()
168
+ .get();
169
+ pageIdMap.set(page.id, newPage.id);
170
+ }
171
+
172
+ // 6. Page blocks
173
+ for (const [oldPageId, newPageId] of pageIdMap) {
174
+ const pageBlocks = await db.select().from(blocks).where(eq(blocks.pageId, oldPageId));
175
+ for (const block of pageBlocks) {
176
+ const { id: _, ...rest } = block;
177
+ const newBlock = await db
178
+ .insert(blocks)
179
+ .values({
180
+ ...rest,
181
+ pageId: newPageId,
182
+ content: remapFileIds(rest.content, fileIdMap),
183
+ createdAt: now,
184
+ updatedAt: now,
185
+ })
186
+ .returning()
187
+ .get();
188
+ blockIdMap.set(block.id, newBlock.id);
189
+ }
190
+ }
191
+
192
+ // 7. Repeatable items for all copied blocks — sort by id so parents come first
193
+ for (const [oldBlockId, newBlockId] of blockIdMap) {
194
+ const items = await db
195
+ .select()
196
+ .from(repeatableItems)
197
+ .where(eq(repeatableItems.blockId, oldBlockId));
198
+ items.sort((a, b) => a.id - b.id);
199
+
200
+ const itemIdMap = new Map<number, number>();
201
+ for (const item of items) {
202
+ const { id: _, ...rest } = item;
203
+ const newItem = await db
204
+ .insert(repeatableItems)
205
+ .values({
206
+ ...rest,
207
+ blockId: newBlockId,
208
+ parentItemId: item.parentItemId ? (itemIdMap.get(item.parentItemId) ?? null) : null,
209
+ content: remapFileIds(rest.content, fileIdMap),
210
+ createdAt: now,
211
+ updatedAt: now,
212
+ })
213
+ .returning()
214
+ .get();
215
+ itemIdMap.set(item.id, newItem.id);
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,21 @@
1
+ type AiJobType = "summary" | "fileMetadata" | "seo";
2
+ type EntityTable = "blocks" | "repeatableItems" | "files" | "pages";
3
+
4
+ export function scheduleAiJob(
5
+ doNamespace: DurableObjectNamespace,
6
+ options: {
7
+ entityTable: EntityTable;
8
+ entityId: number;
9
+ type: AiJobType;
10
+ delayMs: number;
11
+ },
12
+ ) {
13
+ const name = `${options.entityTable}:${options.entityId}:${options.type}`;
14
+ const id = doNamespace.idFromName(name);
15
+ const stub = doNamespace.get(id);
16
+ return stub.fetch("http://do/schedule", {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(options),
20
+ });
21
+ }
@@ -0,0 +1,42 @@
1
+ import { eq } from "drizzle-orm";
2
+ import {
3
+ adjectives,
4
+ animals,
5
+ NumberDictionary,
6
+ uniqueNamesGenerator,
7
+ } from "unique-names-generator";
8
+
9
+ import type { Database } from "../db";
10
+ import { projects } from "../schema";
11
+
12
+ const numberDictionary = NumberDictionary.generate({ min: 10, max: 99 });
13
+
14
+ function generateSlug(): string {
15
+ return uniqueNamesGenerator({
16
+ dictionaries: [adjectives, animals, numberDictionary],
17
+ separator: "-",
18
+ style: "lowerCase",
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Generates a unique human-readable slug (e.g. "swift-falcon-42")
24
+ * and verifies it doesn't collide with an existing project slug.
25
+ * Retries up to 10 times on collision.
26
+ */
27
+ export async function generateUniqueSlug(db: Database): Promise<string> {
28
+ for (let attempt = 0; attempt < 10; attempt++) {
29
+ const slug = generateSlug();
30
+ const existing = await db
31
+ .select({ id: projects.id })
32
+ .from(projects)
33
+ .where(eq(projects.slug, slug))
34
+ .get();
35
+
36
+ if (!existing) {
37
+ return slug;
38
+ }
39
+ }
40
+
41
+ throw new Error("Failed to generate a unique slug after 10 attempts");
42
+ }
@@ -0,0 +1,10 @@
1
+ import { createMiddleware } from "hono/factory";
2
+
3
+ import type { AppEnv } from "./types";
4
+
5
+ export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
6
+ if (!c.var.user) {
7
+ return c.json({ error: "Unauthorized" }, 401);
8
+ }
9
+ await next();
10
+ });
package/src/orpc.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { os, ORPCError } from "@orpc/server";
2
+
3
+ import type { Database } from "./db";
4
+ import type { Auth } from "./routes/auth";
5
+ import type { Bindings } from "./types";
6
+
7
+ // --- Context types ---
8
+
9
+ export type BaseContext = {
10
+ db: Database;
11
+ user: Auth["$Infer"]["Session"]["user"] | null;
12
+ session: Auth["$Infer"]["Session"]["session"] | null;
13
+ env: Bindings;
14
+ headers: Headers;
15
+ environmentName: string;
16
+ };
17
+
18
+ export type AuthedContext = BaseContext & {
19
+ user: Auth["$Infer"]["Session"]["user"];
20
+ session: Auth["$Infer"]["Session"]["session"];
21
+ };
22
+
23
+ // --- Base procedures ---
24
+
25
+ /** Public procedure — available to anyone, no auth required */
26
+ export const pub = os.$context<BaseContext>().use(async ({ next, path }) => {
27
+ try {
28
+ return await next();
29
+ } catch (error) {
30
+ if (error instanceof ORPCError && error.status < 500) {
31
+ throw error;
32
+ }
33
+ console.error(`[oRPC] ${path.join(".")} →`, error);
34
+ throw error;
35
+ }
36
+ });
37
+
38
+ /** Sync procedure — requires either a valid session or the x-sync-secret header */
39
+ export const synced = pub.use(async ({ context, next }) => {
40
+ if (context.user && context.session) {
41
+ return next({ context });
42
+ }
43
+
44
+ const secret = context.headers.get("x-sync-secret");
45
+ if (!secret || secret !== context.env.SYNC_SECRET) {
46
+ throw new ORPCError("UNAUTHORIZED");
47
+ }
48
+
49
+ return next({ context });
50
+ });
51
+
52
+ /** Authed procedure — requires authenticated user */
53
+ export const authed = pub.use(async ({ context, next }) => {
54
+ if (!context.user || !context.session) {
55
+ throw new ORPCError("UNAUTHORIZED");
56
+ }
57
+
58
+ return next({
59
+ context: {
60
+ ...context,
61
+ user: context.user,
62
+ session: context.session,
63
+ },
64
+ });
65
+ });
package/src/router.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { blockDefinitionProcedures } from "./routes/block-definitions";
2
+ import { blockProcedures } from "./routes/blocks";
3
+ import { fileProcedures } from "./routes/files";
4
+ import { layoutProcedures } from "./routes/layouts";
5
+ import { pageProcedures } from "./routes/pages";
6
+ import { projectProcedures } from "./routes/projects";
7
+ import { repeatableItemProcedures } from "./routes/repeatable-items";
8
+
9
+ export const router = {
10
+ projects: projectProcedures,
11
+ pages: pageProcedures,
12
+ blocks: blockProcedures,
13
+ layouts: layoutProcedures,
14
+ files: fileProcedures,
15
+ repeatableItems: repeatableItemProcedures,
16
+ blockDefinitions: blockDefinitionProcedures,
17
+ };
18
+
19
+ export type Router = typeof router;
@@ -0,0 +1,110 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
+ import { bearer, oneTimeToken, organization } from "better-auth/plugins";
4
+ import { Hono } from "hono";
5
+ import { cors } from "hono/cors";
6
+
7
+ import type { Database } from "../db";
8
+ import { crossDomain } from "../lib/cross-domain";
9
+ import {
10
+ user,
11
+ session,
12
+ account,
13
+ verification,
14
+ organizationTable,
15
+ member,
16
+ invitation,
17
+ } from "../schema";
18
+ import type { AppEnv, Bindings } from "../types";
19
+
20
+ // --- Auth Factory ---
21
+
22
+ const authSchema = {
23
+ user,
24
+ session,
25
+ account,
26
+ verification,
27
+ organization: organizationTable,
28
+ member,
29
+ invitation,
30
+ };
31
+
32
+ function generateSlug(name: string): string {
33
+ const base = name
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, "-")
36
+ .replace(/^-|-$/g, "");
37
+ const suffix = crypto.randomUUID().slice(0, 8);
38
+ return `${base}-${suffix}`;
39
+ }
40
+
41
+ export function createAuth(db: Database, env: Bindings) {
42
+ const auth = betterAuth({
43
+ database: drizzleAdapter(db, {
44
+ provider: "sqlite",
45
+ schema: authSchema,
46
+ }),
47
+ baseURL: env.BETTER_AUTH_URL,
48
+ secret: env.BETTER_AUTH_SECRET,
49
+ emailAndPassword: {
50
+ enabled: true,
51
+ requireEmailVerification: false,
52
+ },
53
+ socialProviders: {
54
+ github: {
55
+ clientId: env.GITHUB_CLIENT_ID,
56
+ clientSecret: env.GITHUB_CLIENT_SECRET,
57
+ },
58
+ google: {
59
+ clientId: env.GOOGLE_CLIENT_ID,
60
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
61
+ },
62
+ },
63
+ session: {
64
+ expiresIn: 60 * 60 * 24 * 90, // 90 days
65
+ updateAge: 60 * 60 * 24, // refresh session expiry daily
66
+ },
67
+ // Accept requests from any origin — Camox sites run on arbitrary customer domains
68
+ trustedOrigins: ["*"],
69
+ plugins: [organization(), crossDomain({ siteUrl: env.SITE_URL }), oneTimeToken(), bearer()],
70
+ databaseHooks: {
71
+ user: {
72
+ create: {
73
+ after: async (user) => {
74
+ const orgName = `${user.name}'s team`;
75
+ const slug = generateSlug(orgName);
76
+ await auth.api.createOrganization({
77
+ body: {
78
+ name: orgName,
79
+ slug,
80
+ userId: user.id,
81
+ },
82
+ });
83
+ },
84
+ },
85
+ },
86
+ },
87
+ });
88
+ return auth;
89
+ }
90
+
91
+ export type Auth = ReturnType<typeof createAuth>;
92
+
93
+ // --- Routes ---
94
+
95
+ export const authRoutes = new Hono<AppEnv>()
96
+ .use(
97
+ "*",
98
+ cors({
99
+ origin: (origin) => origin,
100
+ allowHeaders: ["Content-Type", "Authorization"],
101
+ allowMethods: ["POST", "GET", "OPTIONS"],
102
+ exposeHeaders: ["Content-Length", "Set-Better-Auth-Cookie"],
103
+ maxAge: 600,
104
+ credentials: true,
105
+ }),
106
+ )
107
+ .on(["POST", "GET"], "/*", async (c) => {
108
+ const auth = createAuth(c.var.db, c.env);
109
+ return auth.handler(c.req.raw);
110
+ });
@@ -0,0 +1,216 @@
1
+ import { ORPCError } from "@orpc/server";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+
5
+ import { resolveEnvironment } from "../lib/resolve-environment";
6
+ import { pub, synced } from "../orpc";
7
+ import { blockDefinitions, projects } from "../schema";
8
+
9
+ // --- Procedures ---
10
+
11
+ const definitionSchema = z.object({
12
+ projectSlug: z.string(),
13
+ blockId: z.string(),
14
+ title: z.string(),
15
+ description: z.string(),
16
+ contentSchema: z.unknown(),
17
+ settingsSchema: z.unknown().optional(),
18
+ defaultContent: z.unknown().optional(),
19
+ defaultSettings: z.unknown().optional(),
20
+ layoutOnly: z.boolean().optional(),
21
+ });
22
+
23
+ const syncSchema = z.object({
24
+ projectSlug: z.string(),
25
+ definitions: z.array(
26
+ z.object({
27
+ blockId: z.string(),
28
+ title: z.string(),
29
+ description: z.string(),
30
+ contentSchema: z.unknown(),
31
+ settingsSchema: z.unknown().optional(),
32
+ defaultContent: z.unknown().optional(),
33
+ defaultSettings: z.unknown().optional(),
34
+ layoutOnly: z.boolean().optional(),
35
+ }),
36
+ ),
37
+ });
38
+
39
+ const list = pub.input(z.object({ projectId: z.number() })).handler(async ({ context, input }) => {
40
+ const environment = await resolveEnvironment(
41
+ context.db,
42
+ input.projectId,
43
+ context.environmentName,
44
+ );
45
+ const result = await context.db
46
+ .select()
47
+ .from(blockDefinitions)
48
+ .where(
49
+ and(
50
+ eq(blockDefinitions.projectId, input.projectId),
51
+ eq(blockDefinitions.environmentId, environment.id),
52
+ ),
53
+ );
54
+ return result;
55
+ });
56
+
57
+ const sync = synced.input(syncSchema).handler(async ({ context, input }) => {
58
+ const { projectSlug, definitions } = input;
59
+ const project = await context.db
60
+ .select()
61
+ .from(projects)
62
+ .where(eq(projects.slug, projectSlug))
63
+ .get();
64
+ if (!project) throw new ORPCError("NOT_FOUND");
65
+ const projectId = project.id;
66
+ const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
67
+ autoCreate: true,
68
+ });
69
+ const now = Date.now();
70
+ const results = [];
71
+
72
+ for (const def of definitions) {
73
+ const existing = await context.db
74
+ .select()
75
+ .from(blockDefinitions)
76
+ .where(
77
+ and(
78
+ eq(blockDefinitions.projectId, projectId),
79
+ eq(blockDefinitions.environmentId, environment.id),
80
+ eq(blockDefinitions.blockId, def.blockId),
81
+ ),
82
+ )
83
+ .get();
84
+
85
+ if (existing) {
86
+ const updated = await context.db
87
+ .update(blockDefinitions)
88
+ .set({
89
+ title: def.title,
90
+ description: def.description,
91
+ contentSchema: def.contentSchema,
92
+ settingsSchema: def.settingsSchema ?? null,
93
+ defaultContent: def.defaultContent ?? null,
94
+ defaultSettings: def.defaultSettings ?? null,
95
+ layoutOnly: def.layoutOnly ?? null,
96
+ updatedAt: now,
97
+ })
98
+ .where(eq(blockDefinitions.id, existing.id))
99
+ .returning()
100
+ .get();
101
+ results.push(updated);
102
+ } else {
103
+ const created = await context.db
104
+ .insert(blockDefinitions)
105
+ .values({
106
+ projectId,
107
+ environmentId: environment.id,
108
+ blockId: def.blockId,
109
+ title: def.title,
110
+ description: def.description,
111
+ contentSchema: def.contentSchema,
112
+ settingsSchema: def.settingsSchema ?? null,
113
+ defaultContent: def.defaultContent ?? null,
114
+ defaultSettings: def.defaultSettings ?? null,
115
+ layoutOnly: def.layoutOnly ?? null,
116
+ createdAt: now,
117
+ updatedAt: now,
118
+ })
119
+ .returning()
120
+ .get();
121
+ results.push(created);
122
+ }
123
+ }
124
+
125
+ return { results, environmentCreated: environment.created };
126
+ });
127
+
128
+ const upsert = synced.input(definitionSchema).handler(async ({ context, input }) => {
129
+ const { projectSlug, ...body } = input;
130
+ const project = await context.db
131
+ .select()
132
+ .from(projects)
133
+ .where(eq(projects.slug, projectSlug))
134
+ .get();
135
+ if (!project) throw new ORPCError("NOT_FOUND");
136
+ const projectId = project.id;
137
+ const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
138
+ autoCreate: true,
139
+ });
140
+ const now = Date.now();
141
+
142
+ const existing = await context.db
143
+ .select()
144
+ .from(blockDefinitions)
145
+ .where(
146
+ and(
147
+ eq(blockDefinitions.projectId, projectId),
148
+ eq(blockDefinitions.environmentId, environment.id),
149
+ eq(blockDefinitions.blockId, body.blockId),
150
+ ),
151
+ )
152
+ .get();
153
+
154
+ if (existing) {
155
+ const result = await context.db
156
+ .update(blockDefinitions)
157
+ .set({
158
+ title: body.title,
159
+ description: body.description,
160
+ contentSchema: body.contentSchema,
161
+ settingsSchema: body.settingsSchema ?? null,
162
+ defaultContent: body.defaultContent ?? null,
163
+ defaultSettings: body.defaultSettings ?? null,
164
+ layoutOnly: body.layoutOnly ?? null,
165
+ updatedAt: now,
166
+ })
167
+ .where(eq(blockDefinitions.id, existing.id))
168
+ .returning()
169
+ .get();
170
+ return { ...result, action: "updated" as const };
171
+ }
172
+
173
+ const result = await context.db
174
+ .insert(blockDefinitions)
175
+ .values({
176
+ ...body,
177
+ projectId,
178
+ environmentId: environment.id,
179
+ settingsSchema: body.settingsSchema ?? null,
180
+ defaultContent: body.defaultContent ?? null,
181
+ defaultSettings: body.defaultSettings ?? null,
182
+ layoutOnly: body.layoutOnly ?? null,
183
+ createdAt: now,
184
+ updatedAt: now,
185
+ })
186
+ .returning()
187
+ .get();
188
+ return { ...result, action: "created" as const };
189
+ });
190
+
191
+ const deleteFn = synced
192
+ .input(z.object({ projectSlug: z.string(), blockId: z.string() }))
193
+ .handler(async ({ context, input }) => {
194
+ const { projectSlug, blockId } = input;
195
+ const project = await context.db
196
+ .select()
197
+ .from(projects)
198
+ .where(eq(projects.slug, projectSlug))
199
+ .get();
200
+ if (!project) throw new ORPCError("NOT_FOUND");
201
+ const environment = await resolveEnvironment(context.db, project.id, context.environmentName);
202
+ const result = await context.db
203
+ .delete(blockDefinitions)
204
+ .where(
205
+ and(
206
+ eq(blockDefinitions.projectId, project.id),
207
+ eq(blockDefinitions.environmentId, environment.id),
208
+ eq(blockDefinitions.blockId, blockId),
209
+ ),
210
+ )
211
+ .returning()
212
+ .get();
213
+ return { deleted: !!result, blockId };
214
+ });
215
+
216
+ export const blockDefinitionProcedures = { list, sync, upsert, delete: deleteFn };