@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,463 @@
1
+ import { ORPCError } from "@orpc/server";
2
+ import { chat } from "@tanstack/ai";
3
+ import { createOpenRouterText } from "@tanstack/ai-openrouter";
4
+ import { and, eq, inArray } from "drizzle-orm";
5
+ import { generateKeyBetween } from "fractional-indexing";
6
+ import { outdent } from "outdent";
7
+ import { z } from "zod";
8
+
9
+ import { assertBlockAccess, assertRepeatableItemAccess } from "../authorization";
10
+ import type { Database } from "../db";
11
+ import { broadcastInvalidation } from "../lib/broadcast-invalidation";
12
+ import { queryKeys } from "../lib/query-keys";
13
+ import { scheduleAiJob } from "../lib/schedule-ai-job";
14
+ import { pub, authed } from "../orpc";
15
+ import { blocks, files, repeatableItems } from "../schema";
16
+ import { collectFileIds } from "./pages";
17
+
18
+ function comparePositions(a: string, b: string): number {
19
+ if (a < b) return -1;
20
+ if (a > b) return 1;
21
+ return 0;
22
+ }
23
+
24
+ /** Find the last index where item.position <= target in a sorted array. */
25
+ function findLastIndexLe<T extends { position: string }>(items: T[], target: string): number {
26
+ let result = -1;
27
+ for (let i = 0; i < items.length; i++) {
28
+ if (items[i].position <= target) result = i;
29
+ else break;
30
+ }
31
+ return result;
32
+ }
33
+
34
+ // --- AI Executor ---
35
+
36
+ async function generateObjectSummary(
37
+ apiKey: string,
38
+ options: { type: string; markdown: string; previousSummary?: string },
39
+ ) {
40
+ const stabilityBlock = options.previousSummary
41
+ ? outdent`
42
+
43
+ <previous_summary>${options.previousSummary}</previous_summary>
44
+ <stability_instruction>
45
+ A summary was previously generated for this content.
46
+ Return the SAME summary unless it is no longer accurate.
47
+ Only change it if the content has meaningfully changed.
48
+ </stability_instruction>
49
+ `
50
+ : "";
51
+
52
+ return await chat({
53
+ adapter: createOpenRouterText("openai/gpt-oss-20b", apiKey),
54
+ stream: false,
55
+ messages: [
56
+ {
57
+ role: "user",
58
+ content: outdent`
59
+ <instruction>
60
+ Generate a concise summary for a piece of website content.
61
+ </instruction>
62
+
63
+ <constraints>
64
+ - MAXIMUM 4 WORDS
65
+ - Capture the main idea or purpose
66
+ - Be descriptive and specific to the content type
67
+ - Use sentence case (only capitalize the first word and proper nouns)
68
+ - Don't use markdown, just plain text
69
+ - Don't use punctuation
70
+ - Use abbreviations or acronyms where appropriate
71
+ </constraints>
72
+
73
+ <context>
74
+ <type>${options.type}</type>
75
+ <content>${options.markdown}</content>
76
+ </context>
77
+ ${stabilityBlock}
78
+
79
+ <examples>
80
+ <example>
81
+ <type>paragraph</type>
82
+ <content>{"text": "This is a description of how our service works in detail."}</content>
83
+ <output>Service explanation details</output>
84
+ </example>
85
+
86
+ <example>
87
+ <type>button</type>
88
+ <content>{"text": "Submit Form", "action": "submit"}</content>
89
+ <output>Submit form button</output>
90
+ </example>
91
+ </examples>
92
+
93
+ <format>
94
+ Return only the summary text, nothing else.
95
+ </format>
96
+ `,
97
+ },
98
+ ],
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Generates and stores a summary for a repeatable item.
104
+ * Returns `{ blockId }` so the caller can cascade to block summary regeneration.
105
+ */
106
+ export async function executeRepeatableItemSummary(
107
+ db: Database,
108
+ apiKey: string,
109
+ itemId: number,
110
+ ): Promise<{ blockId: number } | null> {
111
+ const item = await db.select().from(repeatableItems).where(eq(repeatableItems.id, itemId)).get();
112
+ if (!item) return null;
113
+
114
+ const block = await db.select().from(blocks).where(eq(blocks.id, item.blockId)).get();
115
+ if (!block) return null;
116
+
117
+ const summary = await generateObjectSummary(apiKey, {
118
+ type: block.type,
119
+ markdown: JSON.stringify(item.content),
120
+ previousSummary: item.summary,
121
+ });
122
+
123
+ await db
124
+ .update(repeatableItems)
125
+ .set({ summary, updatedAt: Date.now() })
126
+ .where(eq(repeatableItems.id, itemId));
127
+
128
+ if (summary !== item.summary) {
129
+ return { blockId: item.blockId };
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ // --- Procedures ---
136
+
137
+ const nestedItemSeedSchema = z.object({
138
+ tempId: z.string(),
139
+ parentTempId: z.string().nullable(),
140
+ fieldName: z.string(),
141
+ content: z.unknown(),
142
+ position: z.string(),
143
+ });
144
+
145
+ const createItemSchema = z.object({
146
+ blockId: z.number(),
147
+ parentItemId: z.number().nullable().optional(),
148
+ fieldName: z.string(),
149
+ content: z.unknown(),
150
+ afterPosition: z.string().nullable().optional(),
151
+ nestedItems: z.array(nestedItemSeedSchema).optional(),
152
+ });
153
+
154
+ const create = authed.input(createItemSchema).handler(async ({ context, input }) => {
155
+ const { blockId, parentItemId, fieldName, content, afterPosition, nestedItems } = input;
156
+ const access = await assertBlockAccess(context.db, blockId, context.user.id);
157
+ if (!access) throw new ORPCError("NOT_FOUND");
158
+
159
+ const now = Date.now();
160
+
161
+ // Get siblings to determine correct position
162
+ const siblings = (
163
+ await context.db
164
+ .select()
165
+ .from(repeatableItems)
166
+ .where(and(eq(repeatableItems.blockId, blockId), eq(repeatableItems.fieldName, fieldName)))
167
+ ).sort((a, b) => comparePositions(a.position, b.position));
168
+
169
+ let position: string;
170
+ if (afterPosition === undefined || afterPosition === null) {
171
+ const lastItem = siblings[siblings.length - 1];
172
+ position = generateKeyBetween(lastItem?.position ?? null, null);
173
+ } else if (afterPosition === "") {
174
+ const firstItem = siblings[0];
175
+ position = generateKeyBetween(null, firstItem?.position ?? null);
176
+ } else {
177
+ const afterIndex = findLastIndexLe(siblings, afterPosition!);
178
+ const nextItem = afterIndex >= 0 ? siblings[afterIndex + 1] : siblings[0];
179
+ position = generateKeyBetween(
180
+ afterIndex >= 0 ? siblings[afterIndex].position : null,
181
+ nextItem?.position ?? null,
182
+ );
183
+ }
184
+
185
+ const result = await context.db
186
+ .insert(repeatableItems)
187
+ .values({
188
+ blockId,
189
+ parentItemId: parentItemId ?? null,
190
+ fieldName,
191
+ content,
192
+ summary: "",
193
+ position,
194
+ createdAt: now,
195
+ updatedAt: now,
196
+ })
197
+ .returning()
198
+ .get();
199
+
200
+ // Insert client-provided nested item seeds
201
+ if (nestedItems && nestedItems.length > 0) {
202
+ const tempIdToRealId = new Map<string, number>();
203
+
204
+ for (const seed of nestedItems) {
205
+ // null parentTempId means child of the item being created
206
+ const seedParentId = seed.parentTempId
207
+ ? (tempIdToRealId.get(seed.parentTempId) ?? result.id)
208
+ : result.id;
209
+ const inserted = await context.db
210
+ .insert(repeatableItems)
211
+ .values({
212
+ blockId,
213
+ parentItemId: seedParentId,
214
+ fieldName: seed.fieldName,
215
+ content: seed.content,
216
+ summary: "",
217
+ position: seed.position,
218
+ createdAt: now,
219
+ updatedAt: now,
220
+ })
221
+ .returning()
222
+ .get();
223
+ tempIdToRealId.set(seed.tempId, inserted.id);
224
+ }
225
+ }
226
+
227
+ scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
228
+ entityTable: "repeatableItems",
229
+ entityId: result.id,
230
+ type: "summary",
231
+ delayMs: 0,
232
+ });
233
+ // Granular invalidation: refetch the parent block bundle (includes new item)
234
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
235
+ queryKeys.blocks.get(blockId),
236
+ queryKeys.blocks.getUsageCounts,
237
+ ]);
238
+
239
+ return result;
240
+ });
241
+
242
+ const updateContent = authed
243
+ .input(z.object({ id: z.number(), content: z.unknown() }))
244
+ .handler(async ({ context, input }) => {
245
+ const { id, content } = input;
246
+ const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
247
+ if (!access) throw new ORPCError("NOT_FOUND");
248
+
249
+ // Merge partial content into existing content (frontend sends single-field patches)
250
+ const merged = {
251
+ ...(access.item.content as Record<string, unknown>),
252
+ ...(content as Record<string, unknown>),
253
+ };
254
+ const result = await context.db
255
+ .update(repeatableItems)
256
+ .set({ content: merged, updatedAt: Date.now() })
257
+ .where(eq(repeatableItems.id, id))
258
+ .returning()
259
+ .get();
260
+
261
+ scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
262
+ entityTable: "repeatableItems",
263
+ entityId: id,
264
+ type: "summary",
265
+ delayMs: 5000,
266
+ });
267
+ // Granular invalidation: only refetch the parent block bundle
268
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
269
+ queryKeys.blocks.get(access.item.blockId),
270
+ ]);
271
+
272
+ return result;
273
+ });
274
+
275
+ const updatePosition = authed
276
+ .input(
277
+ z.object({
278
+ id: z.number(),
279
+ afterPosition: z.string().nullable().optional(),
280
+ beforePosition: z.string().nullable().optional(),
281
+ }),
282
+ )
283
+ .handler(async ({ context, input }) => {
284
+ const { id, afterPosition, beforePosition } = input;
285
+ const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
286
+ if (!access) throw new ORPCError("NOT_FOUND");
287
+
288
+ const item = access.item;
289
+ const siblings = (
290
+ await context.db
291
+ .select()
292
+ .from(repeatableItems)
293
+ .where(
294
+ and(
295
+ eq(repeatableItems.blockId, item.blockId),
296
+ eq(repeatableItems.fieldName, item.fieldName),
297
+ ),
298
+ )
299
+ )
300
+ .filter((s) => s.id !== id)
301
+ .sort((a, b) => comparePositions(a.position, b.position));
302
+
303
+ const after = afterPosition || null;
304
+ const before = beforePosition || null;
305
+
306
+ let position: string;
307
+ if (!after && !before) {
308
+ const last = siblings[siblings.length - 1];
309
+ position = generateKeyBetween(last?.position ?? null, null);
310
+ } else if (!after) {
311
+ const firstIdx = siblings.findIndex((b) => b.position >= before!);
312
+ position = generateKeyBetween(null, siblings[firstIdx]?.position ?? null);
313
+ } else {
314
+ const afterIdx = findLastIndexLe(siblings, after);
315
+ const nextPos = siblings[afterIdx + 1]?.position ?? null;
316
+ position = generateKeyBetween(siblings[afterIdx]?.position ?? null, nextPos);
317
+ }
318
+
319
+ const result = await context.db
320
+ .update(repeatableItems)
321
+ .set({ position, updatedAt: Date.now() })
322
+ .where(eq(repeatableItems.id, id))
323
+ .returning()
324
+ .get();
325
+ // Granular invalidation: only refetch the parent block bundle
326
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
327
+ queryKeys.blocks.get(access.item.blockId),
328
+ ]);
329
+ return result;
330
+ });
331
+
332
+ const duplicate = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
333
+ const { id } = input;
334
+ const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
335
+ if (!access) throw new ORPCError("NOT_FOUND");
336
+ const original = access.item;
337
+
338
+ const now = Date.now();
339
+
340
+ // Find the next sibling to insert between original and next
341
+ const siblings = (
342
+ await context.db
343
+ .select()
344
+ .from(repeatableItems)
345
+ .where(
346
+ and(
347
+ eq(repeatableItems.blockId, original.blockId),
348
+ eq(repeatableItems.fieldName, original.fieldName),
349
+ ),
350
+ )
351
+ ).sort((a, b) => comparePositions(a.position, b.position));
352
+ const originalIndex = siblings.findIndex((s) => s.id === id);
353
+ const nextItem = originalIndex >= 0 ? siblings[originalIndex + 1] : undefined;
354
+ const position = generateKeyBetween(original.position, nextItem?.position ?? null);
355
+
356
+ const result = await context.db
357
+ .insert(repeatableItems)
358
+ .values({
359
+ blockId: original.blockId,
360
+ fieldName: original.fieldName,
361
+ content: original.content,
362
+ summary: original.summary,
363
+ position,
364
+ createdAt: now,
365
+ updatedAt: now,
366
+ })
367
+ .returning()
368
+ .get();
369
+ // Granular invalidation: refetch the parent block bundle (includes new item)
370
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
371
+ queryKeys.blocks.get(original.blockId),
372
+ queryKeys.blocks.getUsageCounts,
373
+ ]);
374
+ return result;
375
+ });
376
+
377
+ const generateSummary = authed
378
+ .input(z.object({ id: z.number() }))
379
+ .handler(async ({ context, input }) => {
380
+ const { id } = input;
381
+ const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
382
+ if (!access) throw new ORPCError("NOT_FOUND");
383
+
384
+ const cascade = await executeRepeatableItemSummary(
385
+ context.db,
386
+ context.env.OPEN_ROUTER_API_KEY,
387
+ id,
388
+ );
389
+ if (cascade) {
390
+ scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
391
+ entityTable: "blocks",
392
+ entityId: cascade.blockId,
393
+ type: "summary",
394
+ delayMs: 5000,
395
+ });
396
+ }
397
+ // Granular invalidation: refetch the parent block bundle (includes updated summary)
398
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
399
+ queryKeys.blocks.get(access.item.blockId),
400
+ queryKeys.blocks.getUsageCounts,
401
+ ]);
402
+ const updated = await context.db
403
+ .select()
404
+ .from(repeatableItems)
405
+ .where(eq(repeatableItems.id, id))
406
+ .get();
407
+ return updated;
408
+ });
409
+
410
+ const deleteFn = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
411
+ const { id } = input;
412
+ const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
413
+ if (!access) throw new ORPCError("NOT_FOUND");
414
+
415
+ const blockId = access.item.blockId;
416
+ const result = await context.db
417
+ .delete(repeatableItems)
418
+ .where(eq(repeatableItems.id, id))
419
+ .returning()
420
+ .get();
421
+ // Granular invalidation: refetch the parent block bundle (item removed)
422
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
423
+ queryKeys.blocks.get(blockId),
424
+ queryKeys.blocks.getUsageCounts,
425
+ ]);
426
+ return result;
427
+ });
428
+
429
+ const get = pub.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
430
+ const item = await context.db
431
+ .select()
432
+ .from(repeatableItems)
433
+ .where(eq(repeatableItems.id, input.id))
434
+ .get();
435
+ if (!item) throw new ORPCError("NOT_FOUND");
436
+
437
+ // Collect and fetch referenced files
438
+ const fileIds = new Set<number>();
439
+ collectFileIds(item.content as Record<string, unknown>, fileIds);
440
+
441
+ const fileRows =
442
+ fileIds.size > 0
443
+ ? await context.db
444
+ .select()
445
+ .from(files)
446
+ .where(inArray(files.id, [...fileIds]))
447
+ : [];
448
+
449
+ return {
450
+ item,
451
+ files: fileRows,
452
+ };
453
+ });
454
+
455
+ export const repeatableItemProcedures = {
456
+ get,
457
+ create,
458
+ updateContent,
459
+ updatePosition,
460
+ duplicate,
461
+ generateSummary,
462
+ delete: deleteFn,
463
+ };