@rudderhq/agent-runtime-codex-local 0.6.6-canary.8 → 0.7.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/server/execute.js +1 -1
- package/dist/server/execute.js.map +1 -1
- package/package.json +3 -3
- package/skills/app-builder/SKILL.md +104 -0
- package/skills/app-builder/agents/openai.yaml +14 -0
- package/skills/app-builder/assets/scaffold/.env.example +3 -0
- package/skills/app-builder/assets/scaffold/app/api/%5F%5Frudder/health/route.ts +20 -0
- package/skills/app-builder/assets/scaffold/app/api/contacts/[id]/route.ts +39 -0
- package/skills/app-builder/assets/scaffold/app/api/contacts/route.ts +31 -0
- package/skills/app-builder/assets/scaffold/app/api/data/export/route.ts +19 -0
- package/skills/app-builder/assets/scaffold/app/api/data/import/route.ts +40 -0
- package/skills/app-builder/assets/scaffold/app/globals.css +32 -0
- package/skills/app-builder/assets/scaffold/app/layout.tsx +15 -0
- package/skills/app-builder/assets/scaffold/app/page.tsx +9 -0
- package/skills/app-builder/assets/scaffold/components/contacts-workspace.tsx +292 -0
- package/skills/app-builder/assets/scaffold/components/ui/button.tsx +42 -0
- package/skills/app-builder/assets/scaffold/components/ui/card.tsx +19 -0
- package/skills/app-builder/assets/scaffold/components/ui/input.tsx +17 -0
- package/skills/app-builder/assets/scaffold/components/ui/label.tsx +6 -0
- package/skills/app-builder/assets/scaffold/data/.gitkeep +1 -0
- package/skills/app-builder/assets/scaffold/drizzle.config.ts +18 -0
- package/skills/app-builder/assets/scaffold/instrumentation.ts +5 -0
- package/skills/app-builder/assets/scaffold/lib/data-transfer.ts +44 -0
- package/skills/app-builder/assets/scaffold/lib/db/client.ts +78 -0
- package/skills/app-builder/assets/scaffold/lib/db/schema.ts +35 -0
- package/skills/app-builder/assets/scaffold/lib/domain.ts +18 -0
- package/skills/app-builder/assets/scaffold/lib/jobs/runner.ts +56 -0
- package/skills/app-builder/assets/scaffold/lib/utils.ts +6 -0
- package/skills/app-builder/assets/scaffold/migrations/0000_app_builder_foundation.sql +27 -0
- package/skills/app-builder/assets/scaffold/migrations/meta/_journal.json +13 -0
- package/skills/app-builder/assets/scaffold/next-env.d.ts +6 -0
- package/skills/app-builder/assets/scaffold/next.config.ts +8 -0
- package/skills/app-builder/assets/scaffold/package.json +47 -0
- package/skills/app-builder/assets/scaffold/playwright.config.ts +30 -0
- package/skills/app-builder/assets/scaffold/pnpm-lock.yaml +2687 -0
- package/skills/app-builder/assets/scaffold/postcss.config.mjs +5 -0
- package/skills/app-builder/assets/scaffold/rudder.app.json +32 -0
- package/skills/app-builder/assets/scaffold/scripts/migrate.ts +21 -0
- package/skills/app-builder/assets/scaffold/scripts/seed.ts +31 -0
- package/skills/app-builder/assets/scaffold/scripts/snapshot.ts +17 -0
- package/skills/app-builder/assets/scaffold/tests/e2e/app.spec.ts +50 -0
- package/skills/app-builder/assets/scaffold/tests/unit/data-transfer.test.ts +28 -0
- package/skills/app-builder/assets/scaffold/tests/unit/domain.test.ts +23 -0
- package/skills/app-builder/assets/scaffold/tsconfig.json +41 -0
- package/skills/app-builder/assets/scaffold/vitest.config.ts +14 -0
- package/skills/app-builder/evals/evals.json +65 -0
- package/skills/app-builder/references/data-safety.md +38 -0
- package/skills/app-builder/references/design-guidelines.md +20 -0
- package/skills/app-builder/references/migrations-and-promotion.md +36 -0
- package/skills/app-builder/references/scaffold-contract.md +77 -0
- package/skills/app-builder/references/verification.md +23 -0
- package/skills/app-builder/scripts/scaffold.mjs +58 -0
- package/skills/app-builder/scripts/validate-manifest.mjs +63 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Label } from "@/components/ui/label";
|
|
7
|
+
import { Download, Plus, Search, Trash2, Upload } from "lucide-react";
|
|
8
|
+
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
type Contact = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
company: string;
|
|
15
|
+
status: "new" | "contacted" | "replied" | "paused";
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Draft = {
|
|
21
|
+
name: string;
|
|
22
|
+
email: string;
|
|
23
|
+
company: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const emptyDraft: Draft = { name: "", email: "", company: "" };
|
|
27
|
+
|
|
28
|
+
export function ContactsWorkspace() {
|
|
29
|
+
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
30
|
+
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
|
31
|
+
const [query, setQuery] = useState("");
|
|
32
|
+
const [loading, setLoading] = useState(true);
|
|
33
|
+
const [saving, setSaving] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const importRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
|
|
37
|
+
const loadContacts = useCallback(async () => {
|
|
38
|
+
setLoading(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch("/api/contacts", { cache: "no-store" });
|
|
42
|
+
if (!response.ok) throw new Error("Could not load contacts.");
|
|
43
|
+
const body = await response.json() as { contacts: Contact[] };
|
|
44
|
+
setContacts(body.contacts);
|
|
45
|
+
} catch (reason) {
|
|
46
|
+
setError(reason instanceof Error ? reason.message : "Could not load contacts.");
|
|
47
|
+
} finally {
|
|
48
|
+
setLoading(false);
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
void loadContacts();
|
|
54
|
+
}, [loadContacts]);
|
|
55
|
+
|
|
56
|
+
const visibleContacts = useMemo(() => {
|
|
57
|
+
const normalized = query.trim().toLowerCase();
|
|
58
|
+
if (!normalized) return contacts;
|
|
59
|
+
return contacts.filter((contact) => (
|
|
60
|
+
`${contact.name} ${contact.email} ${contact.company} ${contact.status}`
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.includes(normalized)
|
|
63
|
+
));
|
|
64
|
+
}, [contacts, query]);
|
|
65
|
+
|
|
66
|
+
async function createContact(event: FormEvent) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
setSaving(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch("/api/contacts", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify(draft),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const body = await response.json().catch(() => null) as { error?: string } | null;
|
|
78
|
+
throw new Error(body?.error === "invalid_contact"
|
|
79
|
+
? "Enter a name and a valid email address."
|
|
80
|
+
: "Could not create the contact.");
|
|
81
|
+
}
|
|
82
|
+
setDraft(emptyDraft);
|
|
83
|
+
await loadContacts();
|
|
84
|
+
} catch (reason) {
|
|
85
|
+
setError(reason instanceof Error ? reason.message : "Could not create the contact.");
|
|
86
|
+
} finally {
|
|
87
|
+
setSaving(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function deleteContact(contact: Contact) {
|
|
92
|
+
if (!window.confirm(`Delete ${contact.name}? This cannot be undone.`)) return;
|
|
93
|
+
setError(null);
|
|
94
|
+
const response = await fetch(`/api/contacts/${contact.id}`, { method: "DELETE" });
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
setError("Could not delete the contact.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setContacts((current) => current.filter((candidate) => candidate.id !== contact.id));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function importData(file: File) {
|
|
103
|
+
setError(null);
|
|
104
|
+
try {
|
|
105
|
+
const payload = JSON.parse(await file.text());
|
|
106
|
+
const response = await fetch("/api/data/import", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) throw new Error("The import file is invalid or incompatible.");
|
|
112
|
+
await loadContacts();
|
|
113
|
+
} catch (reason) {
|
|
114
|
+
setError(reason instanceof Error ? reason.message : "Could not import this file.");
|
|
115
|
+
} finally {
|
|
116
|
+
if (importRef.current) importRef.current.value = "";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="mx-auto grid max-w-7xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:px-8">
|
|
122
|
+
<section className="min-w-0">
|
|
123
|
+
<header className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
124
|
+
<div>
|
|
125
|
+
<p className="mb-1 text-sm font-medium text-[var(--primary)]">Contacts</p>
|
|
126
|
+
<h1 className="m-0 text-2xl font-semibold tracking-tight">Customer workspace</h1>
|
|
127
|
+
<p className="mb-0 mt-2 text-sm text-[var(--muted-foreground)]">
|
|
128
|
+
Keep the next conversation visible and every record on this device.
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex flex-wrap gap-2">
|
|
132
|
+
<input
|
|
133
|
+
ref={importRef}
|
|
134
|
+
aria-label="Import app data"
|
|
135
|
+
className="sr-only"
|
|
136
|
+
type="file"
|
|
137
|
+
accept="application/json,.json"
|
|
138
|
+
onChange={(event) => {
|
|
139
|
+
const file = event.currentTarget.files?.[0];
|
|
140
|
+
if (file) void importData(file);
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
<Button type="button" variant="outline" onClick={() => importRef.current?.click()}>
|
|
144
|
+
<Upload aria-hidden size={16} />
|
|
145
|
+
Import
|
|
146
|
+
</Button>
|
|
147
|
+
<Button asChild variant="outline">
|
|
148
|
+
<a href="/api/data/export" download>
|
|
149
|
+
<Download aria-hidden size={16} />
|
|
150
|
+
Export
|
|
151
|
+
</a>
|
|
152
|
+
</Button>
|
|
153
|
+
</div>
|
|
154
|
+
</header>
|
|
155
|
+
|
|
156
|
+
<Card>
|
|
157
|
+
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
158
|
+
<div>
|
|
159
|
+
<h2 className="m-0 text-base font-semibold">All contacts</h2>
|
|
160
|
+
<p className="mb-0 mt-1 text-sm text-[var(--muted-foreground)]">
|
|
161
|
+
{contacts.length} {contacts.length === 1 ? "record" : "records"}
|
|
162
|
+
</p>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="relative w-full sm:w-72">
|
|
165
|
+
<Search
|
|
166
|
+
aria-hidden
|
|
167
|
+
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]"
|
|
168
|
+
size={16}
|
|
169
|
+
/>
|
|
170
|
+
<Input
|
|
171
|
+
aria-label="Search contacts"
|
|
172
|
+
className="pl-9"
|
|
173
|
+
placeholder="Search name, email, company…"
|
|
174
|
+
value={query}
|
|
175
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
</CardHeader>
|
|
179
|
+
<div aria-live="polite">
|
|
180
|
+
{loading ? (
|
|
181
|
+
<p className="p-8 text-center text-sm text-[var(--muted-foreground)]">
|
|
182
|
+
Loading contacts…
|
|
183
|
+
</p>
|
|
184
|
+
) : visibleContacts.length === 0 ? (
|
|
185
|
+
<div className="p-10 text-center">
|
|
186
|
+
<h3 className="m-0 text-base font-semibold">
|
|
187
|
+
{contacts.length === 0 ? "No contacts yet" : "No matching contacts"}
|
|
188
|
+
</h3>
|
|
189
|
+
<p className="mb-0 mt-2 text-sm text-[var(--muted-foreground)]">
|
|
190
|
+
{contacts.length === 0
|
|
191
|
+
? "Add the first contact with the form beside this list."
|
|
192
|
+
: "Try a different name, email, company, or status."}
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
) : (
|
|
196
|
+
<div className="overflow-x-auto">
|
|
197
|
+
<table className="w-full min-w-[42rem] border-collapse text-left text-sm">
|
|
198
|
+
<thead className="bg-[var(--muted)] text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
|
199
|
+
<tr>
|
|
200
|
+
<th className="px-5 py-3 font-medium" scope="col">Contact</th>
|
|
201
|
+
<th className="px-5 py-3 font-medium" scope="col">Company</th>
|
|
202
|
+
<th className="px-5 py-3 font-medium" scope="col">Status</th>
|
|
203
|
+
<th className="px-5 py-3 text-right font-medium" scope="col">Actions</th>
|
|
204
|
+
</tr>
|
|
205
|
+
</thead>
|
|
206
|
+
<tbody>
|
|
207
|
+
{visibleContacts.map((contact) => (
|
|
208
|
+
<tr className="border-t" key={contact.id}>
|
|
209
|
+
<td className="px-5 py-4">
|
|
210
|
+
<strong className="block font-medium">{contact.name}</strong>
|
|
211
|
+
<span className="text-[var(--muted-foreground)]">{contact.email}</span>
|
|
212
|
+
</td>
|
|
213
|
+
<td className="px-5 py-4">{contact.company || "—"}</td>
|
|
214
|
+
<td className="px-5 py-4">
|
|
215
|
+
<span className="rounded-full bg-[var(--muted)] px-2.5 py-1 text-xs font-medium capitalize">
|
|
216
|
+
{contact.status}
|
|
217
|
+
</span>
|
|
218
|
+
</td>
|
|
219
|
+
<td className="px-5 py-4 text-right">
|
|
220
|
+
<Button
|
|
221
|
+
aria-label={`Delete ${contact.name}`}
|
|
222
|
+
size="sm"
|
|
223
|
+
type="button"
|
|
224
|
+
variant="ghost"
|
|
225
|
+
onClick={() => void deleteContact(contact)}
|
|
226
|
+
>
|
|
227
|
+
<Trash2 aria-hidden size={16} />
|
|
228
|
+
</Button>
|
|
229
|
+
</td>
|
|
230
|
+
</tr>
|
|
231
|
+
))}
|
|
232
|
+
</tbody>
|
|
233
|
+
</table>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
</Card>
|
|
238
|
+
</section>
|
|
239
|
+
|
|
240
|
+
<aside>
|
|
241
|
+
<Card>
|
|
242
|
+
<CardHeader>
|
|
243
|
+
<h2 className="m-0 flex items-center gap-2 text-base font-semibold">
|
|
244
|
+
<Plus aria-hidden size={18} />
|
|
245
|
+
Add contact
|
|
246
|
+
</h2>
|
|
247
|
+
</CardHeader>
|
|
248
|
+
<CardContent>
|
|
249
|
+
<form className="grid gap-4" onSubmit={(event) => void createContact(event)}>
|
|
250
|
+
<div className="grid gap-1.5">
|
|
251
|
+
<Label htmlFor="contact-name">Name</Label>
|
|
252
|
+
<Input
|
|
253
|
+
id="contact-name"
|
|
254
|
+
required
|
|
255
|
+
autoComplete="name"
|
|
256
|
+
value={draft.name}
|
|
257
|
+
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
<div className="grid gap-1.5">
|
|
261
|
+
<Label htmlFor="contact-email">Email</Label>
|
|
262
|
+
<Input
|
|
263
|
+
id="contact-email"
|
|
264
|
+
required
|
|
265
|
+
autoComplete="email"
|
|
266
|
+
type="email"
|
|
267
|
+
value={draft.email}
|
|
268
|
+
onChange={(event) => setDraft({ ...draft, email: event.target.value })}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
<div className="grid gap-1.5">
|
|
272
|
+
<Label htmlFor="contact-company">Company</Label>
|
|
273
|
+
<Input
|
|
274
|
+
id="contact-company"
|
|
275
|
+
autoComplete="organization"
|
|
276
|
+
value={draft.company}
|
|
277
|
+
onChange={(event) => setDraft({ ...draft, company: event.target.value })}
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
{error ? (
|
|
281
|
+
<p className="m-0 text-sm text-[var(--destructive)]" role="alert">{error}</p>
|
|
282
|
+
) : null}
|
|
283
|
+
<Button disabled={saving} type="submit">
|
|
284
|
+
{saving ? "Adding…" : "Add contact"}
|
|
285
|
+
</Button>
|
|
286
|
+
</form>
|
|
287
|
+
</CardContent>
|
|
288
|
+
</Card>
|
|
289
|
+
</aside>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-[var(--primary)] text-[var(--primary-foreground)] hover:brightness-95",
|
|
12
|
+
outline: "border bg-[var(--card)] hover:bg-[var(--muted)]",
|
|
13
|
+
ghost: "hover:bg-[var(--muted)]",
|
|
14
|
+
destructive: "bg-[var(--destructive)] text-white hover:brightness-95",
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
default: "h-10",
|
|
18
|
+
sm: "h-9 px-3",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: {
|
|
22
|
+
variant: "default",
|
|
23
|
+
size: "default",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface ButtonProps
|
|
29
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
30
|
+
VariantProps<typeof buttonVariants> {
|
|
31
|
+
asChild?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
|
|
35
|
+
const Component = asChild ? Slot : "button";
|
|
36
|
+
return (
|
|
37
|
+
<Component
|
|
38
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
5
|
+
return (
|
|
6
|
+
<section
|
|
7
|
+
className={cn("rounded-xl border bg-[var(--card)] text-[var(--card-foreground)] shadow-sm", className)}
|
|
8
|
+
{...props}
|
|
9
|
+
/>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
14
|
+
return <div className={cn("border-b px-5 py-4", className)} {...props} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
18
|
+
return <div className={cn("p-5", className)} {...props} />;
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
export const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|
5
|
+
({ className, type, ...props }, ref) => (
|
|
6
|
+
<input
|
|
7
|
+
ref={ref}
|
|
8
|
+
type={type}
|
|
9
|
+
className={cn(
|
|
10
|
+
"flex h-10 w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none placeholder:text-[var(--muted-foreground)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
Input.displayName = "Input";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const dataDir = process.env.RUDDER_APP_DATA_DIR
|
|
5
|
+
? path.resolve(process.env.RUDDER_APP_DATA_DIR)
|
|
6
|
+
: path.resolve("data");
|
|
7
|
+
const mode = process.env.RUDDER_APP_DATA_MODE === "production"
|
|
8
|
+
? "app.sqlite"
|
|
9
|
+
: "dev.sqlite";
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
dialect: "sqlite",
|
|
13
|
+
schema: "./lib/db/schema.ts",
|
|
14
|
+
out: "./migrations",
|
|
15
|
+
dbCredentials: {
|
|
16
|
+
url: path.join(dataDir, mode),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { contactStatusSchema } from "./domain";
|
|
3
|
+
|
|
4
|
+
export const exportedContactSchema = z.object({
|
|
5
|
+
id: z.string().uuid(),
|
|
6
|
+
name: z.string().min(1).max(120),
|
|
7
|
+
email: z.email().max(320),
|
|
8
|
+
company: z.string().max(160),
|
|
9
|
+
status: contactStatusSchema,
|
|
10
|
+
createdAt: z.iso.datetime(),
|
|
11
|
+
updatedAt: z.iso.datetime(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const importEnvelopeSchema = z.object({
|
|
15
|
+
format: z.literal("rudder-app-data/v1"),
|
|
16
|
+
exportedAt: z.iso.datetime(),
|
|
17
|
+
data: z.object({
|
|
18
|
+
contacts: z.array(exportedContactSchema).max(100_000),
|
|
19
|
+
}),
|
|
20
|
+
}).strict();
|
|
21
|
+
|
|
22
|
+
type ContactRow = {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
email: string;
|
|
26
|
+
company: string;
|
|
27
|
+
status: "new" | "contacted" | "replied" | "paused";
|
|
28
|
+
createdAt: Date;
|
|
29
|
+
updatedAt: Date;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function buildExportEnvelope(rows: ContactRow[]) {
|
|
33
|
+
return {
|
|
34
|
+
format: "rudder-app-data/v1" as const,
|
|
35
|
+
exportedAt: new Date().toISOString(),
|
|
36
|
+
data: {
|
|
37
|
+
contacts: rows.map((row) => ({
|
|
38
|
+
...row,
|
|
39
|
+
createdAt: row.createdAt.toISOString(),
|
|
40
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
41
|
+
})),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
drizzle,
|
|
3
|
+
type SqliteRemoteDatabase,
|
|
4
|
+
} from "drizzle-orm/sqlite-proxy";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
|
8
|
+
import * as schema from "./schema";
|
|
9
|
+
|
|
10
|
+
type DatabaseHandle = {
|
|
11
|
+
sqlite: DatabaseSync;
|
|
12
|
+
db: SqliteRemoteDatabase<typeof schema>;
|
|
13
|
+
filePath: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const handles = new Map<string, DatabaseHandle>();
|
|
17
|
+
|
|
18
|
+
export function dataMode() {
|
|
19
|
+
return process.env.RUDDER_APP_DATA_MODE === "production"
|
|
20
|
+
? "production"
|
|
21
|
+
: "development";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function dataDirectory() {
|
|
25
|
+
return process.env.RUDDER_APP_DATA_DIR
|
|
26
|
+
? path.resolve(process.env.RUDDER_APP_DATA_DIR)
|
|
27
|
+
: path.resolve("data");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function databasePath() {
|
|
31
|
+
if (process.env.RUDDER_APP_DATA_DIR) {
|
|
32
|
+
return path.join(
|
|
33
|
+
dataDirectory(),
|
|
34
|
+
dataMode() === "production" ? "app.sqlite" : "dev.sqlite",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return path.join(
|
|
38
|
+
dataDirectory(),
|
|
39
|
+
dataMode() === "production" ? "production/app.sqlite" : "development/dev.sqlite",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getDatabase(): DatabaseHandle {
|
|
44
|
+
const filePath = databasePath();
|
|
45
|
+
const cached = handles.get(filePath);
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
|
|
48
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
49
|
+
const sqlite = new DatabaseSync(filePath);
|
|
50
|
+
sqlite.exec("PRAGMA journal_mode = WAL");
|
|
51
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
52
|
+
sqlite.exec("PRAGMA busy_timeout = 5000");
|
|
53
|
+
const db = drizzle(async (query, parameters, method): Promise<{ rows: unknown[] }> => {
|
|
54
|
+
const statement = sqlite.prepare(query);
|
|
55
|
+
statement.setReturnArrays(true);
|
|
56
|
+
const values = parameters as SQLInputValue[];
|
|
57
|
+
if (method === "run") {
|
|
58
|
+
statement.run(...values);
|
|
59
|
+
return { rows: [] };
|
|
60
|
+
}
|
|
61
|
+
if (method === "get") {
|
|
62
|
+
return { rows: statement.get(...values) as unknown as unknown[] };
|
|
63
|
+
}
|
|
64
|
+
return { rows: statement.all(...values) as unknown as unknown[][] };
|
|
65
|
+
}, { schema });
|
|
66
|
+
const handle: DatabaseHandle = {
|
|
67
|
+
sqlite,
|
|
68
|
+
db,
|
|
69
|
+
filePath,
|
|
70
|
+
};
|
|
71
|
+
handles.set(filePath, handle);
|
|
72
|
+
return handle;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function closeDatabases() {
|
|
76
|
+
for (const handle of handles.values()) handle.sqlite.close();
|
|
77
|
+
handles.clear();
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
export const contacts = sqliteTable("contacts", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
name: text("name").notNull(),
|
|
6
|
+
email: text("email").notNull(),
|
|
7
|
+
company: text("company").notNull().default(""),
|
|
8
|
+
status: text("status", { enum: ["new", "contacted", "replied", "paused"] })
|
|
9
|
+
.notNull()
|
|
10
|
+
.default("new"),
|
|
11
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
|
12
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
|
13
|
+
}, (table) => [
|
|
14
|
+
uniqueIndex("contacts_email_uq").on(table.email),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const jobs = sqliteTable("jobs", {
|
|
18
|
+
id: text("id").primaryKey(),
|
|
19
|
+
kind: text("kind").notNull(),
|
|
20
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
21
|
+
payloadJson: text("payload_json").notNull().default("{}"),
|
|
22
|
+
status: text("status", { enum: ["pending", "running", "completed", "failed", "missed"] })
|
|
23
|
+
.notNull()
|
|
24
|
+
.default("pending"),
|
|
25
|
+
catchUpPolicy: text("catch_up_policy", { enum: ["run", "skip", "prompt"] })
|
|
26
|
+
.notNull()
|
|
27
|
+
.default("prompt"),
|
|
28
|
+
scheduledFor: integer("scheduled_for", { mode: "timestamp_ms" }).notNull(),
|
|
29
|
+
attempts: integer("attempts").notNull().default(0),
|
|
30
|
+
lastError: text("last_error"),
|
|
31
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
|
32
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
|
33
|
+
}, (table) => [
|
|
34
|
+
uniqueIndex("jobs_idempotency_key_uq").on(table.idempotencyKey),
|
|
35
|
+
]);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const contactStatusSchema = z.enum(["new", "contacted", "replied", "paused"]);
|
|
4
|
+
|
|
5
|
+
export const contactCreateSchema = z.object({
|
|
6
|
+
name: z.string().trim().min(1).max(120),
|
|
7
|
+
email: z.email().max(320),
|
|
8
|
+
company: z.string().trim().max(160).default(""),
|
|
9
|
+
status: contactStatusSchema.default("new"),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const contactUpdateSchema = contactCreateSchema.partial().refine(
|
|
13
|
+
(value) => Object.keys(value).length > 0,
|
|
14
|
+
"At least one contact field is required",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export type ContactInput = z.infer<typeof contactCreateSchema>;
|
|
18
|
+
export type ContactStatus = z.infer<typeof contactStatusSchema>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getDatabase } from "@/lib/db/client";
|
|
2
|
+
|
|
3
|
+
type RunnerState = {
|
|
4
|
+
timer: NodeJS.Timeout | null;
|
|
5
|
+
ticking: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const globalState = globalThis as typeof globalThis & {
|
|
9
|
+
__rudderAppJobRunner?: RunnerState;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function state(): RunnerState {
|
|
13
|
+
globalState.__rudderAppJobRunner ??= { timer: null, ticking: false };
|
|
14
|
+
return globalState.__rudderAppJobRunner;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function tick() {
|
|
18
|
+
const current = state();
|
|
19
|
+
if (current.ticking) return;
|
|
20
|
+
current.ticking = true;
|
|
21
|
+
try {
|
|
22
|
+
const { sqlite } = getDatabase();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
sqlite.prepare(`
|
|
25
|
+
update jobs
|
|
26
|
+
set status = case catch_up_policy
|
|
27
|
+
when 'skip' then 'missed'
|
|
28
|
+
when 'run' then 'pending'
|
|
29
|
+
else 'missed'
|
|
30
|
+
end,
|
|
31
|
+
updated_at = ?
|
|
32
|
+
where status = 'pending' and scheduled_for < ?
|
|
33
|
+
`).run(now, now - 60_000);
|
|
34
|
+
// Domain-specific handlers should claim one pending job transactionally,
|
|
35
|
+
// execute it with the persisted idempotency key, and then complete/fail it.
|
|
36
|
+
} catch {
|
|
37
|
+
// The health endpoint remains authoritative. A missing pre-migration table
|
|
38
|
+
// must not create an unhandled background rejection during startup.
|
|
39
|
+
} finally {
|
|
40
|
+
current.ticking = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startJobRunner() {
|
|
45
|
+
const current = state();
|
|
46
|
+
if (current.timer) return;
|
|
47
|
+
current.timer = setInterval(() => void tick(), 15_000);
|
|
48
|
+
current.timer.unref();
|
|
49
|
+
void tick();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function stopJobRunner() {
|
|
53
|
+
const current = state();
|
|
54
|
+
if (current.timer) clearInterval(current.timer);
|
|
55
|
+
current.timer = null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
CREATE TABLE `contacts` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`name` text NOT NULL,
|
|
4
|
+
`email` text NOT NULL,
|
|
5
|
+
`company` text DEFAULT '' NOT NULL,
|
|
6
|
+
`status` text DEFAULT 'new' NOT NULL,
|
|
7
|
+
`created_at` integer NOT NULL,
|
|
8
|
+
`updated_at` integer NOT NULL
|
|
9
|
+
);
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
CREATE UNIQUE INDEX `contacts_email_uq` ON `contacts` (`email`);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
CREATE TABLE `jobs` (
|
|
14
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
15
|
+
`kind` text NOT NULL,
|
|
16
|
+
`idempotency_key` text NOT NULL,
|
|
17
|
+
`payload_json` text DEFAULT '{}' NOT NULL,
|
|
18
|
+
`status` text DEFAULT 'pending' NOT NULL,
|
|
19
|
+
`catch_up_policy` text DEFAULT 'prompt' NOT NULL,
|
|
20
|
+
`scheduled_for` integer NOT NULL,
|
|
21
|
+
`attempts` integer DEFAULT 0 NOT NULL,
|
|
22
|
+
`last_error` text,
|
|
23
|
+
`created_at` integer NOT NULL,
|
|
24
|
+
`updated_at` integer NOT NULL
|
|
25
|
+
);
|
|
26
|
+
--> statement-breakpoint
|
|
27
|
+
CREATE UNIQUE INDEX `jobs_idempotency_key_uq` ON `jobs` (`idempotency_key`);
|