Skip to content

feat: add ETag support for optimistic concurrency control#59

Merged
hmans merged 1 commit intohmans:mainfrom
matleh:feature/etag-occ
Jan 17, 2026
Merged

feat: add ETag support for optimistic concurrency control#59
hmans merged 1 commit intohmans:mainfrom
matleh:feature/etag-occ

Conversation

@matleh
Copy link
Copy Markdown
Contributor

@matleh matleh commented Jan 16, 2026

Summary

Adds ETag-based optimistic concurrency control to prevent lost updates when multiple agents work on the same bean concurrently.

Supersedes #57 (this PR extracts just the ETag feature; body modification will be a separate PR).

Changes

  • Add ETag() method to Bean using FNV-1a 64-bit hash of rendered content
  • Include etag field in JSON output and GraphQL schema
  • Add --if-match flag to beans update for optimistic locking
  • Add --etag-only flag to beans show for easy etag extraction
  • Add ifMatch parameter to GraphQL mutations
  • Add typed errors (ETagMismatchError, ETagRequiredError) with CONFLICT code
  • Add require_if_match config option to enforce ETag usage
  • Comprehensive test coverage

Usage

# Get etag
ETAG=$(beans show <id> --etag-only)

# Update with concurrency protection
beans update <id> --status completed --if-match "$ETAG"

# On conflict, get clear error with current etag
# Error: etag mismatch (expected abc123, got def456)

- Add ETag() method to Bean using FNV-1a 64-bit hash of rendered content
- Include etag field in JSON output via custom MarshalJSON
- Add etag field to GraphQL schema (Bean type)
- Add ifMatch parameter to GraphQL mutations (updateBean, setParent, addBlocking, removeBlocking)
- Add --if-match flag to beans update command for optimistic locking
- Add --etag-only flag to beans show for easy etag extraction
- Add typed errors (ETagMismatchError, ETagRequiredError) with ErrConflict code
- Add require_if_match config option to enforce ETag usage
- Add comprehensive tests for ETag functionality

ETag enables concurrent modification detection:
  ETAG=$(beans show <id> --etag-only)
  beans update <id> --status completed --if-match "$ETAG"

On conflict, returns CONFLICT error with current etag.
@hmans hmans merged commit ccb4845 into hmans:main Jan 17, 2026
1 check passed
@hmans
Copy link
Copy Markdown
Owner

hmans commented Jan 17, 2026

Thank you! 🙏

hmans added a commit to divaltor/beans that referenced this pull request Jan 17, 2026
* origin/main:
  feat(tui): Two-column layout with detail preview (hmans#42)
  feat: add ETag support for optimistic concurrency control (hmans#59)
  feat(plugin): improve OpenCode plugin robustness with availability checks (hmans#58)
  feat(cli): Add --prefix flag to create command (hmans#56)
  chore: clean up README.md
  docs: only push prime if it exists (hmans#52)
  fix: normalise short IDs when storing relationship links (hmans#50)
@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 19, 2026

Thanks.

I wanted to note that there is still a race condition between etag check and the actual writing of the new data. To remove that race condition would require larger refactoring - I didn't want to convolute this PR.
If you want to, I can work on that as a next step.

hmans added a commit that referenced this pull request Jan 19, 2026
## Summary

Adds CLI flags and GraphQL mutations for partial body modifications,
enabling agents to update bean content without direct file access.
Builds on #59 (ETag support) and supersedes #57.

## Changes

### CLI: Body Modification Flags
- `--body-replace-old` / `--body-replace-new` for exact text replacement
(must match exactly once)
- `--body-append` for appending content (supports stdin with `-`)
- Mutual exclusivity with existing `--body`/`--body-file` flags

### GraphQL: Partial Body Mutations
- `replaceInBody(id, old, new, ifMatch)` - replace exactly one
occurrence of text
- `appendToBody(id, content, ifMatch)` - append content with blank line
separator
- Both support `ifMatch` for optimistic locking

### Shared Logic
- Extract `ReplaceOnce` and `AppendWithSeparator` to
`internal/bean/content.go`
- Reused by both CLI and GraphQL resolvers

### Documentation
- Add GraphQL API section to README with examples

## Usage

```bash
# CLI: Check off a task
beans update <id> --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task"

# CLI: Append notes
beans update <id> --body-append "## Notes\n\nSome notes"

# GraphQL: Check off a task
beans query 'mutation { replaceInBody(id: "bean-xxx", old: "- [ ] Task", new: "- [x] Task") { body } }'

# GraphQL: Append content
beans query 'mutation { appendToBody(id: "bean-xxx", content: "## Notes") { body } }'
```

## Why This Matters for Agents

Agents can now modify bean bodies entirely through CLI/GraphQL without
needing:
- Direct file access to the `.beans/` directory
- Knowledge of where beans are stored (which can be configured outside
the repo)
- Special file permissions

## Testing

- Comprehensive tests for shared logic in
`internal/bean/content_test.go`
- GraphQL resolver tests for both mutations
- CLI tests updated for new behavior

---------

Co-authored-by: Hendrik Mans <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants