prefect server in zig

improve test harness and API consistency

test harness:
- add variables test with full CRUD coverage
- add response validation helper for field/type checking
- validate flow, flow_run, variable, and block_type responses

api consistency:
- include variable name in conflict error message (like Python)
- fix timestamp format consistency: use ISO 8601 (with T, Z, microseconds)
instead of SQLite's datetime('now') format
- ensures create and update return consistent timestamp format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+131 -18
+4
loq.toml
··· 9 9 max_lines = 564 10 10 11 11 [[rules]] 12 + path = "scripts/test-api-sequence" 13 + max_lines = 580 14 + 15 + [[rules]] 12 16 path = "src/broker/redis.zig" 13 17 max_lines = 1020
+99
scripts/test-api-sequence
··· 49 49 return super().request(*args, **kwargs) 50 50 51 51 52 + def validate_response(data: dict, required_fields: list[str], field_types: dict | None = None) -> bool: 53 + """Validate response contains required fields with expected types.""" 54 + for field in required_fields: 55 + if field not in data: 56 + if not QUIET: 57 + console.print(f"[red]VALIDATION[/red]: missing field '{field}'") 58 + return False 59 + if field_types: 60 + for field, expected_type in field_types.items(): 61 + if field in data and data[field] is not None: 62 + if not isinstance(data[field], expected_type): 63 + if not QUIET: 64 + console.print(f"[red]VALIDATION[/red]: field '{field}' expected {expected_type.__name__}, got {type(data[field]).__name__}") 65 + return False 66 + return True 67 + 68 + 52 69 def run_test(name: str, test_fn: Callable[[CountingClient], bool]) -> TestResult: 53 70 """Run a test function with timing and request counting.""" 54 71 if not QUIET: ··· 139 156 console.print(f"[red]FAIL[/red]: {resp.status_code} {resp.text}") 140 157 return False 141 158 flow = resp.json() 159 + if not validate_response(flow, ["id", "name", "created", "updated"], {"id": str, "name": str}): 160 + return False 142 161 if not QUIET: 143 162 console.print(f" flow_id: {flow.get('id')}") 144 163 ··· 155 174 console.print(f"[red]FAIL[/red]: {resp.status_code} {resp.text}") 156 175 return False 157 176 flow_run = resp.json() 177 + if not validate_response(flow_run, ["id", "name", "flow_id", "state_type"], {"id": str, "name": str}): 178 + return False 158 179 flow_run_id = flow_run.get("id") 159 180 if not QUIET: 160 181 console.print(f" flow_run_id: {flow_run_id}") ··· 300 321 return True 301 322 302 323 324 + def test_variables(client: CountingClient) -> bool: 325 + """Test variables API (CRUD).""" 326 + var_name = f"bench-var-{uuid.uuid4().hex[:8]}" 327 + 328 + # create 329 + if not QUIET: 330 + console.print("[bold]POST /variables/[/bold]") 331 + resp = client.post("/variables/", json={ 332 + "name": var_name, 333 + "value": {"nested": "object", "count": 42}, 334 + "tags": ["benchmark", "test"], 335 + }) 336 + if resp.status_code != 201: 337 + if not QUIET: 338 + console.print(f"[red]FAIL[/red]: create {resp.status_code}") 339 + return False 340 + variable = resp.json() 341 + if not validate_response(variable, ["id", "name", "value", "tags", "created", "updated"], {"id": str, "name": str, "tags": list}): 342 + return False 343 + var_id = variable.get("id") 344 + if not QUIET: 345 + console.print(f" created: {var_id}") 346 + 347 + # get by name 348 + resp = client.get(f"/variables/name/{var_name}") 349 + if resp.status_code != 200: 350 + return False 351 + if not QUIET: 352 + console.print(f" get by name: ok") 353 + 354 + # get by id 355 + resp = client.get(f"/variables/{var_id}") 356 + if resp.status_code != 200: 357 + return False 358 + 359 + # update by name 360 + resp = client.patch(f"/variables/name/{var_name}", json={"value": "updated"}) 361 + if resp.status_code != 204: 362 + return False 363 + if not QUIET: 364 + console.print(f" updated by name") 365 + 366 + # filter 367 + resp = client.post("/variables/filter", json={"limit": 10}) 368 + if resp.status_code != 200: 369 + return False 370 + if not QUIET: 371 + console.print(f" filter: {len(resp.json())} items") 372 + 373 + # count 374 + resp = client.post("/variables/count", json={}) 375 + if resp.status_code != 200: 376 + return False 377 + if not QUIET: 378 + console.print(f" count: {resp.text}") 379 + 380 + # duplicate name should fail 381 + resp = client.post("/variables/", json={"name": var_name, "value": "dupe"}) 382 + if resp.status_code != 409: 383 + if not QUIET: 384 + console.print(f"[red]FAIL[/red]: duplicate should return 409, got {resp.status_code}") 385 + return False 386 + if not QUIET: 387 + console.print(f" duplicate rejected: 409") 388 + 389 + # delete 390 + resp = client.delete(f"/variables/name/{var_name}") 391 + if resp.status_code != 204: 392 + return False 393 + if not QUIET: 394 + console.print(f" deleted") 395 + 396 + return True 397 + 398 + 303 399 def test_blocks(client: CountingClient) -> bool: 304 400 """Test blocks API (types, schemas, documents).""" 305 401 slug = f"bench-block-{uuid.uuid4().hex[:8]}" ··· 317 413 console.print(f"[red]FAIL[/red]: create block_type {resp.status_code}") 318 414 return False 319 415 block_type = resp.json() 416 + if not validate_response(block_type, ["id", "name", "slug"], {"id": str, "name": str, "slug": str}): 417 + return False 320 418 block_type_id = block_type.get("id") 321 419 if not QUIET: 322 420 console.print(f" created: {block_type_id}") ··· 417 515 results.append(run_test("task_run", test_task_run)) 418 516 results.append(run_test("filters", test_filters)) 419 517 results.append(run_test("logs", test_logs)) 518 + results.append(run_test("variables", test_variables)) 420 519 results.append(run_test("blocks", test_blocks)) 421 520 422 521 total_duration = sum(r.duration_ms for r in results)
+19 -9
src/api/variables.zig
··· 140 140 141 141 // check for existing 142 142 if (db.variables.getByName(alloc, name) catch null) |_| { 143 - json_util.sendStatus(r, "{\"detail\":\"Variable with this name already exists\"}", .conflict); 143 + const err_msg = std.fmt.allocPrint(alloc, "{{\"detail\":\"Variable with name '{s}' already exists.\"}}", .{name}) catch { 144 + json_util.sendStatus(r, "{\"detail\":\"Variable already exists\"}", .conflict); 145 + return; 146 + }; 147 + json_util.sendStatus(r, err_msg, .conflict); 144 148 return; 145 149 } 146 150 ··· 150 154 var new_id_buf: [36]u8 = undefined; 151 155 const new_id = uuid_util.generate(&new_id_buf); 152 156 153 - db.variables.insert(new_id, name, value_json, tags_json) catch { 157 + var ts_buf: [32]u8 = undefined; 158 + const now = time_util.timestamp(&ts_buf); 159 + 160 + db.variables.insert(new_id, name, value_json, tags_json, now) catch { 154 161 json_util.sendStatus(r, "{\"detail\":\"insert failed\"}", .internal_server_error); 155 162 return; 156 163 }; 157 - 158 - var ts_buf: [32]u8 = undefined; 159 - const now = time_util.timestamp(&ts_buf); 160 164 161 165 const variable = db.variables.VariableRow{ 162 166 .id = new_id, ··· 226 230 const value_json = stringifyFieldOptional(alloc, obj.get("value")); 227 231 const tags_json = stringifyFieldOptional(alloc, obj.get("tags")); 228 232 229 - const updated = db.variables.updateById(id, new_name, value_json, tags_json) catch { 233 + var ts_buf: [32]u8 = undefined; 234 + const now = time_util.timestamp(&ts_buf); 235 + 236 + const did_update = db.variables.updateById(id, new_name, value_json, tags_json, now) catch { 230 237 json_util.sendStatus(r, "{\"detail\":\"update failed\"}", .internal_server_error); 231 238 return; 232 239 }; 233 240 234 - if (!updated) { 241 + if (!did_update) { 235 242 json_util.sendStatus(r, "{\"detail\":\"Variable not found\"}", .not_found); 236 243 return; 237 244 } ··· 260 267 const value_json = stringifyFieldOptional(alloc, obj.get("value")); 261 268 const tags_json = stringifyFieldOptional(alloc, obj.get("tags")); 262 269 263 - const updated = db.variables.updateByName(name, new_name, value_json, tags_json) catch { 270 + var ts_buf: [32]u8 = undefined; 271 + const now = time_util.timestamp(&ts_buf); 272 + 273 + const did_update = db.variables.updateByName(name, new_name, value_json, tags_json, now) catch { 264 274 json_util.sendStatus(r, "{\"detail\":\"update failed\"}", .internal_server_error); 265 275 return; 266 276 }; 267 277 268 - if (!updated) { 278 + if (!did_update) { 269 279 json_util.sendStatus(r, "{\"detail\":\"Variable not found\"}", .not_found); 270 280 return; 271 281 }
+9 -9
src/db/variables.zig
··· 61 61 return null; 62 62 } 63 63 64 - pub fn insert(id: []const u8, name: []const u8, value: []const u8, tags: []const u8) !void { 64 + pub fn insert(id: []const u8, name: []const u8, value: []const u8, tags: []const u8, created: []const u8) !void { 65 65 backend.db.exec( 66 - "INSERT INTO variable (id, name, value, tags) VALUES (?, ?, ?, ?)", 67 - .{ id, name, value, tags }, 66 + "INSERT INTO variable (id, name, value, tags, created, updated) VALUES (?, ?, ?, ?, ?, ?)", 67 + .{ id, name, value, tags, created, created }, 68 68 ) catch |err| { 69 69 log.err("database", "insert variable error: {}", .{err}); 70 70 return err; 71 71 }; 72 72 } 73 73 74 - pub fn updateById(id: []const u8, name: ?[]const u8, value: ?[]const u8, tags: ?[]const u8) !bool { 74 + pub fn updateById(id: []const u8, name: ?[]const u8, value: ?[]const u8, tags: ?[]const u8, updated: []const u8) !bool { 75 75 const affected = backend.db.execWithRowCount( 76 - "UPDATE variable SET name = COALESCE(?, name), value = COALESCE(?, value), tags = COALESCE(?, tags), updated = datetime('now') WHERE id = ?", 77 - .{ name, value, tags, id }, 76 + "UPDATE variable SET name = COALESCE(?, name), value = COALESCE(?, value), tags = COALESCE(?, tags), updated = ? WHERE id = ?", 77 + .{ name, value, tags, updated, id }, 78 78 ) catch |err| { 79 79 log.err("database", "update variable error: {}", .{err}); 80 80 return err; ··· 82 82 return affected > 0; 83 83 } 84 84 85 - pub fn updateByName(name: []const u8, new_name: ?[]const u8, value: ?[]const u8, tags: ?[]const u8) !bool { 85 + pub fn updateByName(name: []const u8, new_name: ?[]const u8, value: ?[]const u8, tags: ?[]const u8, updated: []const u8) !bool { 86 86 const affected = backend.db.execWithRowCount( 87 - "UPDATE variable SET name = COALESCE(?, name), value = COALESCE(?, value), tags = COALESCE(?, tags), updated = datetime('now') WHERE name = ?", 88 - .{ new_name, value, tags, name }, 87 + "UPDATE variable SET name = COALESCE(?, name), value = COALESCE(?, value), tags = COALESCE(?, tags), updated = ? WHERE name = ?", 88 + .{ new_name, value, tags, updated, name }, 89 89 ) catch |err| { 90 90 log.err("database", "update variable error: {}", .{err}); 91 91 return err;