Skip to content

write_wrapper plugin hook for intercepting write operations#2636

Merged
simonw merged 5 commits intomainfrom
claude/implement-wrap-write-hook-BxQJ3
Feb 9, 2026
Merged

write_wrapper plugin hook for intercepting write operations#2636
simonw merged 5 commits intomainfrom
claude/implement-wrap-write-hook-BxQJ3

Conversation

@simonw
Copy link
Owner

@simonw simonw commented Feb 9, 2026

Fetch https://raw.githubusercontent.com/datasette/datasette-sqlite-history-json/38360848cc0458ca339c77a92de98ef25f84ec3b/docs/design-change-groups.md to /tmp using curl, then read it

Implement the proposed wrap_write() plugin hook, but with a difference. The proposal in that document has it returning a wrapper function. Instead, have it return a function that acts as a context manager - so the user provided function takes a connection and can then run some code, then yield to allow Datasette to execute its default write stuff, and then run more code afterwards.

Ask clarifying questions and propose your design before implementing anything

Summary

This PR introduces a new wrap_write plugin hook that allows plugins to intercept and wrap database write operations. This enables use cases like transaction management, change tracking, audit logging, and other cross-cutting concerns around writes.

Key Changes

  • New wrap_write hookspec in datasette/hookspecs.py: Defines the plugin hook interface that accepts datasette, database, request, and transaction parameters and returns a generator-based context manager wrapper.

  • Request parameter propagation: Added optional request parameter to execute_write(), execute_write_script(), execute_write_many(), and execute_write_fn() methods to pass HTTP request context to plugins.

  • Hook wrapping implementation in datasette/database.py:

    • New _wrap_fn_with_hooks() method that collects and applies all registered wrap_write wrappers
    • New _apply_wrap_write() utility function that properly handles generator-based context managers, including exception propagation and result passing via .send()
  • View layer updates: Modified write operations in datasette/views/database.py, datasette/views/row.py, and datasette/views/table.py to pass the request object to write methods.

  • Comprehensive test coverage in new tests/test_wrap_write.py with 13 test cases covering:

    • Before/after hook execution
    • Result passing through yield
    • Exception handling in generators
    • Multiple plugin nesting
    • Request and transaction parameter passing
    • Integration with API write operations
    • Real-world use case example (change group pattern)

Implementation Details

The wrap_write hook uses Python generators as context managers. Plugins return a generator function that:

  • Runs setup code before the yield
  • Yields to allow the write to execute
  • Runs cleanup code after the yield
  • Can capture the write result via result = yield
  • Can handle exceptions thrown into the generator

Multiple wrappers are nested in reverse order (LIFO) so the first registered plugin is the outermost wrapper, maintaining intuitive execution order.

https://claude.ai/code/session_019hWsbmHqitdVNCNChN5aNJ


📚 Documentation preview 📚: https://datasette--2636.org.readthedocs.build/en/2636/

https://gisthost.github.io/?c4c12079434e69677e4aa8ac664b21b8/index.html

Refs:

Add a new `wrap_write` plugin hook that lets plugins wrap write
operations with before/after logic using a generator-based context
manager pattern. The hook receives (datasette, database, request,
transaction) and returns a generator function that takes a conn,
yields once to let the write execute, and can run cleanup after.

The write result is sent back via generator.send() and exceptions
are thrown via generator.throw(), giving plugins full visibility.

Also adds `request=None` parameter to execute_write, execute_write_fn,
execute_write_script, and execute_write_many, and threads request
through all view-layer call sites (insert, upsert, update, delete,
drop, create table, canned queries).

https://claude.ai/code/session_019hWsbmHqitdVNCNChN5aNJ
Document the wrap_write plugin hook in plugin_hooks.rst with
parameter descriptions and two examples: a simple logging wrapper
and an advanced SQLite authorizer-based table protection pattern.

Also fix black formatting and remove unused variable flagged by ruff.

https://claude.ai/code/session_019hWsbmHqitdVNCNChN5aNJ
@codecov
Copy link

codecov bot commented Feb 9, 2026

Codecov Report

❌ Patch coverage is 95.65217% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.39%. Comparing base (5873578) to head (0486988).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
datasette/database.py 94.73% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2636      +/-   ##
==========================================
+ Coverage   90.37%   90.39%   +0.01%     
==========================================
  Files          52       52              
  Lines        7983     8017      +34     
==========================================
+ Hits         7215     7247      +32     
- Misses        768      770       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@simonw
Copy link
Owner Author

simonw commented Feb 9, 2026

I pitched Claude the idea of having the plugin function able to yield conn with a completely new connection which would then be used by the rest of the stack, to allow for fancy ideas like having per-actor connections that persist across requests, or something like https://github.com/datasette/datasette-transactions

Quoting the response in full because it's worth recording somewhere:

This is a fascinating idea. Let me think through it carefully.


yield conn --- Connection Substitution via wrap_write

The idea: if the generator yields a connection object instead of None, Datasette uses that yielded connection for the write instead of the default shared write connection. This would let a plugin:

  1. Hold open a dedicated connection per actor --- reused across requests
  2. Hold a transaction open across multiple HTTP requests --- exactly what datasette-transactions does, but without needing to bypass execute_write_fn entirely

How datasette-transactions works today

The plugin completely sidesteps Datasette's write system. It:

  • Opens its own sqlite3.connect(db.path, isolation_level=None, check_same_thread=False) connections
  • Manages them in a global TransactionManager singleton
  • Registers its own routes (/-/transactions/begin/.../-/transactions/{uuid}, etc.)
  • Never touches execute_write_fn at all --- it runs SQL directly against its own connections

This means writes done through datasette-transactions don't trigger any of the normal Datasette write hooks --- no wrap_write, no track_event. They're invisible to the rest of the plugin ecosystem.

What yield conn could enable

If wrap_write allowed yielding a substitute connection:

@hookimpl
def wrap_write(datasette, database, request):
    # Look up whether this actor has an open transaction
    tx_state = get_transaction_for_actor(request)
    if tx_state is None:
        return None  # Use default connection

    def wrapper(conn):
        # Yield a different connection --- this one has an open transaction
        yield tx_state.connection

    return wrapper

Then writes going through the normal Datasette API (row insert/update/delete/drop, canned queries) would automatically be redirected to the actor's dedicated transactional connection. This would mean:

  • The existing Datasette table UI and JSON API would just work inside transactions
  • Other wrap_write plugins (audit, change groups, authorizer) would still fire
  • track_event would still fire
  • No need for separate routes or a custom SQL execution endpoint

The tricky parts

1. The write thread model breaks down. Today, execute_write_fn serializes everything through one connection on one dedicated thread. If a plugin yields a different connection, that connection might be owned by a different thread, or might need to live longer than a single execute_write_fn call. The yielded connection would need check_same_thread=False and its own locking.

2. Transaction boundary management. The with conn: transaction wrapper in _execute_writes and the non-threaded path would need to be skipped or handled differently when a substitute connection is in use --- the plugin is managing the transaction itself. The transaction parameter being passed to the hook helps here: the plugin can see it and the hook could signal "I'm managing transactions myself."

3. Connection lifetime. The substitute connection outlives a single write call. Who closes it? The plugin would need to manage lifecycle (begin, commit/rollback, close) through its own endpoints, just like datasette-transactions does today --- but the execution of writes flows through Datasette's normal path.

4. prepare_connection hooks. Datasette calls _prepare_connection on the default write connection. A substitute connection from a plugin wouldn't have had those hooks applied unless the plugin explicitly calls datasette._prepare_connection(conn, database) or Datasette does it automatically.

Implementation sketch

The change to _apply_wrap_write would be small. Currently:

result = fn(conn)  # always uses the original conn

With yield conn support:

yielded = next(gen)
# If generator yielded a connection, use that instead
effective_conn = yielded if yielded is not None else conn
result = fn(effective_conn)

But the harder question is whether to skip the outer transaction wrapping (with conn:) when a substitute connection is used. That probably needs to be a separate flag or convention.

Is this worth building now?

I think the concept is powerful and the implementation change to _apply_wrap_write is minimal. But the real complexity is in the interaction with the write thread and transaction management --- specifically, when a plugin yields a substitute connection:

  • Should execute_write_fn still send the task to the write thread? (Probably yes, to maintain serialization)
  • Should the with conn: transaction wrapper use the substitute connection? (Probably not --- the plugin owns the transaction)
  • Should _prepare_connection be called on substitute connections? (Probably --- but only once, not per-write)

@simonw
Copy link
Owner Author

simonw commented Feb 9, 2026

Consolidate duplicate test cases: merge before/after tests for
execute_write_fn and execute_write into one parametrized test, and
merge three parameter-passing tests into one parametrized test.

https://claude.ai/code/session_019hWsbmHqitdVNCNChN5aNJ
@simonw simonw changed the title Add wrap_write plugin hook for intercepting write operations write_wrapper plugin hook for intercepting write operations Feb 9, 2026
@simonw simonw added the plugins label Feb 9, 2026
Copy link
Owner Author

@simonw simonw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After several iterations this all looks great to me.

@simonw simonw linked an issue Feb 9, 2026 that may be closed by this pull request
@simonw simonw merged commit 80b7f98 into main Feb 9, 2026
39 checks passed
@simonw simonw deleted the claude/implement-wrap-write-hook-BxQJ3 branch February 9, 2026 21:20
simonw added a commit that referenced this pull request Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

write_wrapper plugin hook

2 participants