{ "$schema": "https://shadcn-vue.com/schema/registry-item.json", "name": "event-calendar-persistence", "title": "Event Calendar — Persistence (Nitro + libSQL)", "description": "Optional Nitro server layer: /api/events CRUD backed by libSQL via Drizzle, with a dialect-portable schema, validation, and a mapper. Nuxt/Nitro only. Bring your own libSQL URL. Enables durable, per-user events.", "dependencies": [ "@libsql/client", "drizzle-orm", "date-fns" ], "devDependencies": [ "drizzle-kit" ], "registryDependencies": [ "https://event-calendar.anorebel.net/r/event-calendar-data.json" ], "files": [ { "path": "app/registry/event-calendar-persistence/schema.ts", "content": "import { sqliteTable, text, integer } from \"drizzle-orm/sqlite-core\"\n\n// Events table. Kept dialect-portable: only text/integer columns and the Drizzle\n// query builder are used elsewhere, so moving to Postgres is a config + import\n// change (drizzle-orm/pg-core) rather than a query rewrite.\n//\n// Dates are stored as ISO-8601 strings (text) rather than a DB-specific datetime\n// type — portable across SQLite/libSQL and Postgres, and matches the wire format\n// the client (useEventAPI) already sends/receives.\nexport const events = sqliteTable(\"events\", {\n id: text(\"id\").primaryKey(),\n // ownerId is nullable — set it when you add auth; left null in the single-user template.\n ownerId: text(\"owner_id\"),\n title: text(\"title\").notNull(),\n description: text(\"description\"),\n startDate: text(\"start_date\").notNull(), // ISO string\n endDate: text(\"end_date\").notNull(), // ISO string\n startTime: text(\"start_time\"),\n endTime: text(\"end_time\"),\n allDay: integer(\"all_day\", { mode: \"boolean\" }).notNull().default(false),\n color: text(\"color\"),\n location: text(\"location\"),\n status: text(\"status\"),\n timezone: text(\"timezone\"),\n isRecurring: integer(\"is_recurring\", { mode: \"boolean\" }).notNull().default(false),\n recurringPattern: text(\"recurring_pattern\"), // JSON string when present\n recurringId: text(\"recurring_id\"),\n createdAt: text(\"created_at\").notNull(),\n updatedAt: text(\"updated_at\").notNull(),\n})\n\n// Users. Sessions are sealed cookies (nuxt-auth-utils) and need no table — this\n// is the only auth table the app owns. Password auth stores only a scrypt hash.\nexport const users = sqliteTable(\"users\", {\n id: text(\"id\").primaryKey(),\n email: text(\"email\").notNull().unique(),\n name: text(\"name\"),\n passwordHash: text(\"password_hash\").notNull(),\n createdAt: text(\"created_at\").notNull(),\n})\n\n// Invites. When registration is invite-only (the default), a valid unused,\n// unexpired invite token is required to register. Created by an existing user.\nexport const invites = sqliteTable(\"invites\", {\n token: text(\"token\").primaryKey(),\n email: text(\"email\"), // optional: restrict the invite to a specific email\n createdBy: text(\"created_by\").notNull(),\n usedBy: text(\"used_by\"), // set to the new user's id once consumed\n expiresAt: text(\"expires_at\"), // ISO string; null = no expiry\n createdAt: text(\"created_at\").notNull(),\n})\n\nexport type EventRow = typeof events.$inferSelect\nexport type NewEventRow = typeof events.$inferInsert\nexport type UserRow = typeof users.$inferSelect\nexport type NewUserRow = typeof users.$inferInsert\nexport type InviteRow = typeof invites.$inferSelect\nexport type NewInviteRow = typeof invites.$inferInsert\n", "type": "registry:file", "target": "~/server/db/schema.ts" }, { "path": "app/registry/event-calendar-persistence/db.ts", "content": "import { createClient, type Client } from \"@libsql/client\"\nimport { drizzle, type LibSQLDatabase } from \"drizzle-orm/libsql\"\nimport * as schema from \"../db/schema\"\n\n// Single lazily-created libSQL client + Drizzle instance per server process.\n// The connection URL/token come from runtimeConfig (runtime-configurable). For a\n// self-hosted sqld with no auth, the token is empty and omitted.\nlet _client: Client | null = null\nlet _db: LibSQLDatabase | null = null\n\nexport function useDb(): LibSQLDatabase {\n if (_db) return _db\n\n const { libsql } = useRuntimeConfig()\n const url = libsql?.url\n if (!url) {\n throw createError({ statusCode: 500, statusMessage: \"libSQL URL is not configured\" })\n }\n\n _client = createClient(\n libsql.authToken ? { url, authToken: libsql.authToken } : { url },\n )\n _db = drizzle(_client, { schema })\n return _db\n}\n\n// Raw client — used by the health check for a lightweight connectivity probe.\nexport function useDbClient(): Client {\n useDb()\n return _client as Client\n}\n\nexport { schema }\n", "type": "registry:file", "target": "~/server/utils/db.ts" }, { "path": "app/registry/event-calendar-persistence/eventMapper.ts", "content": "import { isValid, parseISO } from \"date-fns\"\nimport type { EventRow, NewEventRow } from \"../db/schema\"\n\n// The wire shape of an event: same as the client's CalendarEvent but with dates\n// as ISO strings (JSON has no Date type). useEventAPI sends/expects exactly this.\nexport interface EventDTO {\n id: string\n ownerId?: string | null\n title: string\n description?: string\n startDate: string\n endDate: string\n startTime?: string\n endTime?: string\n allDay?: boolean\n color?: string\n location?: string\n status?: string\n timezone?: string\n isRecurring?: boolean\n recurringPattern?: unknown\n recurringId?: string\n}\n\n// DB row -> wire DTO (client JSON).\nexport function rowToDto(row: EventRow): EventDTO {\n return {\n id: row.id,\n ownerId: row.ownerId,\n title: row.title,\n description: row.description ?? undefined,\n startDate: row.startDate,\n endDate: row.endDate,\n startTime: row.startTime ?? undefined,\n endTime: row.endTime ?? undefined,\n allDay: row.allDay,\n color: row.color ?? undefined,\n location: row.location ?? undefined,\n status: row.status ?? undefined,\n timezone: row.timezone ?? undefined,\n isRecurring: row.isRecurring,\n recurringPattern: row.recurringPattern ? safeParse(row.recurringPattern) : undefined,\n recurringId: row.recurringId ?? undefined,\n }\n}\n\n// Incoming create/update payload -> DB row columns. Only known fields are copied\n// (unknown fields are ignored, never persisted). Returns a partial for updates.\nexport function dtoToRow(input: Partial): Partial {\n const row: Partial = {}\n if (input.id !== undefined) row.id = input.id\n if (input.ownerId !== undefined) row.ownerId = input.ownerId\n if (input.title !== undefined) row.title = input.title\n if (input.description !== undefined) row.description = input.description\n if (input.startDate !== undefined) row.startDate = toISO(input.startDate)\n if (input.endDate !== undefined) row.endDate = toISO(input.endDate)\n if (input.startTime !== undefined) row.startTime = input.startTime\n if (input.endTime !== undefined) row.endTime = input.endTime\n if (input.allDay !== undefined) row.allDay = !!input.allDay\n if (input.color !== undefined) row.color = input.color\n if (input.location !== undefined) row.location = input.location\n if (input.status !== undefined) row.status = input.status\n if (input.timezone !== undefined) row.timezone = input.timezone\n if (input.isRecurring !== undefined) row.isRecurring = !!input.isRecurring\n if (input.recurringPattern !== undefined) {\n row.recurringPattern = input.recurringPattern ? JSON.stringify(input.recurringPattern) : null\n }\n if (input.recurringId !== undefined) row.recurringId = input.recurringId\n return row\n}\n\nfunction toISO(value: string): string {\n // Accept ISO strings and normalize to a canonical ISO timestamp; pass through\n // anything unparseable unchanged (validation rejects it separately).\n const parsed = parseISO(value)\n return isValid(parsed) ? parsed.toISOString() : value\n}\n\nfunction safeParse(json: string): unknown {\n try {\n return JSON.parse(json)\n } catch {\n return undefined\n }\n}\n", "type": "registry:file", "target": "~/server/utils/eventMapper.ts" }, { "path": "app/registry/event-calendar-persistence/eventValidation.ts", "content": "import { isValid, parseISO } from \"date-fns\"\nimport type { EventDTO } from \"./eventMapper\"\n\nexport interface ValidationError {\n field: string\n message: string\n}\n\n// Server-side validation for event create/update payloads. This is the\n// authoritative check — the client validates too, but the API never trusts it.\n// `partial` allows update payloads that omit fields.\nexport function validateEventInput(\n input: Partial,\n { partial = false }: { partial?: boolean } = {},\n): ValidationError[] {\n const errors: ValidationError[] = []\n\n const hasTitle = input.title !== undefined\n if (!partial || hasTitle) {\n if (typeof input.title !== \"string\" || input.title.trim().length === 0) {\n errors.push({ field: \"title\", message: \"Event title is required\" })\n }\n }\n\n const hasStart = input.startDate !== undefined\n const hasEnd = input.endDate !== undefined\n\n if (!partial || hasStart) {\n if (!isValidDate(input.startDate)) {\n errors.push({ field: \"startDate\", message: \"A valid start date is required\" })\n }\n }\n if (!partial || hasEnd) {\n if (!isValidDate(input.endDate)) {\n errors.push({ field: \"endDate\", message: \"A valid end date is required\" })\n }\n }\n\n // End must be after start for non-all-day events, when both are present/valid.\n if (isValidDate(input.startDate) && isValidDate(input.endDate) && !input.allDay) {\n const start = parseISO(input.startDate as string)\n const end = parseISO(input.endDate as string)\n if (start >= end) {\n errors.push({ field: \"endDate\", message: \"End time must be after start time\" })\n }\n }\n\n return errors\n}\n\nfunction isValidDate(value: unknown): boolean {\n if (typeof value !== \"string\" || value.length === 0) return false\n return isValid(parseISO(value))\n}\n", "type": "registry:file", "target": "~/server/utils/eventValidation.ts" }, { "path": "app/registry/event-calendar-persistence/index.get.ts", "content": "import { and, gte, lte } from \"drizzle-orm\"\nimport { events } from \"../../db/schema\"\nimport { rowToDto } from \"../../utils/eventMapper\"\n\n// GET /api/events?start=&end=&page=&limit=\n// Returns a paginated envelope: { data, total, page, limit, hasMore }.\n// A row overlaps [start, end] when its start <= end AND its end >= start.\n//\n// AUTH: this template returns ALL events (single-user). To scope events per user,\n// resolve the user here (e.g. `const { user } = await requireUserSession(event)`)\n// and add `eq(events.ownerId, user.id)` to the conditions below.\nexport default defineEventHandler(async (event) => {\n const q = getQuery(event)\n const page = Math.max(1, Number.parseInt(String(q.page ?? \"1\"), 10) || 1)\n const limit = Math.min(500, Math.max(1, Number.parseInt(String(q.limit ?? \"100\"), 10) || 100))\n const start = typeof q.start === \"string\" ? q.start : undefined\n const end = typeof q.end === \"string\" ? q.end : undefined\n\n const db = useDb()\n\n const conditions = []\n // TODO(auth): scope to the current user — conditions.push(eq(events.ownerId, user.id))\n if (end) conditions.push(lte(events.startDate, end))\n if (start) conditions.push(gte(events.endDate, start))\n\n const where = conditions.length ? and(...conditions) : undefined\n\n const rows = await db\n .select()\n .from(events)\n .where(where)\n .limit(limit)\n .offset((page - 1) * limit)\n\n const total = await db.$count(events, where)\n\n return {\n data: rows.map(rowToDto),\n total,\n page,\n limit,\n hasMore: page * limit < total,\n }\n})\n", "type": "registry:file", "target": "~/server/api/events/index.get.ts" }, { "path": "app/registry/event-calendar-persistence/index.post.ts", "content": "import { randomUUID } from \"node:crypto\"\nimport { events } from \"../../db/schema\"\nimport { dtoToRow, rowToDto, type EventDTO } from \"../../utils/eventMapper\"\nimport { validateEventInput } from \"../../utils/eventValidation\"\n\n// POST /api/events — create an event. Returns 201 with the persisted event.\nexport default defineEventHandler(async (event) => {\n // TODO(auth): require a session and set ownerId to the current user\n const body = await readBody>(event)\n\n const errors = validateEventInput(body)\n if (errors.length) {\n throw createError({ statusCode: 400, statusMessage: \"Invalid event\", data: { errors } })\n }\n\n // TODO(features): gate recurring events if you flag them\n\n const now = new Date().toISOString()\n const row = {\n ...dtoToRow(body),\n // Server assigns the id; client-sent id/ownerId are ignored.\n id: randomUUID(),\n ownerId: null,\n isRecurring: !!body.isRecurring,\n allDay: !!body.allDay,\n createdAt: now,\n updatedAt: now,\n } as typeof events.$inferInsert\n\n const db = useDb()\n const [created] = await db.insert(events).values(row).returning()\n\n setResponseStatus(event, 201)\n const dto = rowToDto(created)\n\n // Broadcast to connected clients only after a successful write.\n await broadcastEventChange({ type: \"created\", event: dto })\n\n return dto\n})\n", "type": "registry:file", "target": "~/server/api/events/index.post.ts" }, { "path": "app/registry/event-calendar-persistence/[id].patch.ts", "content": "import { eq } from \"drizzle-orm\"\nimport { events } from \"../../db/schema\"\nimport { dtoToRow, rowToDto, type EventDTO } from \"../../utils/eventMapper\"\nimport { validateEventInput } from \"../../utils/eventValidation\"\n\n// PATCH /api/events/:id — update an event. 404 if it doesn't exist.\nexport default defineEventHandler(async (event) => {\n const id = getRouterParam(event, \"id\")\n if (!id) {\n throw createError({ statusCode: 400, statusMessage: \"Missing event id\" })\n }\n\n const body = await readBody>(event)\n const errors = validateEventInput(body, { partial: true })\n if (errors.length) {\n throw createError({ statusCode: 400, statusMessage: \"Invalid event\", data: { errors } })\n }\n\n const db = useDb()\n const existing = await db.select().from(events).where(eq(events.id, id)).limit(1)\n if (existing.length === 0) {\n throw createError({ statusCode: 404, statusMessage: \"Event not found\" })\n }\n\n // TODO(auth): require a session; verify the caller owns this event (403 otherwise)\n\n const updates = {\n ...dtoToRow(body),\n updatedAt: new Date().toISOString(),\n }\n // Never let id/ownerId/createdAt be overwritten via the update path.\n delete (updates as Record).id\n delete (updates as Record).ownerId\n delete (updates as Record).createdAt\n\n const [updated] = await db.update(events).set(updates).where(eq(events.id, id)).returning()\n const dto = rowToDto(updated)\n\n await broadcastEventChange({ type: \"updated\", event: dto })\n\n return dto\n})\n", "type": "registry:file", "target": "~/server/api/events/[id].patch.ts" }, { "path": "app/registry/event-calendar-persistence/[id].delete.ts", "content": "import { eq } from \"drizzle-orm\"\nimport { events } from \"../../db/schema\"\n\n// DELETE /api/events/:id — remove an event. 204 on success, 404 if missing.\nexport default defineEventHandler(async (event) => {\n const id = getRouterParam(event, \"id\")\n if (!id) {\n throw createError({ statusCode: 400, statusMessage: \"Missing event id\" })\n }\n\n const db = useDb()\n const [existing] = await db.select({ id: events.id, ownerId: events.ownerId }).from(events).where(eq(events.id, id)).limit(1)\n if (!existing) {\n throw createError({ statusCode: 404, statusMessage: \"Event not found\" })\n }\n\n // TODO(auth): require a session; verify the caller owns this event (403 otherwise)\n\n const deleted = await db.delete(events).where(eq(events.id, id)).returning({ id: events.id })\n if (deleted.length === 0) {\n throw createError({ statusCode: 404, statusMessage: \"Event not found\" })\n }\n\n await broadcastEventChange({ type: \"deleted\", id, ownerId: existing.ownerId ?? null })\n\n setResponseStatus(event, 204)\n return null\n})\n", "type": "registry:file", "target": "~/server/api/events/[id].delete.ts" } ], "type": "registry:block" }