html_url,issue_url,id,node_id,user,created_at,updated_at,author_association,body,reactions,issue,performed_via_github_app
https://github.com/simonw/datasette/pull/683#issuecomment-590608228,https://api.github.com/repos/simonw/datasette/issues/683,590608228,MDEyOklzc3VlQ29tbWVudDU5MDYwODIyOA==,9599,2020-02-24T23:52:35Z,2020-02-24T23:52:35Z,OWNER,I'm going to punt on the ability to introspect the write queue and poll for completion using a UUID for the moment. Can add those later.,"{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590607385,https://api.github.com/repos/simonw/datasette/issues/683,590607385,MDEyOklzc3VlQ29tbWVudDU5MDYwNzM4NQ==,9599,2020-02-24T23:49:37Z,2020-02-24T23:49:37Z,OWNER,"Here's the `upload_csv.py` plugin file I've been playing with:
```python
from datasette import hookimpl
from starlette.responses import PlainTextResponse, HTMLResponse
from starlette.endpoints import HTTPEndpoint
import csv as csv_std
import codecs
import sqlite_utils


class UploadApp(HTTPEndpoint):
    def __init__(self, scope, receive, send, datasette):
        self.datasette = datasette
        super().__init__(scope, receive, send)

    def get_database(self):
        # For the moment just use the first one that's not immutable
        mutable = [db for db in self.datasette.databases.values() if db.is_mutable]
        return mutable[0]

    async def get(self, request):
        return HTMLResponse(
            await self.datasette.render_template(
                ""upload_csv.html"", {""database_name"": self.get_database().name}
            )
        )

    async def post(self, request):
        formdata = await request.form()
        csv = formdata[""csv""]
        # csv.file is a SpooledTemporaryFile, I can read it directly
        filename = csv.filename
        # TODO: Support other encodings:
        reader = csv_std.reader(codecs.iterdecode(csv.file, ""utf-8""))
        headers = next(reader)
        docs = (dict(zip(headers, row)) for row in reader)
        if filename.endswith("".csv""):
            filename = filename[:-4]
        # Import data into a table of that name using sqlite-utils
        db = self.get_database()

        def fn(conn):
            writable_conn = sqlite_utils.Database(db.path)
            writable_conn[filename].insert_all(docs, alter=True)
            return writable_conn[filename].count

        # Without block=True we may attempt 'select count(*) from ...'
        # before the table has been created by the write thread
        count = await db.execute_write_fn(fn, block=True)

        return HTMLResponse(
            await self.datasette.render_template(
                ""upload_csv_done.html"",
                {
                    ""database"": self.get_database().name,
                    ""table"": filename,
                    ""num_docs"": count,
                },
            )
        )


@hookimpl
def asgi_wrapper(datasette):
    def wrap_with_asgi_auth(app):
        async def wrapped_app(scope, recieve, send):
            if scope[""path""] == ""/-/upload-csv"":
                await UploadApp(scope, recieve, send, datasette)
            else:
                await app(scope, recieve, send)

        return wrapped_app

    return wrap_with_asgi_auth
```
I also dropped copies of the two template files from https://github.com/simonw/datasette-upload-csvs/tree/699e6ca591f36264bfc8e590d877e6852f274beb/datasette_upload_csvs/templates into my `write-templates/` directory.","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590606825,https://api.github.com/repos/simonw/datasette/issues/683,590606825,MDEyOklzc3VlQ29tbWVudDU5MDYwNjgyNQ==,9599,2020-02-24T23:47:38Z,2020-02-24T23:47:38Z,OWNER,"Another demo plugin: `delete_table.py`
```python
from datasette import hookimpl
from datasette.utils import escape_sqlite
from starlette.responses import HTMLResponse
from starlette.endpoints import HTTPEndpoint


class DeleteTableApp(HTTPEndpoint):
    def __init__(self, scope, receive, send, datasette):
        self.datasette = datasette
        super().__init__(scope, receive, send)

    async def post(self, request):
        formdata = await request.form()
        database = formdata[""database""]
        db = self.datasette.databases[database]
        await db.execute_write(""drop table {}"".format(escape_sqlite(formdata[""table""])))
        return HTMLResponse(""Table has been deleted."")


@hookimpl
def asgi_wrapper(datasette):
    def wrap_with_asgi_auth(app):
        async def wrapped_app(scope, recieve, send):
            if scope[""path""] == ""/-/delete-table"":
                await DeleteTableApp(scope, recieve, send, datasette)
            else:
                await app(scope, recieve, send)

        return wrapped_app

    return wrap_with_asgi_auth
```
Then I saved this as `table.html` in the `write-templates/` directory:
```html+django
{% extends ""default:table.html"" %}

{% block content %}
<form action=""/-/delete-table"" method=""POST"">
    <p>
        <input type=""hidden"" name=""database"" value=""{{ database }}"">
        <input type=""hidden"" name=""table"" value=""{{ table }}"">
        <input type=""submit"" value=""Delete this table"">
    </p>
</form>
{{ super() }}
{% endblock %}
```
(Needs CSRF protection added)

I ran Datasette like this:

    $ datasette --plugins-dir=write-plugins/ data.db --template-dir=write-templates/

Result: I can delete tables!

<img width=""596"" alt=""data__everything__30_132_rows_-_Mozilla_Firefox"" src=""https://user-images.githubusercontent.com/9599/75201302-f9cec580-571c-11ea-9c55-67a49e68ec0c.png"">
","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590599257,https://api.github.com/repos/simonw/datasette/issues/683,590599257,MDEyOklzc3VlQ29tbWVudDU5MDU5OTI1Nw==,9599,2020-02-24T23:21:56Z,2020-02-24T23:22:35Z,OWNER,"Also: are UUIDs really necessary here or could I use a simpler form of task identifier? Like an in-memory counter variable that starts at 0 and increments every time this instance of Datasette issues a new task ID?

The neat thing about UUIDs is that I don't have to worry if there are multiple Datasette instances accepting writes behind a load balancer. That seems pretty unlikely (especially considering SQLite databases encourage only one process to be writing at a time)... but I am experimenting with PostgreSQL support in #670 so it's probably worth ensuring these task IDs really are globally unique.

I'm going to stick with UUIDs. They're short-lived enough that their size doesn't really matter.","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590598689,https://api.github.com/repos/simonw/datasette/issues/683,590598689,MDEyOklzc3VlQ29tbWVudDU5MDU5ODY4OQ==,9599,2020-02-24T23:20:11Z,2020-02-24T23:20:11Z,OWNER,"I think `if block` it makes sense to return the return value of the function that was executed. Without it all I really need to do is return the `uuid` so something could theoretically poll for completion later on.

But is it weird having a function that returns different types depending on if you passed `block=True` or not? Should they be differently named functions?

I'm OK with the `block=True` pattern changing the return value I think.","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590598248,https://api.github.com/repos/simonw/datasette/issues/683,590598248,MDEyOklzc3VlQ29tbWVudDU5MDU5ODI0OA==,9599,2020-02-24T23:18:50Z,2020-02-24T23:18:50Z,OWNER,"I'm not convinced by the return value of the `.execute_write_fn()` method:

https://github.com/simonw/datasette/blob/ab2348280206bde1390b931ae89d372c2f74b87e/datasette/database.py#L79-L83

Do I really need that `WriteResponse` class or can I do something nicer?","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590593120,https://api.github.com/repos/simonw/datasette/issues/683,590593120,MDEyOklzc3VlQ29tbWVudDU5MDU5MzEyMA==,9599,2020-02-24T23:02:30Z,2020-02-24T23:02:30Z,OWNER,"I'm going to muck around with a couple more demo plugins - in particular one derived from [datasette-upload-csvs](https://github.com/simonw/datasette-upload-csvs) - to make sure I'm comfortable with this API - then add a couple of tests and merge it with documentation that warns ""this is still an experimental feature and may change"".","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590592581,https://api.github.com/repos/simonw/datasette/issues/683,590592581,MDEyOklzc3VlQ29tbWVudDU5MDU5MjU4MQ==,9599,2020-02-24T23:00:44Z,2020-02-24T23:01:09Z,OWNER,"I've been testing this out by running one-off demo plugins. I saved the following in a file called `write-plugins/log_asgi.py` (it's a hacked around copy of [asgi-log-to-sqlite](https://github.com/simonw/asgi-log-to-sqlite)) and then running `datasette data.db --plugins-dir=write-plugins/`:
```python
from datasette import hookimpl
import sqlite_utils
import time


class AsgiLogToSqliteViaWriteQueue:
    lookup_columns = (
        ""path"",
        ""user_agent"",
        ""referer"",
        ""accept_language"",
        ""content_type"",
        ""query_string"",
    )

    def __init__(self, app, db):
        self.app = app
        self.db = db
        self._tables_ensured = False

    async def ensure_tables(self):
        def _ensure_tables(conn):
            db = sqlite_utils.Database(conn)
            for column in self.lookup_columns:
                table = ""{}s"".format(column)
                if not db[table].exists():
                    db[table].create({""id"": int, ""name"": str}, pk=""id"")
            if ""requests"" not in db.table_names():
                db[""requests""].create(
                    {
                        ""start"": float,
                        ""method"": str,
                        ""path"": int,
                        ""query_string"": int,
                        ""user_agent"": int,
                        ""referer"": int,
                        ""accept_language"": int,
                        ""http_status"": int,
                        ""content_type"": int,
                        ""client_ip"": str,
                        ""duration"": float,
                        ""body_size"": int,
                    },
                    foreign_keys=self.lookup_columns,
                )
        await self.db.execute_write_fn(_ensure_tables)

    async def __call__(self, scope, receive, send):
        if not self._tables_ensured:
            self._tables_ensured = True
            await self.ensure_tables()

        response_headers = []
        body_size = 0
        http_status = None

        async def wrapped_send(message):
            nonlocal body_size, response_headers, http_status
            if message[""type""] == ""http.response.start"":
                response_headers = message[""headers""]
                http_status = message[""status""]

            if message[""type""] == ""http.response.body"":
                body_size += len(message[""body""])

            await send(message)

        start = time.time()
        await self.app(scope, receive, wrapped_send)
        end = time.time()

        path = str(scope[""path""])
        query_string = None
        if scope.get(""query_string""):
            query_string = ""?{}"".format(scope[""query_string""].decode(""utf8""))

        request_headers = dict(scope.get(""headers"") or [])

        referer = header(request_headers, ""referer"")
        user_agent = header(request_headers, ""user-agent"")
        accept_language = header(request_headers, ""accept-language"")

        content_type = header(dict(response_headers), ""content-type"")

        def _log_to_database(conn):
            db = sqlite_utils.Database(conn)
            db[""requests""].insert(
                {
                    ""start"": start,
                    ""method"": scope[""method""],
                    ""path"": lookup(db, ""paths"", path),
                    ""query_string"": lookup(db, ""query_strings"", query_string),
                    ""user_agent"": lookup(db, ""user_agents"", user_agent),
                    ""referer"": lookup(db, ""referers"", referer),
                    ""accept_language"": lookup(db, ""accept_languages"", accept_language),
                    ""http_status"": http_status,
                    ""content_type"": lookup(db, ""content_types"", content_type),
                    ""client_ip"": scope.get(""client"", (None, None))[0],
                    ""duration"": end - start,
                    ""body_size"": body_size,
                },
                alter=True,
                foreign_keys=self.lookup_columns,
            )

        await self.db.execute_write_fn(_log_to_database)


def header(d, name):
    return d.get(name.encode(""utf8""), b"""").decode(""utf8"") or None


def lookup(db, table, value):
    return db[table].lookup({""name"": value}) if value else None


@hookimpl
def asgi_wrapper(datasette):
    def wrap_with_class(app):
        return AsgiLogToSqliteViaWriteQueue(
            app, next(iter(datasette.databases.values()))
        )

    return wrap_with_class
```","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,
https://github.com/simonw/datasette/pull/683#issuecomment-590518182,https://api.github.com/repos/simonw/datasette/issues/683,590518182,MDEyOklzc3VlQ29tbWVudDU5MDUxODE4Mg==,9599,2020-02-24T19:53:12Z,2020-02-24T19:53:12Z,OWNER,"Next steps are from comment https://github.com/simonw/datasette/issues/682#issuecomment-590517338
> I'm going to move ahead without needing that ability though. I figure SQLite writes are _fast_, and plugins can be trusted to implement just fast writes. So I'm going to support either fire-and-forget writes (they get added to the queue and a task ID is returned) or have the option to block awaiting the completion of the write (using Janus) but let callers decide which version they want. I may add optional timeouts some time in the future.
> 
> I am going to make both `execute_write()` and `execute_write_fn()` awaitable functions though, for consistency with `.execute()` and to give me flexibility to change how they work in the future.
> 
> I'll also add a `block=True` option to both of them which causes the function to wait for the write to be successfully executed - defaults to `False` (fire-and-forget mode).","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",570101428,