Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: allow multiple record ops per automation

Hugo 2c896cd2 53242dd5

+639 -87
+1 -1
Dockerfile
··· 21 21 22 22 EXPOSE 5175 23 23 24 - CMD ["sh", "-c", "bun run lib/db/migrate.ts && bun run app/server.ts"] 24 + CMD ["sh", "-c", "bun run lib/db/migrate.ts && bun run dist/server.js"]
+48
app/islands/AutomationForm.css.ts
··· 16 16 gap: space[1], 17 17 }); 18 18 19 + export const operationCheckboxes = style({ 20 + display: "flex", 21 + gap: space[4], 22 + }); 23 + 24 + export const checkbox = style({ 25 + appearance: "none", 26 + width: "18px", 27 + height: "18px", 28 + borderRadius: radii.sm, 29 + border: `1.5px solid ${vars.color.border}`, 30 + backgroundColor: "transparent", 31 + cursor: "pointer", 32 + position: "relative", 33 + flexShrink: 0, 34 + transition: "background-color 0.15s, border-color 0.15s", 35 + selectors: { 36 + "&:checked": { 37 + backgroundColor: vars.color.accent, 38 + borderColor: vars.color.accent, 39 + }, 40 + "&:checked::after": { 41 + content: '""', 42 + position: "absolute", 43 + insetBlockStart: "50%", 44 + insetInlineStart: "50%", 45 + width: "5px", 46 + height: "9px", 47 + border: `2px solid ${vars.color.accentText}`, 48 + borderBlockStart: "none", 49 + borderInlineStart: "none", 50 + transform: "translate(-50%, -60%) rotate(45deg)", 51 + }, 52 + "&:hover:not(:checked)": { 53 + borderColor: vars.color.text, 54 + }, 55 + }, 56 + }); 57 + 58 + export const checkboxLabel = style({ 59 + display: "flex", 60 + alignItems: "center", 61 + gap: space[2], 62 + fontSize: fontSize.base, 63 + color: vars.color.text, 64 + cursor: "pointer", 65 + }); 66 + 19 67 export const label = style({ 20 68 fontSize: fontSize.sm, 21 69 fontWeight: fontWeight.medium,
+27 -21
app/islands/AutomationForm.tsx
··· 33 33 name: string; 34 34 description: string | null; 35 35 lexicon: string; 36 - operation: string; 36 + operations: string[]; 37 37 actions: Action[]; 38 38 fetches: FetchStep[]; 39 39 conditions: Array<{ field: string; operator: string; value: string; comment?: string }>; ··· 299 299 const [name, setName] = useState(initial?.name ?? ""); 300 300 const [description, setDescription] = useState(initial?.description ?? ""); 301 301 const [lexicon, setLexicon] = useState(initialLexicon); 302 - const [operation, setOperation] = useState(initial?.operation ?? "create"); 302 + const [operations, setOperations] = useState<string[]>(initial?.operations ?? ["create"]); 303 303 const [fields, setFields] = useState<Field[]>([]); 304 304 const [fieldsLoading, setFieldsLoading] = useState(false); 305 305 const [fieldsError, setFieldsError] = useState(""); ··· 325 325 if (isEdit) { 326 326 if (name !== (initial.name ?? "")) return true; 327 327 if (description !== (initial.description ?? "")) return true; 328 - if (operation !== (initial.operation ?? "create")) return true; 328 + if (JSON.stringify(operations) !== JSON.stringify(initial.operations ?? ["create"])) 329 + return true; 329 330 if (JSON.stringify(conditions) !== JSON.stringify(toConditionDrafts(initial.conditions))) 330 331 return true; 331 332 if (JSON.stringify(fetches) !== JSON.stringify(toFetchDrafts(initial.fetches))) return true; ··· 340 341 fetches.length || 341 342 actions.length 342 343 ); 343 - }, [name, description, lexicon, operation, conditions, fetches, actions, isEdit]); 344 + }, [name, description, lexicon, operations, conditions, fetches, actions, isEdit]); 344 345 345 346 useEffect(() => { 346 347 if (!isDirty) return; ··· 467 468 }, []); 468 469 469 470 const previewPayload = useMemo(() => { 470 - const payload: Record<string, unknown> = { name, lexicon, operation }; 471 + const payload: Record<string, unknown> = { name, lexicon, operations }; 471 472 if (description.trim()) payload.description = description.trim(); 472 473 const filteredFetches = fetches.filter((f) => f.name && f.uri); 473 474 if (filteredFetches.length > 0 || isEdit) { ··· 497 498 }; 498 499 }); 499 500 return JSON.stringify(payload, null, 2); 500 - }, [name, description, lexicon, operation, fetches, conditions, actions]); 501 + }, [name, description, lexicon, operations, fetches, conditions, actions]); 501 502 502 503 const handleSubmit = useCallback( 503 504 async (e: Event) => { ··· 602 603 </div> 603 604 604 605 <div class={s.fieldGroup}> 605 - <label class={s.label} for="operation"> 606 - Operation 607 - </label> 608 - <select 609 - id="operation" 610 - class={s.select} 611 - value={operation} 612 - onChange={(e: Event) => setOperation((e.target as HTMLSelectElement).value)} 613 - > 614 - <option value="create">create</option> 615 - <option value="update">update</option> 616 - <option value="delete">delete</option> 617 - </select> 618 - <span class={s.hint}>Type of commit event this automation responds to</span> 606 + <span class={s.label}>Operations</span> 607 + <div class={s.operationCheckboxes}> 608 + {(["create", "update", "delete"] as const).map((op) => ( 609 + <label key={op} class={s.checkboxLabel}> 610 + <input 611 + type="checkbox" 612 + class={s.checkbox} 613 + checked={operations.includes(op)} 614 + onChange={() => 615 + setOperations((prev) => 616 + prev.includes(op) ? prev.filter((o) => o !== op) : [...prev, op], 617 + ) 618 + } 619 + /> 620 + {op} 621 + </label> 622 + ))} 623 + </div> 624 + <span class={s.hint}>Types of commit events this automation responds to</span> 619 625 </div> 620 626 621 627 {allPlaceholders.length > 0 && ( ··· 899 905 <button 900 906 type="submit" 901 907 class={s.submitBtn} 902 - disabled={!name.trim() || actions.length === 0} 908 + disabled={!name.trim() || operations.length === 0 || actions.length === 0} 903 909 > 904 910 {submitting 905 911 ? isEdit
+1 -1
app/routes/api/automations/[rkey].test.ts
··· 57 57 name: "Test Auto", 58 58 description: null, 59 59 lexicon: "app.bsky.feed.like", 60 - operation: "create", 60 + operations: ["create"], 61 61 actions: [ 62 62 { $type: "webhook" as const, callbackUrl: "https://example.com/hook", secret: "old-secret" }, 63 63 ],
+19 -7
app/routes/api/automations/[rkey].ts
··· 57 57 name: auto.name, 58 58 description: auto.description, 59 59 lexicon: auto.lexicon, 60 - operation: auto.operation, 60 + operations: auto.operations, 61 61 actions: auto.actions.map((a) => 62 62 a.$type === "webhook" 63 63 ? { ··· 98 98 const body = await c.req.json<{ 99 99 name?: string; 100 100 description?: string | null; 101 - operation?: string; 101 + operations?: string[]; 102 102 actions?: ActionInput[]; 103 103 fetches?: Array<{ name: string; uri: string; comment?: string }>; 104 104 conditions?: Array<{ field: string; operator?: string; value: string; comment?: string }>; ··· 119 119 return c.json({ error: "Description must be 1024 characters or less" }, 400); 120 120 } 121 121 122 - const operation = body.operation ?? auto.operation; 123 - if (!VALID_OPERATIONS.has(operation)) { 124 - return c.json({ error: "Operation must be one of: create, update, delete" }, 400); 122 + let operations = body.operations ?? auto.operations; 123 + if (!Array.isArray(operations) || operations.length === 0) { 124 + return c.json({ error: "At least one operation is required" }, 400); 125 + } 126 + if (operations.length > 3) { 127 + return c.json({ error: "Maximum 3 operations allowed" }, 400); 128 + } 129 + operations = [...new Set(operations)]; 130 + for (const op of operations) { 131 + if (!VALID_OPERATIONS.has(op)) { 132 + return c.json( 133 + { error: `Invalid operation: ${op}. Must be one of: create, update, delete` }, 134 + 400, 135 + ); 136 + } 125 137 } 126 138 127 139 const conditions = body.conditions ··· 308 320 name: name.trim(), 309 321 description: description?.trim() || undefined, 310 322 lexicon: auto.lexicon, 311 - operation, 323 + operations, 312 324 actions: pdsActions, 313 325 fetches: 314 326 pdsFetches ?? ··· 335 347 .set({ 336 348 name: name.trim(), 337 349 description: description?.trim() || null, 338 - operation, 350 + operations, 339 351 actions: localActions, 340 352 fetches: localFetches, 341 353 conditions,
+1 -1
app/routes/api/automations/[rkey]/logs.test.ts
··· 30 30 name: "Test Auto", 31 31 description: null, 32 32 lexicon: "app.bsky.feed.like", 33 - operation: "create", 33 + operations: ["create"], 34 34 actions: [{ $type: "webhook" as const, callbackUrl: "https://example.com/hook", secret: "sec" }], 35 35 fetches: [] as any[], 36 36 conditions: [] as any[],
+35 -23
app/routes/api/automations/index.test.ts
··· 87 87 }); 88 88 }); 89 89 90 - it("returns 400 for missing operation", async () => { 90 + it("returns 400 for missing operations", async () => { 91 91 const res = await app.request( 92 92 jsonReq("/api/automations", { 93 93 name: "Test", ··· 97 97 ); 98 98 expect(res.status).toBe(400); 99 99 const body = await res.json(); 100 - expect(body.error).toContain("Operation"); 100 + expect(body.error).toContain("operation"); 101 + }); 102 + 103 + it("returns 400 for empty operations array", async () => { 104 + const res = await app.request( 105 + jsonReq("/api/automations", { 106 + name: "Test", 107 + lexicon: "app.bsky.feed.like", 108 + operations: [], 109 + actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 110 + }), 111 + ); 112 + expect(res.status).toBe(400); 101 113 }); 102 114 103 - it("returns 400 for invalid operation", async () => { 115 + it("returns 400 for invalid operation value", async () => { 104 116 const res = await app.request( 105 117 jsonReq("/api/automations", { 106 118 name: "Test", 107 119 lexicon: "app.bsky.feed.like", 108 - operation: "invalid", 120 + operations: ["invalid"], 109 121 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 110 122 }), 111 123 ); ··· 117 129 jsonReq("/api/automations", { 118 130 name: "My Auto", 119 131 lexicon: "app.bsky.feed.like", 120 - operation: "create", 132 + operations: ["create"], 121 133 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 122 134 }), 123 135 ); ··· 135 147 jsonReq("/api/automations", { 136 148 name: "Record Auto", 137 149 lexicon: "app.bsky.feed.like", 138 - operation: "create", 150 + operations: ["create"], 139 151 actions: [ 140 152 { 141 153 type: "record", ··· 154 166 jsonReq("/api/automations", { 155 167 name: "", 156 168 lexicon: "app.bsky.feed.like", 157 - operation: "create", 169 + operations: ["create"], 158 170 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 159 171 }), 160 172 ); ··· 166 178 jsonReq("/api/automations", { 167 179 name: "a".repeat(129), 168 180 lexicon: "app.bsky.feed.like", 169 - operation: "create", 181 + operations: ["create"], 170 182 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 171 183 }), 172 184 ); ··· 179 191 name: "Test", 180 192 description: "x".repeat(1025), 181 193 lexicon: "app.bsky.feed.like", 182 - operation: "create", 194 + operations: ["create"], 183 195 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 184 196 }), 185 197 ); ··· 191 203 jsonReq("/api/automations", { 192 204 name: "Test", 193 205 lexicon: "not-valid", 194 - operation: "create", 206 + operations: ["create"], 195 207 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 196 208 }), 197 209 ); ··· 203 215 jsonReq("/api/automations", { 204 216 name: "Test", 205 217 lexicon: "blocked.nsid.something", 206 - operation: "create", 218 + operations: ["create"], 207 219 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 208 220 }), 209 221 ); ··· 215 227 jsonReq("/api/automations", { 216 228 name: "Test", 217 229 lexicon: "app.bsky.feed.like", 218 - operation: "create", 230 + operations: ["create"], 219 231 actions: [], 220 232 }), 221 233 ); ··· 227 239 jsonReq("/api/automations", { 228 240 name: "Test", 229 241 lexicon: "app.bsky.feed.like", 230 - operation: "create", 242 + operations: ["create"], 231 243 actions: Array.from({ length: 11 }, () => ({ 232 244 type: "webhook", 233 245 callbackUrl: "https://example.com/hook", ··· 244 256 jsonReq("/api/automations", { 245 257 name: "Test", 246 258 lexicon: "app.bsky.feed.like", 247 - operation: "create", 259 + operations: ["create"], 248 260 actions: [{ type: "webhook", callbackUrl: "not-a-url" }], 249 261 }), 250 262 ); ··· 261 273 jsonReq("/api/automations", { 262 274 name: "Test", 263 275 lexicon: "app.bsky.feed.like", 264 - operation: "create", 276 + operations: ["create"], 265 277 actions: [{ type: "webhook", callbackUrl: "https://127.0.0.1/hook" }], 266 278 }), 267 279 ); ··· 277 289 jsonReq("/api/automations", { 278 290 name: "Test", 279 291 lexicon: "app.bsky.feed.like", 280 - operation: "create", 292 + operations: ["create"], 281 293 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 282 294 }), 283 295 ); ··· 293 305 jsonReq("/api/automations", { 294 306 name: "Test", 295 307 lexicon: "app.bsky.feed.like", 296 - operation: "create", 308 + operations: ["create"], 297 309 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 298 310 }), 299 311 ); ··· 309 321 jsonReq("/api/automations", { 310 322 name: "Test", 311 323 lexicon: "app.bsky.feed.like", 312 - operation: "create", 324 + operations: ["create"], 313 325 actions: [ 314 326 { type: "record", targetCollection: "app.bsky.feed.post", recordTemplate: "not json" }, 315 327 ], ··· 323 335 jsonReq("/api/automations", { 324 336 name: "Test", 325 337 lexicon: "app.bsky.feed.like", 326 - operation: "create", 338 + operations: ["create"], 327 339 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 328 340 conditions: [{ field: "event.did", operator: "invalid", value: "x" }], 329 341 }), ··· 336 348 jsonReq("/api/automations", { 337 349 name: "Test", 338 350 lexicon: "app.bsky.feed.like", 339 - operation: "create", 351 + operations: ["create"], 340 352 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 341 353 conditions: Array.from({ length: 21 }, () => ({ 342 354 field: "event.did", ··· 353 365 jsonReq("/api/automations", { 354 366 name: "Test", 355 367 lexicon: "app.bsky.feed.like", 356 - operation: "create", 368 + operations: ["create"], 357 369 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 358 370 fetches: Array.from({ length: 6 }, (_, i) => ({ 359 371 name: `step${i}`, ··· 371 383 jsonReq("/api/automations", { 372 384 name: "Test", 373 385 lexicon: "app.bsky.feed.like", 374 - operation: "create", 386 + operations: ["create"], 375 387 actions: [{ type: "webhook", callbackUrl: "https://example.com/hook" }], 376 388 }), 377 389 ); ··· 383 395 jsonReq("/api/automations", { 384 396 name: "Test", 385 397 lexicon: "app.bsky.feed.like", 386 - operation: "create", 398 + operations: ["create"], 387 399 actions: [{ type: "invalid" }], 388 400 }), 389 401 );
+19 -7
app/routes/api/automations/index.ts
··· 41 41 name: r.name, 42 42 description: r.description, 43 43 lexicon: r.lexicon, 44 - operation: r.operation, 44 + operations: r.operations, 45 45 actions: r.actions.map((a) => 46 46 a.$type === "webhook" 47 47 ? { ··· 66 66 name: string; 67 67 description?: string; 68 68 lexicon: string; 69 - operation: string; 69 + operations: string[]; 70 70 actions: ActionInput[]; 71 71 fetches?: Array<{ name: string; uri: string; comment?: string }>; 72 72 conditions?: Array<{ field: string; operator?: string; value: string; comment?: string }>; ··· 92 92 return c.json({ error: "This lexicon is not allowed on this instance" }, 403); 93 93 } 94 94 95 - // Validate operation 96 - if (!body.operation || !VALID_OPERATIONS.has(body.operation)) { 97 - return c.json({ error: "Operation must be one of: create, update, delete" }, 400); 95 + // Validate operations 96 + if (!Array.isArray(body.operations) || body.operations.length === 0) { 97 + return c.json({ error: "At least one operation is required" }, 400); 98 + } 99 + if (body.operations.length > 3) { 100 + return c.json({ error: "Maximum 3 operations allowed" }, 400); 101 + } 102 + const operations = [...new Set(body.operations)]; 103 + for (const op of operations) { 104 + if (!VALID_OPERATIONS.has(op)) { 105 + return c.json( 106 + { error: `Invalid operation: ${op}. Must be one of: create, update, delete` }, 107 + 400, 108 + ); 109 + } 98 110 } 99 111 100 112 // Validate actions ··· 224 236 name: body.name.trim(), 225 237 description: body.description?.trim() || undefined, 226 238 lexicon: body.lexicon, 227 - operation: body.operation, 239 + operations, 228 240 actions: pdsActions, 229 241 fetches: pdsFetches.length > 0 ? pdsFetches : undefined, 230 242 conditions, ··· 247 259 name: body.name.trim(), 248 260 description: body.description?.trim() || null, 249 261 lexicon: body.lexicon, 250 - operation: body.operation, 262 + operations, 251 263 actions: localActions, 252 264 fetches: localFetches, 253 265 conditions,
+7 -2
app/routes/dashboard/automations/[rkey].tsx
··· 85 85 <dd> 86 86 <InlineCode>{auto.lexicon}</InlineCode> 87 87 </dd> 88 - <dt>Operation</dt> 88 + <dt>Operations</dt> 89 89 <dd> 90 - <InlineCode>{auto.operation}</InlineCode> 90 + {auto.operations.map((op, i) => ( 91 + <> 92 + {i > 0 && ", "} 93 + <InlineCode>{op}</InlineCode> 94 + </> 95 + ))} 91 96 </dd> 92 97 <dt>Status</dt> 93 98 <dd>
+1 -1
app/routes/dashboard/automations/[rkey]/duplicate.tsx
··· 57 57 name: `${auto.name} (copy)`, 58 58 description: auto.description, 59 59 lexicon: auto.lexicon, 60 - operation: auto.operation, 60 + operations: auto.operations, 61 61 actions: auto.actions, 62 62 fetches: auto.fetches, 63 63 conditions: auto.conditions,
+1 -1
app/routes/dashboard/automations/[rkey]/edit.tsx
··· 58 58 name: auto.name, 59 59 description: auto.description, 60 60 lexicon: auto.lexicon, 61 - operation: auto.operation, 61 + operations: auto.operations, 62 62 actions: auto.actions, 63 63 fetches: auto.fetches, 64 64 conditions: auto.conditions,
+7 -2
app/routes/dashboard/index.tsx
··· 51 51 <tr> 52 52 <th>Name</th> 53 53 <th>Lexicon</th> 54 - <th>Operation</th> 54 + <th>Operations</th> 55 55 <th>Actions</th> 56 56 <th>Status</th> 57 57 <th></th> ··· 67 67 <InlineCode>{auto.lexicon}</InlineCode> 68 68 </td> 69 69 <td> 70 - <InlineCode>{auto.operation}</InlineCode> 70 + {auto.operations.map((op, i) => ( 71 + <> 72 + {i > 0 && ", "} 73 + <InlineCode>{op}</InlineCode> 74 + </> 75 + ))} 71 76 </td> 72 77 <td> 73 78 {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""}
+7
app/server.ts
··· 9 9 10 10 const app = createApp(); 11 11 12 + // Static files (production only — Vite serves these in dev) 13 + if (import.meta.env.PROD) { 14 + const { serveStatic } = await import("hono/bun"); 15 + app.use("/static/*", serveStatic({ root: "./dist" })); 16 + app.use("/favicon.svg", serveStatic({ root: "./dist" })); 17 + } 18 + 12 19 // Security headers on all responses 13 20 app.use("*", securityHeaders()); 14 21
+10 -6
lexicons/run/airglow/automation.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "lexicon", "operation", "actions", "createdAt"], 11 + "required": ["name", "lexicon", "operations", "actions", "createdAt"], 12 12 "properties": { 13 13 "name": { 14 14 "type": "string", ··· 25 25 "description": "NSID of the collection to listen to.", 26 26 "maxLength": 256 27 27 }, 28 - "operation": { 29 - "type": "string", 30 - "description": "The commit operation this automation responds to.", 31 - "knownValues": ["create", "update", "delete"], 32 - "maxLength": 16 28 + "operations": { 29 + "type": "array", 30 + "description": "The commit operations this automation responds to.", 31 + "minLength": 1, 32 + "maxLength": 3, 33 + "items": { 34 + "type": "string", 35 + "knownValues": ["create", "update", "delete"] 36 + } 33 37 }, 34 38 "actions": { 35 39 "type": "array",
+1 -1
lib/automations/pds.ts
··· 57 57 name: string; 58 58 description?: string; 59 59 lexicon: string; 60 - operation: string; 60 + operations: string[]; 61 61 actions: PdsAction[]; 62 62 fetches?: PdsFetchStep[]; 63 63 conditions: Array<{ field: string; operator: string; value: string; comment?: string }>;
+21
lib/db/migrations/0001_tranquil_timeslip.sql
··· 1 + PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 + CREATE TABLE `__new_automations` ( 3 + `uri` text PRIMARY KEY NOT NULL, 4 + `did` text NOT NULL, 5 + `rkey` text NOT NULL, 6 + `name` text NOT NULL, 7 + `description` text, 8 + `lexicon` text NOT NULL, 9 + `operation` text DEFAULT '["create"]' NOT NULL, 10 + `actions` text DEFAULT '[]' NOT NULL, 11 + `fetches` text DEFAULT '[]' NOT NULL, 12 + `conditions` text DEFAULT '[]' NOT NULL, 13 + `active` integer DEFAULT false NOT NULL, 14 + `dry_run` integer DEFAULT false NOT NULL, 15 + `indexed_at` integer NOT NULL 16 + ); 17 + --> statement-breakpoint 18 + INSERT INTO `__new_automations`("uri", "did", "rkey", "name", "description", "lexicon", "operation", "actions", "fetches", "conditions", "active", "dry_run", "indexed_at") SELECT "uri", "did", "rkey", "name", "description", "lexicon", json_array("operation"), "actions", "fetches", "conditions", "active", "dry_run", "indexed_at" FROM `automations`;--> statement-breakpoint 19 + DROP TABLE `automations`;--> statement-breakpoint 20 + ALTER TABLE `__new_automations` RENAME TO `automations`;--> statement-breakpoint 21 + PRAGMA foreign_keys=ON;
+368
lib/db/migrations/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "360540df-91e2-43c5-87c8-d58fd0e41ec3", 5 + "prevId": "01f653dd-7499-4c2a-a4d8-41bdb94cedf5", 6 + "tables": { 7 + "automations": { 8 + "name": "automations", 9 + "columns": { 10 + "uri": { 11 + "name": "uri", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "rkey": { 25 + "name": "rkey", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "name": { 32 + "name": "name", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "description": { 39 + "name": "description", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "lexicon": { 46 + "name": "lexicon", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + }, 52 + "operation": { 53 + "name": "operation", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": "'[\"create\"]'" 59 + }, 60 + "actions": { 61 + "name": "actions", 62 + "type": "text", 63 + "primaryKey": false, 64 + "notNull": true, 65 + "autoincrement": false, 66 + "default": "'[]'" 67 + }, 68 + "fetches": { 69 + "name": "fetches", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true, 73 + "autoincrement": false, 74 + "default": "'[]'" 75 + }, 76 + "conditions": { 77 + "name": "conditions", 78 + "type": "text", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false, 82 + "default": "'[]'" 83 + }, 84 + "active": { 85 + "name": "active", 86 + "type": "integer", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false, 90 + "default": false 91 + }, 92 + "dry_run": { 93 + "name": "dry_run", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false, 98 + "default": false 99 + }, 100 + "indexed_at": { 101 + "name": "indexed_at", 102 + "type": "integer", 103 + "primaryKey": false, 104 + "notNull": true, 105 + "autoincrement": false 106 + } 107 + }, 108 + "indexes": {}, 109 + "foreignKeys": {}, 110 + "compositePrimaryKeys": {}, 111 + "uniqueConstraints": {}, 112 + "checkConstraints": {} 113 + }, 114 + "delivery_logs": { 115 + "name": "delivery_logs", 116 + "columns": { 117 + "id": { 118 + "name": "id", 119 + "type": "integer", 120 + "primaryKey": true, 121 + "notNull": true, 122 + "autoincrement": true 123 + }, 124 + "automation_uri": { 125 + "name": "automation_uri", 126 + "type": "text", 127 + "primaryKey": false, 128 + "notNull": true, 129 + "autoincrement": false 130 + }, 131 + "action_index": { 132 + "name": "action_index", 133 + "type": "integer", 134 + "primaryKey": false, 135 + "notNull": true, 136 + "autoincrement": false, 137 + "default": 0 138 + }, 139 + "event_time_us": { 140 + "name": "event_time_us", 141 + "type": "integer", 142 + "primaryKey": false, 143 + "notNull": true, 144 + "autoincrement": false 145 + }, 146 + "payload": { 147 + "name": "payload", 148 + "type": "text", 149 + "primaryKey": false, 150 + "notNull": false, 151 + "autoincrement": false 152 + }, 153 + "status_code": { 154 + "name": "status_code", 155 + "type": "integer", 156 + "primaryKey": false, 157 + "notNull": false, 158 + "autoincrement": false 159 + }, 160 + "message": { 161 + "name": "message", 162 + "type": "text", 163 + "primaryKey": false, 164 + "notNull": false, 165 + "autoincrement": false 166 + }, 167 + "error": { 168 + "name": "error", 169 + "type": "text", 170 + "primaryKey": false, 171 + "notNull": false, 172 + "autoincrement": false 173 + }, 174 + "dry_run": { 175 + "name": "dry_run", 176 + "type": "integer", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false, 180 + "default": false 181 + }, 182 + "attempt": { 183 + "name": "attempt", 184 + "type": "integer", 185 + "primaryKey": false, 186 + "notNull": true, 187 + "autoincrement": false, 188 + "default": 1 189 + }, 190 + "created_at": { 191 + "name": "created_at", 192 + "type": "integer", 193 + "primaryKey": false, 194 + "notNull": true, 195 + "autoincrement": false 196 + } 197 + }, 198 + "indexes": {}, 199 + "foreignKeys": { 200 + "delivery_logs_automation_uri_automations_uri_fk": { 201 + "name": "delivery_logs_automation_uri_automations_uri_fk", 202 + "tableFrom": "delivery_logs", 203 + "tableTo": "automations", 204 + "columnsFrom": [ 205 + "automation_uri" 206 + ], 207 + "columnsTo": [ 208 + "uri" 209 + ], 210 + "onDelete": "cascade", 211 + "onUpdate": "no action" 212 + } 213 + }, 214 + "compositePrimaryKeys": {}, 215 + "uniqueConstraints": {}, 216 + "checkConstraints": {} 217 + }, 218 + "lexicon_cache": { 219 + "name": "lexicon_cache", 220 + "columns": { 221 + "nsid": { 222 + "name": "nsid", 223 + "type": "text", 224 + "primaryKey": true, 225 + "notNull": true, 226 + "autoincrement": false 227 + }, 228 + "schema": { 229 + "name": "schema", 230 + "type": "text", 231 + "primaryKey": false, 232 + "notNull": true, 233 + "autoincrement": false 234 + }, 235 + "fetched_at": { 236 + "name": "fetched_at", 237 + "type": "integer", 238 + "primaryKey": false, 239 + "notNull": true, 240 + "autoincrement": false 241 + } 242 + }, 243 + "indexes": {}, 244 + "foreignKeys": {}, 245 + "compositePrimaryKeys": {}, 246 + "uniqueConstraints": {}, 247 + "checkConstraints": {} 248 + }, 249 + "oauth_sessions": { 250 + "name": "oauth_sessions", 251 + "columns": { 252 + "key": { 253 + "name": "key", 254 + "type": "text", 255 + "primaryKey": true, 256 + "notNull": true, 257 + "autoincrement": false 258 + }, 259 + "value": { 260 + "name": "value", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + }, 266 + "expires_at": { 267 + "name": "expires_at", 268 + "type": "integer", 269 + "primaryKey": false, 270 + "notNull": false, 271 + "autoincrement": false 272 + } 273 + }, 274 + "indexes": {}, 275 + "foreignKeys": {}, 276 + "compositePrimaryKeys": {}, 277 + "uniqueConstraints": {}, 278 + "checkConstraints": {} 279 + }, 280 + "oauth_states": { 281 + "name": "oauth_states", 282 + "columns": { 283 + "key": { 284 + "name": "key", 285 + "type": "text", 286 + "primaryKey": true, 287 + "notNull": true, 288 + "autoincrement": false 289 + }, 290 + "value": { 291 + "name": "value", 292 + "type": "text", 293 + "primaryKey": false, 294 + "notNull": true, 295 + "autoincrement": false 296 + }, 297 + "expires_at": { 298 + "name": "expires_at", 299 + "type": "integer", 300 + "primaryKey": false, 301 + "notNull": false, 302 + "autoincrement": false 303 + } 304 + }, 305 + "indexes": {}, 306 + "foreignKeys": {}, 307 + "compositePrimaryKeys": {}, 308 + "uniqueConstraints": {}, 309 + "checkConstraints": {} 310 + }, 311 + "users": { 312 + "name": "users", 313 + "columns": { 314 + "id": { 315 + "name": "id", 316 + "type": "integer", 317 + "primaryKey": true, 318 + "notNull": true, 319 + "autoincrement": true 320 + }, 321 + "did": { 322 + "name": "did", 323 + "type": "text", 324 + "primaryKey": false, 325 + "notNull": true, 326 + "autoincrement": false 327 + }, 328 + "handle": { 329 + "name": "handle", 330 + "type": "text", 331 + "primaryKey": false, 332 + "notNull": true, 333 + "autoincrement": false 334 + }, 335 + "created_at": { 336 + "name": "created_at", 337 + "type": "integer", 338 + "primaryKey": false, 339 + "notNull": true, 340 + "autoincrement": false 341 + } 342 + }, 343 + "indexes": { 344 + "users_did_unique": { 345 + "name": "users_did_unique", 346 + "columns": [ 347 + "did" 348 + ], 349 + "isUnique": true 350 + } 351 + }, 352 + "foreignKeys": {}, 353 + "compositePrimaryKeys": {}, 354 + "uniqueConstraints": {}, 355 + "checkConstraints": {} 356 + } 357 + }, 358 + "views": {}, 359 + "enums": {}, 360 + "_meta": { 361 + "schemas": {}, 362 + "tables": {}, 363 + "columns": {} 364 + }, 365 + "internal": { 366 + "indexes": {} 367 + } 368 + }
+7
lib/db/migrations/meta/_journal.json
··· 8 8 "when": 1775733020381, 9 9 "tag": "0000_majestic_taskmaster", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1775740206840, 16 + "tag": "0001_tranquil_timeslip", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+1 -1
lib/db/schema.ts
··· 40 40 name: text("name").notNull(), 41 41 description: text("description"), 42 42 lexicon: text("lexicon").notNull(), // NSID being watched 43 - operation: text("operation").notNull().default("create"), // "create" | "update" | "delete" 43 + operations: text("operation", { mode: "json" }).notNull().$type<string[]>().default(["create"]), 44 44 actions: text("actions", { mode: "json" }).notNull().$type<Action[]>().default([]), 45 45 fetches: text("fetches", { mode: "json" }).notNull().$type<FetchStep[]>().default([]), 46 46 conditions: text("conditions", { mode: "json" })
+26 -1
lib/jetstream/consumer.test.ts
··· 213 213 uri: "at://u/s/1", 214 214 rkey: "1", 215 215 lexicon: "app.bsky.feed.like", 216 - operation: "create", 216 + operations: ["create"], 217 217 }), 218 218 ); 219 219 await consumer.refreshAutomations(); ··· 230 230 (consumer as any).processEvent(event); 231 231 232 232 expect(handler).not.toHaveBeenCalled(); 233 + }); 234 + 235 + it("calls handler when event matches one of multiple operations", async () => { 236 + await db.insert(automations).values( 237 + makeAutomation({ 238 + uri: "at://u/s/1", 239 + rkey: "1", 240 + lexicon: "app.bsky.feed.like", 241 + operations: ["create", "delete"], 242 + }), 243 + ); 244 + await consumer.refreshAutomations(); 245 + 246 + const event = makeEvent({ 247 + commit: { 248 + rev: "r", 249 + operation: "delete", 250 + collection: "app.bsky.feed.like", 251 + rkey: "rk", 252 + record: {}, 253 + }, 254 + }); 255 + (consumer as any).processEvent(event); 256 + 257 + expect(handler).toHaveBeenCalledOnce(); 233 258 }); 234 259 235 260 it("does not call handler when conditions do not match", async () => {
+6 -4
lib/jetstream/consumer.ts
··· 46 46 const byCollectionOp = new Map<string, Automation[]>(); 47 47 for (const row of rows) { 48 48 if (!isNsidAllowed(row.lexicon, config.nsidAllowlist, config.nsidBlocklist)) continue; 49 - const key = `${row.lexicon}\0${row.operation}`; 50 - const list = byCollectionOp.get(key) || []; 51 - list.push(row); 52 - byCollectionOp.set(key, list); 49 + for (const op of row.operations) { 50 + const key = `${row.lexicon}\0${op}`; 51 + const list = byCollectionOp.get(key) || []; 52 + list.push(row); 53 + byCollectionOp.set(key, list); 54 + } 53 55 } 54 56 55 57 const deriveCollections = (map: Map<string, Automation[]>) => {
+2 -2
lib/test/fixtures.ts
··· 9 9 name: string; 10 10 description: string | null; 11 11 lexicon: string; 12 - operation: string; 12 + operations: string[]; 13 13 actions: Action[]; 14 14 fetches: FetchStep[]; 15 15 conditions: Array<{ field: string; operator: string; value: string; comment?: string }>; ··· 74 74 name: "Test Automation", 75 75 description: null, 76 76 lexicon: "app.bsky.feed.like", 77 - operation: "create", 77 + operations: ["create"], 78 78 actions: [makeWebhookAction()], 79 79 fetches: [], 80 80 conditions: [],
+1 -1
package.json
··· 4 4 "type": "module", 5 5 "scripts": { 6 6 "dev": "vp dev", 7 - "build": "vp build --mode client", 7 + "build": "vp build --mode client && vp build --ssr app/server.ts --emptyOutDir false", 8 8 "start": "bun run app/server.ts", 9 9 "db:generate": "drizzle-kit generate", 10 10 "db:migrate": "bun run lib/db/migrate.ts"
+22 -4
vite.config.ts
··· 6 6 import { defineConfig } from "vite-plus"; 7 7 import type { Plugin, ViteDevServer } from "vite"; 8 8 9 + // Dev-only: alias bun:sqlite to better-sqlite3 compat shim for Vite's Node-based dev server. 10 + // In production SSR builds, bun:sqlite is externalized and resolved natively by Bun. 11 + function sqliteCompat(): Plugin { 12 + return { 13 + name: "sqlite-compat", 14 + apply: "serve", 15 + config: () => ({ 16 + resolve: { 17 + alias: { 18 + "bun:sqlite": "/lib/db/sqlite-compat.ts", 19 + "better-sqlite3": "/lib/db/sqlite-compat.ts", 20 + "drizzle-orm/bun-sqlite": "drizzle-orm/better-sqlite3", 21 + }, 22 + }, 23 + }), 24 + }; 25 + } 26 + 9 27 // Collect all CSS from the Vite module graph and serve at /__dev.css 10 28 // This allows a blocking <link> tag in dev mode to prevent FOUC 11 29 function devCss(): Plugin { ··· 185 203 port: 5175, 186 204 host: true, 187 205 }, 188 - plugins: [devCss(), pdsProxy(), honox(), vanillaExtractPlugin()], 206 + plugins: [devCss(), pdsProxy(), sqliteCompat(), honox(), vanillaExtractPlugin({ identifiers: "short" })], 207 + ssr: { 208 + external: ["bun:sqlite"], 209 + }, 189 210 resolve: { 190 211 alias: { 191 212 "@": "/lib", 192 - "bun:sqlite": "/lib/db/sqlite-compat.ts", 193 - "better-sqlite3": "/lib/db/sqlite-compat.ts", 194 - "drizzle-orm/bun-sqlite": "drizzle-orm/better-sqlite3", 195 213 "lucide-preact/icons": resolve( 196 214 import.meta.dirname, 197 215 "node_modules/lucide-preact/dist/esm/icons",