@electric-agent/agent 1.0.0

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.
Files changed (127) hide show
  1. package/dist/agents/clarifier.d.ts +16 -0
  2. package/dist/agents/clarifier.d.ts.map +1 -0
  3. package/dist/agents/clarifier.js +158 -0
  4. package/dist/agents/clarifier.js.map +1 -0
  5. package/dist/agents/coder.d.ts +14 -0
  6. package/dist/agents/coder.d.ts.map +1 -0
  7. package/dist/agents/coder.js +126 -0
  8. package/dist/agents/coder.js.map +1 -0
  9. package/dist/agents/planner.d.ts +6 -0
  10. package/dist/agents/planner.d.ts.map +1 -0
  11. package/dist/agents/planner.js +69 -0
  12. package/dist/agents/planner.js.map +1 -0
  13. package/dist/agents/prompts.d.ts +9 -0
  14. package/dist/agents/prompts.d.ts.map +1 -0
  15. package/dist/agents/prompts.js +231 -0
  16. package/dist/agents/prompts.js.map +1 -0
  17. package/dist/cli/headless.d.ts +9 -0
  18. package/dist/cli/headless.d.ts.map +1 -0
  19. package/dist/cli/headless.js +506 -0
  20. package/dist/cli/headless.js.map +1 -0
  21. package/dist/cli/serve.d.ts +6 -0
  22. package/dist/cli/serve.d.ts.map +1 -0
  23. package/dist/cli/serve.js +113 -0
  24. package/dist/cli/serve.js.map +1 -0
  25. package/dist/engine/message-parser.d.ts +8 -0
  26. package/dist/engine/message-parser.d.ts.map +1 -0
  27. package/dist/engine/message-parser.js +106 -0
  28. package/dist/engine/message-parser.js.map +1 -0
  29. package/dist/engine/orchestrator.d.ts +50 -0
  30. package/dist/engine/orchestrator.d.ts.map +1 -0
  31. package/dist/engine/orchestrator.js +492 -0
  32. package/dist/engine/orchestrator.js.map +1 -0
  33. package/dist/engine/stdio-adapter.d.ts +24 -0
  34. package/dist/engine/stdio-adapter.d.ts.map +1 -0
  35. package/dist/engine/stdio-adapter.js +139 -0
  36. package/dist/engine/stdio-adapter.js.map +1 -0
  37. package/dist/engine/stream-adapter.d.ts +45 -0
  38. package/dist/engine/stream-adapter.d.ts.map +1 -0
  39. package/dist/engine/stream-adapter.js +154 -0
  40. package/dist/engine/stream-adapter.js.map +1 -0
  41. package/dist/find-env.d.ts +3 -0
  42. package/dist/find-env.d.ts.map +1 -0
  43. package/dist/find-env.js +16 -0
  44. package/dist/find-env.js.map +1 -0
  45. package/dist/git/index.d.ts +114 -0
  46. package/dist/git/index.d.ts.map +1 -0
  47. package/dist/git/index.js +434 -0
  48. package/dist/git/index.js.map +1 -0
  49. package/dist/hooks/block-bash.d.ts +7 -0
  50. package/dist/hooks/block-bash.d.ts.map +1 -0
  51. package/dist/hooks/block-bash.js +15 -0
  52. package/dist/hooks/block-bash.js.map +1 -0
  53. package/dist/hooks/dependency-guard.d.ts +7 -0
  54. package/dist/hooks/dependency-guard.d.ts.map +1 -0
  55. package/dist/hooks/dependency-guard.js +43 -0
  56. package/dist/hooks/dependency-guard.js.map +1 -0
  57. package/dist/hooks/guardrail-inject.d.ts +17 -0
  58. package/dist/hooks/guardrail-inject.d.ts.map +1 -0
  59. package/dist/hooks/guardrail-inject.js +69 -0
  60. package/dist/hooks/guardrail-inject.js.map +1 -0
  61. package/dist/hooks/import-validation.d.ts +7 -0
  62. package/dist/hooks/import-validation.d.ts.map +1 -0
  63. package/dist/hooks/import-validation.js +192 -0
  64. package/dist/hooks/import-validation.js.map +1 -0
  65. package/dist/hooks/index.d.ts +15 -0
  66. package/dist/hooks/index.d.ts.map +1 -0
  67. package/dist/hooks/index.js +42 -0
  68. package/dist/hooks/index.js.map +1 -0
  69. package/dist/hooks/migration-validation.d.ts +9 -0
  70. package/dist/hooks/migration-validation.d.ts.map +1 -0
  71. package/dist/hooks/migration-validation.js +62 -0
  72. package/dist/hooks/migration-validation.js.map +1 -0
  73. package/dist/hooks/schema-consistency.d.ts +12 -0
  74. package/dist/hooks/schema-consistency.d.ts.map +1 -0
  75. package/dist/hooks/schema-consistency.js +72 -0
  76. package/dist/hooks/schema-consistency.js.map +1 -0
  77. package/dist/hooks/write-protection.d.ts +7 -0
  78. package/dist/hooks/write-protection.d.ts.map +1 -0
  79. package/dist/hooks/write-protection.js +33 -0
  80. package/dist/hooks/write-protection.js.map +1 -0
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.d.ts.map +1 -0
  83. package/dist/index.js +37 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/progress/reporter.d.ts +15 -0
  86. package/dist/progress/reporter.d.ts.map +1 -0
  87. package/dist/progress/reporter.js +133 -0
  88. package/dist/progress/reporter.js.map +1 -0
  89. package/dist/scaffold/index.d.ts +23 -0
  90. package/dist/scaffold/index.d.ts.map +1 -0
  91. package/dist/scaffold/index.js +315 -0
  92. package/dist/scaffold/index.js.map +1 -0
  93. package/dist/tools/build.d.ts +3 -0
  94. package/dist/tools/build.d.ts.map +1 -0
  95. package/dist/tools/build.js +84 -0
  96. package/dist/tools/build.js.map +1 -0
  97. package/dist/tools/playbook.d.ts +14 -0
  98. package/dist/tools/playbook.d.ts.map +1 -0
  99. package/dist/tools/playbook.js +239 -0
  100. package/dist/tools/playbook.js.map +1 -0
  101. package/dist/tools/server.d.ts +3 -0
  102. package/dist/tools/server.d.ts.map +1 -0
  103. package/dist/tools/server.js +13 -0
  104. package/dist/tools/server.js.map +1 -0
  105. package/dist/working-memory/errors.d.ts +14 -0
  106. package/dist/working-memory/errors.d.ts.map +1 -0
  107. package/dist/working-memory/errors.js +89 -0
  108. package/dist/working-memory/errors.js.map +1 -0
  109. package/dist/working-memory/session.d.ts +12 -0
  110. package/dist/working-memory/session.d.ts.map +1 -0
  111. package/dist/working-memory/session.js +71 -0
  112. package/dist/working-memory/session.js.map +1 -0
  113. package/package.json +50 -0
  114. package/playbooks/electric-app-guardrails/SKILL.md +255 -0
  115. package/template/.env.example +2 -0
  116. package/template/Caddyfile +11 -0
  117. package/template/docker-compose.yml +47 -0
  118. package/template/drizzle.config.ts +12 -0
  119. package/template/postgres.conf +4 -0
  120. package/template/src/components/ClientOnly.tsx +27 -0
  121. package/template/src/db/index.ts +7 -0
  122. package/template/src/db/schema.ts +14 -0
  123. package/template/src/db/utils.ts +31 -0
  124. package/template/src/db/zod-schemas.ts +14 -0
  125. package/template/src/lib/electric-proxy.ts +59 -0
  126. package/template/tests/helpers/schema-test-utils.ts +106 -0
  127. package/template/vitest.config.ts +7 -0
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: electric-app-guardrails
3
+ description: Project-specific guardrails for drizzle-zod integration, parseDates, ClientOnly, testing patterns, and hallucination guard. Read this FIRST before coding.
4
+ triggers:
5
+ - zod-schemas
6
+ - drizzle-zod
7
+ - parseDates
8
+ - ClientOnly
9
+ - testing
10
+ - smoke test
11
+ - hallucination
12
+ - lucide
13
+ - icons
14
+ ---
15
+
16
+ # Project-Specific Guardrails
17
+
18
+ Patterns NOT covered by external playbooks. Read playbooks for collections, live-queries, mutations, schemas, and Electric quickstart patterns.
19
+
20
+ ## Drizzle-Zod Integration (CRITICAL)
21
+
22
+ **Import `z` from `"zod/v4"`** (NOT `"zod"`) — drizzle-zod 0.8.x uses Zod v4 internals. The v4 runtime rejects v3-style overrides with "Invalid element: expected a Zod schema".
23
+
24
+ **Always override timestamp columns** with `z.union([z.date(), z.string()]).default(() => new Date())` — Electric streams dates as ISO strings, but `createSelectSchema` generates `z.date()` which only accepts Date objects. The `.default()` is critical: it makes timestamps omittable during `collection.insert()` (the DB sets them server-side), while still accepting them when present from Electric sync. Without this, `collection.insert()` throws `SchemaValidationError` on `created_at`/`updated_at`.
25
+
26
+ **Do NOT use `z.coerce.date()`** — creates ZodEffects that TanStack DB's schema introspection rejects.
27
+
28
+ ```typescript
29
+ // src/db/zod-schemas.ts
30
+ import { createSelectSchema, createInsertSchema } from "drizzle-zod"
31
+ import { z } from "zod/v4"
32
+ import { todos } from "./schema"
33
+
34
+ const dateOrString = z.union([z.date(), z.string()]).default(() => new Date())
35
+
36
+ export const todoSelectSchema = createSelectSchema(todos, {
37
+ created_at: dateOrString,
38
+ updated_at: dateOrString,
39
+ })
40
+ export const todoInsertSchema = createInsertSchema(todos, {
41
+ created_at: dateOrString.optional(),
42
+ updated_at: dateOrString.optional(),
43
+ })
44
+ ```
45
+
46
+ **Use `selectSchema` as the collection schema** — it has defaults for timestamps so `collection.insert()` works without them, and validates fully populated rows from Electric sync.
47
+
48
+ ## parseDates Utility (CRITICAL)
49
+
50
+ Mutation routes MUST wrap `request.json()` with `parseDates()` — JSON serialization turns Date objects into ISO strings, and Drizzle's timestamp columns crash on strings.
51
+ ```typescript
52
+ import { parseDates } from "@/db/utils"
53
+ const data = parseDates(await request.json())
54
+ ```
55
+
56
+ ## Mutation PUT/PATCH Handlers (CRITICAL)
57
+
58
+ **Always destructure out timestamp columns** before spreading into `.set()`. Electric streams timestamps as Postgres-format strings (`"2024-01-01 00:00:00+00"` — space separator, not ISO `T`). `parseDates` only matches ISO format, so these pass through as raw strings. Drizzle's `PgTimestamp.mapToDriverValue` calls `.toISOString()` on them → `TypeError: value.toISOString is not a function`.
59
+
60
+ ```typescript
61
+ // WRONG — created_at leaks into .set() as a string, Drizzle crashes
62
+ const { id, ...rest } = body
63
+ await tx.update(todos).set({ ...rest, updated_at: new Date() })
64
+
65
+ // RIGHT — strip timestamps, only spread user-editable fields
66
+ const { id, created_at: _, updated_at: __, ...fields } = body
67
+ await tx.update(todos).set({ ...fields, updated_at: new Date() })
68
+ ```
69
+
70
+ ## ClientOnly Wrapper
71
+
72
+ Components using `useLiveQuery` that must render from `__root.tsx` need `ClientOnly`:
73
+ ```typescript
74
+ import { ClientOnly } from "../components/ClientOnly"
75
+ <ClientOnly fallback={<Box style={{ width: 240 }} />}>
76
+ {() => <Sidebar />}
77
+ </ClientOnly>
78
+ ```
79
+ `ClientOnly` is provided by the scaffold. **NEVER render useLiveQuery components directly in `__root.tsx`** — crashes with `Missing getServerSnapshot`.
80
+
81
+ ## Collection shapeOptions URL (CRITICAL)
82
+
83
+ `shapeOptions.url` MUST be an absolute URL — Electric's `ShapeStream` calls `new URL(url)` with no base, which throws on relative paths like `"/api/todos"`.
84
+
85
+ ```typescript
86
+ // WRONG — relative URL, ShapeStream throws "Invalid URL"
87
+ shapeOptions: { url: "/api/todos" }
88
+
89
+ // RIGHT — absolute URL with SSR-safe fallback
90
+ shapeOptions: {
91
+ url: new URL(
92
+ "/api/todos",
93
+ typeof window !== "undefined" ? window.location.origin : "http://localhost:5174",
94
+ ).toString(),
95
+ }
96
+ ```
97
+
98
+ ## API Route Pattern (CRITICAL)
99
+
100
+ Use `createFileRoute` from `@tanstack/react-router` with `server.handlers`. Do NOT use `createAPIFileRoute` or `createServerFileRoute` — those do not exist in the installed packages.
101
+
102
+ ### Electric Shape Proxy Route (`src/routes/api/<tablename>.ts`)
103
+ ```typescript
104
+ import { createFileRoute } from "@tanstack/react-router"
105
+ import { proxyElectricRequest } from "@/lib/electric-proxy"
106
+
107
+ export const Route = createFileRoute("/api/todos")({
108
+ // @ts-expect-error – server.handlers types lag behind runtime support
109
+ server: {
110
+ handlers: {
111
+ GET: async ({ request }: { request: Request }) => {
112
+ return proxyElectricRequest(request, "todos")
113
+ },
114
+ },
115
+ },
116
+ })
117
+ ```
118
+
119
+ ### Mutation Route (`src/routes/api/mutations/<tablename>.ts`)
120
+ ```typescript
121
+ import { createFileRoute } from "@tanstack/react-router"
122
+ import { eq } from "drizzle-orm"
123
+ import { db } from "@/db"
124
+ import { todos } from "@/db/schema"
125
+ import { generateTxId, parseDates } from "@/db/utils"
126
+
127
+ export const Route = createFileRoute("/api/mutations/todos")({
128
+ // @ts-expect-error – server.handlers types lag behind runtime support
129
+ server: {
130
+ handlers: {
131
+ POST: async ({ request }: { request: Request }) => {
132
+ const body = parseDates(await request.json())
133
+ const txid = await db.transaction(async (tx) => {
134
+ await tx.insert(todos).values(body)
135
+ return generateTxId(tx)
136
+ })
137
+ return new Response(JSON.stringify({ txid }), {
138
+ headers: { "Content-Type": "application/json" },
139
+ })
140
+ },
141
+ PUT: async ({ request }: { request: Request }) => {
142
+ const body = parseDates(await request.json())
143
+ const { id, created_at: _, updated_at: __, ...fields } = body
144
+ const txid = await db.transaction(async (tx) => {
145
+ await tx.update(todos).set({ ...fields, updated_at: new Date() }).where(eq(todos.id, id as string))
146
+ return generateTxId(tx)
147
+ })
148
+ return new Response(JSON.stringify({ txid }), {
149
+ headers: { "Content-Type": "application/json" },
150
+ })
151
+ },
152
+ DELETE: async ({ request }: { request: Request }) => {
153
+ const body = parseDates(await request.json())
154
+ const txid = await db.transaction(async (tx) => {
155
+ await tx.delete(todos).where(eq(todos.id, body.id as string))
156
+ return generateTxId(tx)
157
+ })
158
+ return new Response(JSON.stringify({ txid }), {
159
+ headers: { "Content-Type": "application/json" },
160
+ })
161
+ },
162
+ },
163
+ },
164
+ })
165
+ ```
166
+
167
+ ### Route Naming Convention
168
+
169
+ - `/api/<tablename>` — Electric shape proxy (GET only)
170
+ - `/api/mutations/<tablename>` — Write mutations (POST/PUT/DELETE)
171
+
172
+ ## Icons
173
+
174
+ Use `lucide-react` (already installed). Do NOT use `@radix-ui/react-icons` (not installed).
175
+ ```typescript
176
+ import { Trash2, Plus, Check, X, Search } from "lucide-react"
177
+ ```
178
+
179
+ ## Drizzle Schema Conventions
180
+
181
+ - UUID primary keys: `uuid().primaryKey().defaultRandom()`
182
+ - Timestamps: `timestamp({ withTimezone: true })`
183
+ - snake_case for SQL table/column names
184
+ - Foreign keys: `.references(() => table.id, { onDelete: "cascade" })` — do NOT import `relations` from `drizzle-orm`
185
+ - Every table needs REPLICA IDENTITY FULL (auto-applied by migration hook)
186
+ - `src/server.ts` entry point is required
187
+ - vite.config.ts must include `nitro()` plugin
188
+
189
+ ## Testing Patterns
190
+
191
+ ### File Structure
192
+ ```
193
+ tests/
194
+ ├── helpers/
195
+ │ └── schema-test-utils.ts # generateValidRow, generateRowWithout (scaffold-provided)
196
+ ├── schema.test.ts # Zod schema smoke tests — no Docker
197
+ ├── collections.test.ts # Collection insert validation — no Docker
198
+ └── integration/
199
+ └── data-flow.test.ts # Drizzle insert + read back — requires Docker
200
+ ```
201
+
202
+ ### Schema Smoke Test
203
+ ```typescript
204
+ import { generateValidRow, generateRowWithout } from "./helpers/schema-test-utils"
205
+ import { todoSelectSchema } from "@/db/zod-schemas"
206
+
207
+ describe("todo schema", () => {
208
+ it("accepts a complete row", () => {
209
+ expect(todoSelectSchema.safeParse(generateValidRow(todoSelectSchema)).success).toBe(true)
210
+ })
211
+ it("rejects without id", () => {
212
+ expect(todoSelectSchema.safeParse(generateRowWithout(todoSelectSchema, "id")).success).toBe(false)
213
+ })
214
+ })
215
+ ```
216
+
217
+ ### JSON Round-Trip Test
218
+ ```typescript
219
+ it("survives JSON round-trip", () => {
220
+ const row = generateValidRow(todoSelectSchema)
221
+ const serialized = JSON.parse(JSON.stringify(row))
222
+ const revived = parseDates(serialized)
223
+ expect(todoSelectSchema.safeParse(revived).success).toBe(true)
224
+ })
225
+ ```
226
+
227
+ ### Testing Rules
228
+ - **DO NOT** import collection files in smoke tests — collections connect to Electric on import
229
+ - **DO NOT** import `@/db` in smoke tests — requires Postgres
230
+ - **ONLY** import from `@/db/zod-schemas` and `@/db/schema` in smoke tests
231
+ - **ALWAYS** test that removing `id`, `createdAt`, `updatedAt` causes validation failure
232
+ - Use `generateValidRow(schema)` — never hand-write partial test data
233
+
234
+ ## Hallucination Guard
235
+
236
+ | WRONG | RIGHT |
237
+ |-------|-------|
238
+ | `import { z } from "zod"` in zod-schemas | `import { z } from "zod/v4"` |
239
+ | `createSelectSchema(todos)` without date overrides | Override ALL timestamps: `{ created_at: z.union([z.date(), z.string()]).default(() => new Date()) }` |
240
+ | `z.union([z.date(), z.string()])` without `.default()` | Add `.default(() => new Date())` — required for `collection.insert()` to work without timestamps |
241
+ | `z.coerce.date()` for timestamps | `z.union([z.date(), z.string()])` |
242
+ | `import { createInsertSchema } from 'drizzle-orm/zod'` | `from 'drizzle-zod'` |
243
+ | `import { drizzle } from 'drizzle-orm'` | `from 'drizzle-orm/postgres-js'` |
244
+ | `import { X } from '@radix-ui/react-icons'` | `from 'lucide-react'` |
245
+ | `import { relations } from "drizzle-orm"` | Use `.references()` on columns instead |
246
+ | `const data = await request.json()` in mutations | `parseDates(await request.json())` |
247
+ | `<Sidebar />` (useLiveQuery) in `__root.tsx` | `<ClientOnly>{() => <Sidebar />}</ClientOnly>` |
248
+ | `import { todoCollection }` in smoke tests | Import from `@/db/zod-schemas` only |
249
+ | `import { db } from "@/db"` in smoke tests | Only in integration tests |
250
+ | Testing with `{ text: "foo" }` (partial) | `generateValidRow(schema)` |
251
+ | `const { id, ...rest } = body` then `.set(rest)` in PUT | Destructure out `created_at`, `updated_at` before spreading |
252
+ | `shapeOptions: { url: "/api/todos" }` (relative) | `url: new URL("/api/todos", typeof window !== "undefined" ? window.location.origin : "http://localhost:5174").toString()` |
253
+ | `import { createAPIFileRoute } from '@tanstack/start/api'` | Does NOT exist — use `createFileRoute` from `@tanstack/react-router` with `server.handlers` |
254
+ | `import { createServerFileRoute } from '@tanstack/start/server'` | Does NOT exist — use `createFileRoute` from `@tanstack/react-router` with `server.handlers` |
255
+ | `createAPIFileRoute('/api/todos')` | `createFileRoute('/api/todos')({ server: { handlers: { GET: ... } } })` |
@@ -0,0 +1,2 @@
1
+ DATABASE_URL=postgresql://postgres:password@localhost:54321/electric
2
+ ELECTRIC_URL=http://localhost:3000
@@ -0,0 +1,11 @@
1
+ http://localhost:5173 {
2
+ # Proxy Electric shape API
3
+ handle /v1/shape* {
4
+ reverse_proxy electric:3000
5
+ }
6
+
7
+ # Proxy everything else to the Vite dev server
8
+ handle {
9
+ reverse_proxy host.docker.internal:5174
10
+ }
11
+ }
@@ -0,0 +1,47 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:17
4
+ environment:
5
+ POSTGRES_DB: electric
6
+ POSTGRES_USER: postgres
7
+ POSTGRES_PASSWORD: password
8
+ ports:
9
+ - "54321:5432"
10
+ volumes:
11
+ - pgdata:/var/lib/postgresql/data
12
+ - ./postgres.conf:/etc/postgresql/postgresql.conf
13
+ command: postgres -c config_file=/etc/postgresql/postgresql.conf
14
+ healthcheck:
15
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
16
+ interval: 2s
17
+ timeout: 5s
18
+ retries: 10
19
+
20
+ electric:
21
+ image: electricsql/electric:latest
22
+ environment:
23
+ DATABASE_URL: postgresql://postgres:password@postgres:5432/electric
24
+ ELECTRIC_INSECURE: "true"
25
+ ports:
26
+ - "3000:3000"
27
+ depends_on:
28
+ postgres:
29
+ condition: service_healthy
30
+
31
+ caddy:
32
+ image: caddy:2-alpine
33
+ extra_hosts:
34
+ - "host.docker.internal:host-gateway"
35
+ ports:
36
+ - "5173:5173"
37
+ volumes:
38
+ - ./Caddyfile:/etc/caddy/Caddyfile
39
+ - caddy_data:/data
40
+ - caddy_config:/config
41
+ depends_on:
42
+ - electric
43
+
44
+ volumes:
45
+ pgdata:
46
+ caddy_data:
47
+ caddy_config:
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "drizzle-kit"
2
+
3
+ if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set")
4
+
5
+ export default defineConfig({
6
+ out: "./drizzle",
7
+ schema: "./src/db/schema.ts",
8
+ dialect: "postgresql",
9
+ dbCredentials: {
10
+ url: process.env.DATABASE_URL,
11
+ },
12
+ })
@@ -0,0 +1,4 @@
1
+ listen_addresses = '*'
2
+ wal_level = logical
3
+ max_replication_slots = 10
4
+ max_wal_senders = 10
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from "react"
2
+ import { useSyncExternalStore } from "react"
3
+
4
+ const emptySubscribe = () => () => {}
5
+
6
+ /**
7
+ * Renders children only on the client. During SSR, renders the fallback.
8
+ * Use this to wrap components that use useLiveQuery or collections
9
+ * when they need to be rendered from __root.tsx (which always SSRs).
10
+ *
11
+ * Uses useSyncExternalStore with getServerSnapshot=false so React
12
+ * correctly handles the server/client boundary without hydration mismatch.
13
+ */
14
+ export function ClientOnly({
15
+ children,
16
+ fallback,
17
+ }: {
18
+ children: () => ReactNode
19
+ fallback?: ReactNode
20
+ }) {
21
+ const isClient = useSyncExternalStore(
22
+ emptySubscribe,
23
+ () => true,
24
+ () => false,
25
+ )
26
+ return isClient ? children() : (fallback ?? null)
27
+ }
@@ -0,0 +1,7 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js"
2
+ import postgres from "postgres"
3
+ import * as schema from "./schema"
4
+
5
+ if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set")
6
+ const client = postgres(process.env.DATABASE_URL)
7
+ export const db = drizzle(client, { schema })
@@ -0,0 +1,14 @@
1
+ // Drizzle schema — the single source of truth for your data model.
2
+ // Define your tables here using pgTable(). The agent will fill this in.
3
+ //
4
+ // Example:
5
+ // import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core"
6
+ //
7
+ // export const todos = pgTable("todos", {
8
+ // id: uuid().primaryKey().defaultRandom(),
9
+ // text: text().notNull(),
10
+ // completed: boolean().notNull().default(false),
11
+ // createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
12
+ // })
13
+
14
+ export {}
@@ -0,0 +1,31 @@
1
+ import { sql } from "drizzle-orm"
2
+
3
+ /**
4
+ * Get the current Postgres transaction ID. Used to correlate
5
+ * optimistic updates with server-side writes via Electric sync.
6
+ */
7
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle transaction type varies by driver
8
+ export async function generateTxId(tx: any): Promise<number> {
9
+ const result = await tx.execute(sql`SELECT pg_current_xact_id()::text as txid`)
10
+ const txid = result[0]?.txid
11
+ if (txid === undefined) throw new Error("Failed to get transaction ID")
12
+ return parseInt(txid as string, 10)
13
+ }
14
+
15
+ /**
16
+ * Convert ISO date strings back to Date objects after JSON deserialization.
17
+ * Use this in mutation routes: `const data = parseDates(await request.json())`
18
+ *
19
+ * When collections send data via fetch(), JSON.stringify turns Date objects
20
+ * into ISO strings. Drizzle's timestamp columns expect Date objects, so
21
+ * passing the raw JSON to db.insert() crashes with "toISOString is not a function".
22
+ */
23
+ export function parseDates<T extends Record<string, unknown>>(obj: T): T {
24
+ const result = { ...obj }
25
+ for (const [key, value] of Object.entries(result)) {
26
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
27
+ ;(result as Record<string, unknown>)[key] = new Date(value)
28
+ }
29
+ }
30
+ return result as T
31
+ }
@@ -0,0 +1,14 @@
1
+ // Zod schemas derived from Drizzle tables.
2
+ // Never hand-write Zod schemas — always use createSelectSchema / createInsertSchema.
3
+ //
4
+ // Example:
5
+ // import { createSelectSchema, createInsertSchema } from "drizzle-zod"
6
+ // import { todos } from "./schema"
7
+ //
8
+ // export const todoSelectSchema = createSelectSchema(todos)
9
+ // export const todoInsertSchema = createInsertSchema(todos)
10
+ //
11
+ // export type Todo = typeof todoSelectSchema._type
12
+ // export type NewTodo = typeof todoInsertSchema._type
13
+
14
+ export {}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Electric shape proxy helpers.
3
+ * Used by API routes to forward shape requests to the Electric service.
4
+ *
5
+ * Supports both local Electric (Docker) and Electric Cloud.
6
+ * For Electric Cloud, set these environment variables:
7
+ * ELECTRIC_URL=https://api.electric-sql.cloud
8
+ * ELECTRIC_SOURCE_ID=<your-source-id>
9
+ * ELECTRIC_SECRET=<your-secret>
10
+ */
11
+
12
+ export function prepareElectricUrl(request: Request, tableName: string): string {
13
+ const electricUrl = process.env.ELECTRIC_URL || "http://localhost:3000"
14
+ const url = new URL(`${electricUrl}/v1/shape`)
15
+
16
+ // Forward Electric-specific query parameters
17
+ const requestUrl = new URL(request.url)
18
+ for (const [key, value] of requestUrl.searchParams) {
19
+ url.searchParams.set(key, value)
20
+ }
21
+
22
+ // Set the table name
23
+ url.searchParams.set("table", tableName)
24
+
25
+ // Add Electric Cloud auth if configured (server-side only, never exposed to browser)
26
+ if (process.env.ELECTRIC_SOURCE_ID && process.env.ELECTRIC_SECRET) {
27
+ url.searchParams.set("source_id", process.env.ELECTRIC_SOURCE_ID)
28
+ url.searchParams.set("secret", process.env.ELECTRIC_SECRET)
29
+ }
30
+
31
+ return url.toString()
32
+ }
33
+
34
+ export async function proxyElectricRequest(request: Request, tableName: string): Promise<Response> {
35
+ const url = prepareElectricUrl(request, tableName)
36
+
37
+ const response = await fetch(url)
38
+
39
+ return new Response(response.body, {
40
+ status: response.status,
41
+ headers: {
42
+ "Content-Type": response.headers.get("Content-Type") || "application/json",
43
+ "Cache-Control":
44
+ response.headers.get("Cache-Control") || "no-cache, no-store, must-revalidate",
45
+ ...(response.headers.get("Electric-Handle")
46
+ ? { "Electric-Handle": response.headers.get("Electric-Handle") as string }
47
+ : {}),
48
+ ...(response.headers.get("Electric-Offset")
49
+ ? { "Electric-Offset": response.headers.get("Electric-Offset") as string }
50
+ : {}),
51
+ ...(response.headers.get("Electric-Schema")
52
+ ? { "Electric-Schema": response.headers.get("Electric-Schema") as string }
53
+ : {}),
54
+ ...(response.headers.get("Electric-Cursor")
55
+ ? { "Electric-Cursor": response.headers.get("Electric-Cursor") as string }
56
+ : {}),
57
+ },
58
+ })
59
+ }
@@ -0,0 +1,106 @@
1
+ import type { ZodObject, ZodRawShape, ZodTypeAny } from "zod"
2
+
3
+ // Re-export parseDates so tests can import from the test helper
4
+ export { parseDates } from "@/db/utils"
5
+
6
+ /**
7
+ * Generate a valid row from a Zod schema by introspecting its shape.
8
+ * Produces type-appropriate default values for each field.
9
+ */
10
+ export function generateValidRow<T extends ZodRawShape>(
11
+ schema: ZodObject<T>,
12
+ ): Record<string, unknown> {
13
+ const shape = schema.shape
14
+ const row: Record<string, unknown> = {}
15
+
16
+ for (const [key, zodType] of Object.entries(shape)) {
17
+ row[key] = generateValueForType(key, zodType as ZodTypeAny)
18
+ }
19
+
20
+ return row
21
+ }
22
+
23
+ /**
24
+ * Generate a valid row with a specific field omitted.
25
+ * Useful for negative tests that verify required fields.
26
+ */
27
+ export function generateRowWithout<T extends ZodRawShape>(
28
+ schema: ZodObject<T>,
29
+ field: string,
30
+ ): Record<string, unknown> {
31
+ const row = generateValidRow(schema)
32
+ delete row[field]
33
+ return row
34
+ }
35
+
36
+ /**
37
+ * Resolve the type name from a Zod schema's internal _def.
38
+ * Handles both Zod v3 (_def.typeName = "ZodString") and
39
+ * Zod v4 (_def.type = "string") conventions.
40
+ */
41
+ function resolveTypeName(def: Record<string, unknown>): string | undefined {
42
+ // Zod v3: _def.typeName is PascalCase like "ZodString"
43
+ if (typeof def.typeName === "string") return def.typeName
44
+ // Zod v4: _def.type is lowercase like "string"
45
+ if (typeof def.type === "string") {
46
+ const t = def.type as string
47
+ return `Zod${t.charAt(0).toUpperCase()}${t.slice(1)}`
48
+ }
49
+ return undefined
50
+ }
51
+
52
+ function generateValueForType(key: string, zodType: ZodTypeAny): unknown {
53
+ // Unwrap optional/nullable/default wrappers to find the inner type
54
+ const inner = unwrap(zodType)
55
+ const typeName = inner._def ? resolveTypeName(inner._def as Record<string, unknown>) : undefined
56
+
57
+ // UUID fields — id, *Id (camelCase), or *_id (snake_case)
58
+ if (key === "id" || key.endsWith("Id") || key.endsWith("_id")) {
59
+ return crypto.randomUUID()
60
+ }
61
+
62
+ // Timestamp fields
63
+ if (key === "createdAt" || key === "updatedAt" || key.endsWith("_at") || key.endsWith("At")) {
64
+ return new Date()
65
+ }
66
+
67
+ switch (typeName) {
68
+ case "ZodString":
69
+ return `test-${key}`
70
+ case "ZodNumber":
71
+ case "ZodFloat":
72
+ return 0
73
+ case "ZodInt":
74
+ return 0
75
+ case "ZodBoolean":
76
+ return false
77
+ case "ZodDate":
78
+ return new Date()
79
+ case "ZodEnum":
80
+ // Return the first enum value
81
+ return inner._def?.values?.[0] ?? "unknown"
82
+ case "ZodArray":
83
+ return []
84
+ case "ZodUUID":
85
+ return crypto.randomUUID()
86
+ default:
87
+ return `test-${key}`
88
+ }
89
+ }
90
+
91
+ function unwrap(zodType: ZodTypeAny): ZodTypeAny {
92
+ const def = zodType._def as Record<string, unknown> | undefined
93
+ if (!def) return zodType
94
+
95
+ const typeName = resolveTypeName(def)
96
+ if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
97
+ return unwrap(def.innerType as ZodTypeAny)
98
+ }
99
+
100
+ // Zod v4 ZodUnion: unwrap to first option (e.g., z.union([z.date(), z.string()]) → z.date())
101
+ if (typeName === "ZodUnion" && Array.isArray(def.options)) {
102
+ return unwrap(def.options[0] as ZodTypeAny)
103
+ }
104
+
105
+ return zodType
106
+ }
@@ -0,0 +1,7 @@
1
+ import path from "node:path"
2
+ import { defineConfig } from "vitest/config"
3
+
4
+ export default defineConfig({
5
+ resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
6
+ test: { globals: true, environment: "node" },
7
+ })