@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.
- package/dist/agents/clarifier.d.ts +16 -0
- package/dist/agents/clarifier.d.ts.map +1 -0
- package/dist/agents/clarifier.js +158 -0
- package/dist/agents/clarifier.js.map +1 -0
- package/dist/agents/coder.d.ts +14 -0
- package/dist/agents/coder.d.ts.map +1 -0
- package/dist/agents/coder.js +126 -0
- package/dist/agents/coder.js.map +1 -0
- package/dist/agents/planner.d.ts +6 -0
- package/dist/agents/planner.d.ts.map +1 -0
- package/dist/agents/planner.js +69 -0
- package/dist/agents/planner.js.map +1 -0
- package/dist/agents/prompts.d.ts +9 -0
- package/dist/agents/prompts.d.ts.map +1 -0
- package/dist/agents/prompts.js +231 -0
- package/dist/agents/prompts.js.map +1 -0
- package/dist/cli/headless.d.ts +9 -0
- package/dist/cli/headless.d.ts.map +1 -0
- package/dist/cli/headless.js +506 -0
- package/dist/cli/headless.js.map +1 -0
- package/dist/cli/serve.d.ts +6 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +113 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/engine/message-parser.d.ts +8 -0
- package/dist/engine/message-parser.d.ts.map +1 -0
- package/dist/engine/message-parser.js +106 -0
- package/dist/engine/message-parser.js.map +1 -0
- package/dist/engine/orchestrator.d.ts +50 -0
- package/dist/engine/orchestrator.d.ts.map +1 -0
- package/dist/engine/orchestrator.js +492 -0
- package/dist/engine/orchestrator.js.map +1 -0
- package/dist/engine/stdio-adapter.d.ts +24 -0
- package/dist/engine/stdio-adapter.d.ts.map +1 -0
- package/dist/engine/stdio-adapter.js +139 -0
- package/dist/engine/stdio-adapter.js.map +1 -0
- package/dist/engine/stream-adapter.d.ts +45 -0
- package/dist/engine/stream-adapter.d.ts.map +1 -0
- package/dist/engine/stream-adapter.js +154 -0
- package/dist/engine/stream-adapter.js.map +1 -0
- package/dist/find-env.d.ts +3 -0
- package/dist/find-env.d.ts.map +1 -0
- package/dist/find-env.js +16 -0
- package/dist/find-env.js.map +1 -0
- package/dist/git/index.d.ts +114 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/index.js +434 -0
- package/dist/git/index.js.map +1 -0
- package/dist/hooks/block-bash.d.ts +7 -0
- package/dist/hooks/block-bash.d.ts.map +1 -0
- package/dist/hooks/block-bash.js +15 -0
- package/dist/hooks/block-bash.js.map +1 -0
- package/dist/hooks/dependency-guard.d.ts +7 -0
- package/dist/hooks/dependency-guard.d.ts.map +1 -0
- package/dist/hooks/dependency-guard.js +43 -0
- package/dist/hooks/dependency-guard.js.map +1 -0
- package/dist/hooks/guardrail-inject.d.ts +17 -0
- package/dist/hooks/guardrail-inject.d.ts.map +1 -0
- package/dist/hooks/guardrail-inject.js +69 -0
- package/dist/hooks/guardrail-inject.js.map +1 -0
- package/dist/hooks/import-validation.d.ts +7 -0
- package/dist/hooks/import-validation.d.ts.map +1 -0
- package/dist/hooks/import-validation.js +192 -0
- package/dist/hooks/import-validation.js.map +1 -0
- package/dist/hooks/index.d.ts +15 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +42 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/migration-validation.d.ts +9 -0
- package/dist/hooks/migration-validation.d.ts.map +1 -0
- package/dist/hooks/migration-validation.js +62 -0
- package/dist/hooks/migration-validation.js.map +1 -0
- package/dist/hooks/schema-consistency.d.ts +12 -0
- package/dist/hooks/schema-consistency.d.ts.map +1 -0
- package/dist/hooks/schema-consistency.js +72 -0
- package/dist/hooks/schema-consistency.js.map +1 -0
- package/dist/hooks/write-protection.d.ts +7 -0
- package/dist/hooks/write-protection.d.ts.map +1 -0
- package/dist/hooks/write-protection.js +33 -0
- package/dist/hooks/write-protection.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/progress/reporter.d.ts +15 -0
- package/dist/progress/reporter.d.ts.map +1 -0
- package/dist/progress/reporter.js +133 -0
- package/dist/progress/reporter.js.map +1 -0
- package/dist/scaffold/index.d.ts +23 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +315 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/tools/build.d.ts +3 -0
- package/dist/tools/build.d.ts.map +1 -0
- package/dist/tools/build.js +84 -0
- package/dist/tools/build.js.map +1 -0
- package/dist/tools/playbook.d.ts +14 -0
- package/dist/tools/playbook.d.ts.map +1 -0
- package/dist/tools/playbook.js +239 -0
- package/dist/tools/playbook.js.map +1 -0
- package/dist/tools/server.d.ts +3 -0
- package/dist/tools/server.d.ts.map +1 -0
- package/dist/tools/server.js +13 -0
- package/dist/tools/server.js.map +1 -0
- package/dist/working-memory/errors.d.ts +14 -0
- package/dist/working-memory/errors.d.ts.map +1 -0
- package/dist/working-memory/errors.js +89 -0
- package/dist/working-memory/errors.js.map +1 -0
- package/dist/working-memory/session.d.ts +12 -0
- package/dist/working-memory/session.d.ts.map +1 -0
- package/dist/working-memory/session.js +71 -0
- package/dist/working-memory/session.js.map +1 -0
- package/package.json +50 -0
- package/playbooks/electric-app-guardrails/SKILL.md +255 -0
- package/template/.env.example +2 -0
- package/template/Caddyfile +11 -0
- package/template/docker-compose.yml +47 -0
- package/template/drizzle.config.ts +12 -0
- package/template/postgres.conf +4 -0
- package/template/src/components/ClientOnly.tsx +27 -0
- package/template/src/db/index.ts +7 -0
- package/template/src/db/schema.ts +14 -0
- package/template/src/db/utils.ts +31 -0
- package/template/src/db/zod-schemas.ts +14 -0
- package/template/src/lib/electric-proxy.ts +59 -0
- package/template/tests/helpers/schema-test-utils.ts +106 -0
- 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,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,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
|
+
}
|