Action-based API pattern that sends user gestures instead of full state blobs. Prevents data loss from concurrent edits and enables conflict detection. Apply when implementing API endpoints or modifying data sync.
Send user gestures (actions) instead of full JSON blobs. Enables atomic operations, conflict detection, and future undo/redo.
// Component A sends full highlights_data
PUT /overlay-data { highlights_data: [...all regions...] }
// Component B sends full crop_keyframes (same request)
PUT /clips/123 { crop_data: [...all keyframes...] }
// Result: One overwrites the other, data lost
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 |
| Action Design |
| CRITICAL |
action- |
| 2 | Endpoint Pattern | HIGH | endpoint- |
| 3 | Versioning | HIGH | version- |
| 4 | Frontend Integration | MEDIUM | frontend- |
action-gesture-not-state - Send the user action, not resulting stateaction-idempotent - Same action applied twice = same resultaction-atomic - Each action is one DB operationendpoint-actions-route - Use POST /resource/actions patternendpoint-action-target-data - Structure: {action, target, data}endpoint-version-response - Return {success, version, applied_at}version-expected - Client sends expected_versionversion-conflict-409 - Return 409 if version mismatchversion-increment - Increment version after each actionfrontend-optimistic-ui - Apply change immediately, confirm/reject laterfrontend-action-queue - Queue actions for offline supportfrontend-conflict-refresh - Show "data changed, refresh" on conflictPUT /api/clips/123
{ crop_data: JSON.stringify(allKeyframes) } // BAD: Full blob
POST /api/clips/123/actions
{
"action": "add_crop_keyframe",
"data": { "frame": 100, "x": 50, "y": 50, "width": 640, "height": 360 }
}
{ "action": "add_crop_keyframe", "data": { "frame": 100, "x": 50, ... } }
{ "action": "update_crop_keyframe", "target": { "frame": 100 }, "data": { "x": 60 } }
{ "action": "delete_crop_keyframe", "target": { "frame": 100 } }
{ "action": "move_crop_keyframe", "target": { "frame": 100 }, "data": { "new_frame": 120 } }
{ "action": "set_trim_range", "data": { "start": 1.0, "end": 5.0 } }
{ "action": "create_region", "data": { "start_time": 0, "end_time": 2.0 } }
{ "action": "delete_region", "target": { "region_id": "xxx" } }
{ "action": "add_region_keyframe", "target": { "region_id": "xxx" }, "data": { ... } }
{ "action": "set_effect_type", "data": { "effect_type": "brightness_boost" } }
{ "action": "create_annotation", "data": { "start_time": 10, "end_time": 15, "name": "Play" } }
{ "action": "update_annotation", "target": { "annotation_id": 123 }, "data": { "rating": 5 } }
{ "action": "add_tag", "target": { "annotation_id": 123 }, "data": { "tag": "goal" } }
@router.post("/projects/{project_id}/clips/{clip_id}/actions")
async def clip_action(project_id: int, clip_id: int, action: ClipAction):
if action.action == "add_crop_keyframe":
current = get_clip_crop_data(clip_id)
current.append(action.data)
current.sort(key=lambda k: k["frame"])
save_clip_crop_data(clip_id, current)
return {"success": True, "version": get_new_version()}
elif action.action == "delete_crop_keyframe":
current = get_clip_crop_data(clip_id)
current = [k for k in current if k["frame"] != action.target["frame"]]
save_clip_crop_data(clip_id, current)
return {"success": True, "version": get_new_version()}
| Aspect | Full Blob | Gesture-Based |
|---|---|---|
| Data loss risk | High (overwrites) | Low (atomic ops) |
| Bandwidth | High (full state) | Low (just delta) |
| Conflict resolution | Last-write-wins only | Can merge |
| Debugging | Hard (what changed?) | Easy (action log) |
| Undo/redo | Store full states | Store action history |
| Offline support | Complex | Natural (queue actions) |
Based on complexity and bug frequency:
/actions endpoints alongside existing PUT endpointsversion fieldexpected_version parameterSee individual rule files in rules/ directory.