durably 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. durably-0.1.0/.gitignore +9 -0
  2. durably-0.1.0/CHANGELOG.md +60 -0
  3. durably-0.1.0/LICENSE +21 -0
  4. durably-0.1.0/PKG-INFO +343 -0
  5. durably-0.1.0/README.md +310 -0
  6. durably-0.1.0/examples/research_agent/README.md +46 -0
  7. durably-0.1.0/pyproject.toml +62 -0
  8. durably-0.1.0/src/durably/__init__.py +26 -0
  9. durably-0.1.0/src/durably/activity_context.py +55 -0
  10. durably-0.1.0/src/durably/cli.py +41 -0
  11. durably-0.1.0/src/durably/client.py +104 -0
  12. durably-0.1.0/src/durably/context.py +276 -0
  13. durably-0.1.0/src/durably/db.py +46 -0
  14. durably-0.1.0/src/durably/errors.py +30 -0
  15. durably-0.1.0/src/durably/failpoints.py +42 -0
  16. durably-0.1.0/src/durably/py.typed +0 -0
  17. durably-0.1.0/src/durably/registry.py +44 -0
  18. durably-0.1.0/src/durably/retry.py +24 -0
  19. durably-0.1.0/src/durably/schema.sql +74 -0
  20. durably-0.1.0/src/durably/store.py +347 -0
  21. durably-0.1.0/src/durably/testing.py +117 -0
  22. durably-0.1.0/src/durably/worker.py +247 -0
  23. durably-0.1.0/tests/conftest.py +168 -0
  24. durably-0.1.0/tests/crash_app.py +44 -0
  25. durably-0.1.0/tests/test_continue_as_new.py +79 -0
  26. durably-0.1.0/tests/test_crash.py +102 -0
  27. durably-0.1.0/tests/test_engine.py +142 -0
  28. durably-0.1.0/tests/test_failpoints.py +13 -0
  29. durably-0.1.0/tests/test_fencing.py +66 -0
  30. durably-0.1.0/tests/test_heartbeat.py +65 -0
  31. durably-0.1.0/tests/test_model.py +166 -0
  32. durably-0.1.0/tests/test_network.py +37 -0
  33. durably-0.1.0/tests/test_replay.py +194 -0
  34. durably-0.1.0/tests/test_schema.py +64 -0
  35. durably-0.1.0/tests/test_signals.py +78 -0
  36. durably-0.1.0/tests/test_store.py +116 -0
  37. durably-0.1.0/tests/test_timers.py +33 -0
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .hypothesis/
9
+ .env
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to durably are listed here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/). Until 1.0, minor versions may change the API
6
+ or the database schema; such changes and their migration steps are listed here.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-11-07
11
+
12
+ First release.
13
+
14
+ ### Added
15
+ - Workflow engine: workflows replay against an append-only event history in Postgres;
16
+ recorded activity results are returned instead of re-run.
17
+ - Task claiming with `FOR UPDATE SKIP LOCKED` and epoch fencing on every history write.
18
+ - Activity workers with retries (exponential backoff and jitter), timeouts and a stable
19
+ idempotency key (`workflow_id:activity_seq`).
20
+ - `Client.start` (idempotent by key), `Client.result`, `Client.describe`, `Client.history`.
21
+ - CLI: `durably init-db`, `durably worker --kind workflow|activity`.
22
+ - `durably.testing.FakeLLM` for tests.
23
+ - Failpoints (`FAILPOINT=<name>` / `<name>:pause`) for crash-testing workers.
24
+ - `durably.testing.SharedFakeLLM`: a fake provider whose counters live in Postgres, so
25
+ they are shared across worker processes and survive crashes.
26
+ - Crash-recovery test suite with real worker processes: resume after a crash, a
27
+ failpoint matrix, and a frozen-worker test with fencing on and off.
28
+ - Activity heartbeats: `await durably.heartbeat(details)` extends the lease;
29
+ `heartbeat_timeout=` on `ctx.activity` retries hung attempts; the next attempt reads the
30
+ last details from `durably.activity_info().heartbeat_details`.
31
+ - Durable timers: `await ctx.sleep(seconds)` survives crashes and deploys; each timer
32
+ fires exactly once. Workflow workers fire due timers.
33
+ - Signals: `client.signal(id, name, payload)` and `await ctx.wait_signal(name, timeout=)`.
34
+ Signals are received in send order, each exactly once; signalling a finished workflow
35
+ raises `WorkflowNotRunningError`.
36
+ - `ctx.now()`, `ctx.random()` and `ctx.uuid4()`: values are recorded as markers the first
37
+ time, so every replay sees the same value.
38
+ - Continue-As-New: `ctx.should_continue_as_new()` turns True once a run's history reaches
39
+ `--max-history` events (default 1000); `ctx.continue_as_new(input)` starts a fresh run,
40
+ carrying over untaken signals. `client.result` and `client.signal` follow the chain;
41
+ `client.runs(id)` lists every run.
42
+ - Workers back off exponentially (up to 2 s) while the database is unreachable.
43
+ - Database calls time out (60 s by default, `durably worker --db-timeout`) instead of
44
+ waiting forever on a connection that stopped answering.
45
+ - Tests for recovery from dropped database connections (Toxiproxy) and a Hypothesis
46
+ state-machine test against a reference model.
47
+ - `FAILPOINT=<name>@<n>` fires on the n-th hit.
48
+ - Benchmark scripts in `experiments/` (`python -m experiments.run_all`).
49
+ - Runnable research agent example in `examples/research_agent`.
50
+ - Packaging: PyPI metadata, `py.typed` for type checkers, mypy-clean source.
51
+ - CI on GitHub Actions (type check, tests, example, package build; install check on
52
+ Linux, macOS and Windows with Python 3.12 and 3.13) and a release workflow that
53
+ publishes to PyPI when a version tag is pushed.
54
+
55
+ ### Fixed
56
+ - A deadlock between delivering an event (activity result, timer, signal) and claiming
57
+ the same workflow's task: all writes now take task locks before workflow locks.
58
+
59
+ [Unreleased]: https://github.com/tarang802/Durable-agent-runtime/compare/v0.1.0...HEAD
60
+ [0.1.0]: https://github.com/tarang802/Durable-agent-runtime/releases/tag/v0.1.0
durably-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tarang802
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
durably-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,343 @@
1
+ Metadata-Version: 2.5
2
+ Name: durably
3
+ Version: 0.1.0
4
+ Summary: Durable execution for AI agents, with just Postgres.
5
+ Project-URL: Homepage, https://github.com/tarang802/Durable-agent-runtime
6
+ Project-URL: Repository, https://github.com/tarang802/Durable-agent-runtime
7
+ Project-URL: Issues, https://github.com/tarang802/Durable-agent-runtime/issues
8
+ Project-URL: Changelog, https://github.com/tarang802/Durable-agent-runtime/blob/main/CHANGELOG.md
9
+ Author: tarang802
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai agents,asyncio,durable execution,llm,postgres,workflow
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Database
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: asyncpg>=0.30
25
+ Provides-Extra: dev
26
+ Requires-Dist: build>=1.2; extra == 'dev'
27
+ Requires-Dist: hypothesis>=6.112; extra == 'dev'
28
+ Requires-Dist: mypy>=1.13; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
30
+ Requires-Dist: pytest>=8.3; extra == 'dev'
31
+ Requires-Dist: twine>=6.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # durably
35
+
36
+ [![CI](https://github.com/tarang802/Durable-agent-runtime/actions/workflows/ci.yml/badge.svg)](https://github.com/tarang802/Durable-agent-runtime/actions/workflows/ci.yml)
37
+
38
+ **Durable execution for AI agents, with just Postgres.**
39
+
40
+ Your agent crashes after a paid LLM call. durably resumes it on another worker
41
+ without repeating the call — and without running a separate workflow server.
42
+
43
+ > **Early release (0.x):** the core guarantees are tested; the API may change
44
+ > before 1.0. See the [changelog](https://github.com/tarang802/Durable-agent-runtime/blob/main/CHANGELOG.md).
45
+
46
+ ## What it is
47
+
48
+ `durably` is a small workflow engine for long-running agent loops. A workflow is an
49
+ ordinary async Python function that calls activities (an LLM call, a tool call). The
50
+ engine records every step in an append-only history in PostgreSQL. If a worker dies,
51
+ another worker reruns the function against that history: recorded steps return their
52
+ saved result instead of running again, and execution continues from the first
53
+ unrecorded step.
54
+
55
+ **Example.** A research agent plans, runs three searches, summarizes with a paid LLM call,
56
+ then sends an email. The worker is killed after the summary. A naive restart repeats the
57
+ paid call and may send the email twice. With `durably`, the restart skips everything
58
+ recorded and sends the email once.
59
+
60
+ ## Install
61
+
62
+ Requires Python 3.12+ and PostgreSQL. CI tests against PostgreSQL 16.
63
+
64
+ ```bash
65
+ pip install durably
66
+ ```
67
+
68
+ Then create durably's tables in your database (safe to run again):
69
+
70
+ ```bash
71
+ durably init-db --database-url postgres://user:pass@host:5432/db
72
+ ```
73
+
74
+ Every command and `durably.Client.connect()` read `DATABASE_URL` when no URL is given.
75
+
76
+ To see it work first, run the [research agent example](https://github.com/tarang802/Durable-agent-runtime/tree/main/examples/research_agent),
77
+ which plans, searches, summarizes, waits for approval and sends an email:
78
+
79
+ ```bash
80
+ git clone https://github.com/tarang802/Durable-agent-runtime && cd Durable-agent-runtime
81
+ docker compose run --rm tests python -m examples.research_agent.run
82
+ ```
83
+
84
+ ## Usage
85
+
86
+ Activities do the real work. Each one receives a stable `idempotency_key`
87
+ (`workflow_id:activity_seq`) to pass to the provider:
88
+
89
+ ```python
90
+ import durably
91
+
92
+ @durably.activity("llm")
93
+ async def llm(prompt, *, idempotency_key):
94
+ return await my_llm_client.complete(prompt, idempotency_key=idempotency_key)
95
+
96
+ @durably.activity("send_email")
97
+ async def send_email(to, body, *, idempotency_key):
98
+ await my_email_client.send(to, body, idempotency_key=idempotency_key)
99
+ ```
100
+
101
+ A workflow is plain async code that calls activities through `ctx`:
102
+
103
+ ```python
104
+ @durably.workflow("research_agent")
105
+ async def research_agent(ctx, input):
106
+ plan = await ctx.activity("llm", [f"Plan research on {input['topic']}"])
107
+ summary = await ctx.activity("llm", [f"Summarize: {plan}"], retry={"max_attempts": 5}, timeout=60)
108
+ await ctx.activity("send_email", [input["email"], summary])
109
+ return {"summary": summary}
110
+ ```
111
+
112
+ Workflows can sleep for minutes or days and wait for outside events. Both survive
113
+ crashes and deploys:
114
+
115
+ ```python
116
+ @durably.workflow("publish_with_approval")
117
+ async def publish_with_approval(ctx, draft_id):
118
+ draft = await ctx.activity("llm", [f"Write post {draft_id}"])
119
+ decision = await ctx.wait_signal("review", timeout=3 * 24 * 3600) # None if nobody answers in 3 days
120
+ if decision is None or not decision["approved"]:
121
+ return "not published"
122
+ await ctx.sleep(3600) # publish an hour later
123
+ await ctx.activity("publish", [draft])
124
+ return "published"
125
+ ```
126
+
127
+ ```python
128
+ await client.signal(wf_id, "review", {"approved": True})
129
+ ```
130
+
131
+ Long-running activities heartbeat so a hung attempt is detected and retried, resuming
132
+ from the last saved progress:
133
+
134
+ ```python
135
+ @durably.activity("embed_documents")
136
+ async def embed_documents(doc_ids, *, idempotency_key):
137
+ start = durably.activity_info().heartbeat_details or 0 # progress from a previous attempt
138
+ for i in range(start, len(doc_ids)):
139
+ await embed(doc_ids[i])
140
+ await durably.heartbeat(i + 1)
141
+ ```
142
+
143
+ ```python
144
+ await ctx.activity("embed_documents", [doc_ids], heartbeat_timeout=30)
145
+ ```
146
+
147
+ Use `ctx.now()`, `ctx.random()` and `ctx.uuid4()` instead of `datetime.now()`, `random`
148
+ and `uuid`: the first value is recorded, so replays see the same one.
149
+
150
+ Agents that run indefinitely (a chat assistant, a monitor) roll over to a fresh run before
151
+ their history grows large, so replay stays fast:
152
+
153
+ ```python
154
+ @durably.workflow("assistant")
155
+ async def assistant(ctx, state):
156
+ while True:
157
+ message = await ctx.wait_signal("message")
158
+ reply = await ctx.activity("llm", [state["memory"], message])
159
+ state["memory"] = await ctx.activity("summarize", [state["memory"], message, reply])
160
+ if ctx.should_continue_as_new(): # history reached --max-history events
161
+ ctx.continue_as_new(state) # unread messages carry over to the new run
162
+ ```
163
+
164
+ Run workers, then start workflows from your app:
165
+
166
+ ```bash
167
+ durably init-db
168
+ durably worker --kind workflow --module my_app.workflows
169
+ durably worker --kind activity --module my_app.activities
170
+ ```
171
+
172
+ ```python
173
+ client = await durably.Client.connect()
174
+ wf_id = await client.start(research_agent, {"topic": "durable execution", "email": "me@example.com"},
175
+ idempotency_key="research:42")
176
+ print(await client.result(wf_id, timeout=300))
177
+ ```
178
+
179
+ ## Guarantees
180
+
181
+ Each guarantee is checked by a test, most of them with real worker processes that are
182
+ killed or frozen mid-run.
183
+
184
+ | | Guarantee | Test |
185
+ |---|---|---|
186
+ | G1 | History is append-only and `(workflow_id, seq)` is unique | `test_events_are_append_only`, `test_event_seq_is_unique_per_workflow` |
187
+ | G2 | A write from a stale worker is rejected | `test_stale_worker_write_rejected` (paired with `test_without_fencing_stale_worker_corrupts_history`) |
188
+ | G3 | Replay never re-runs an activity recorded as completed | `test_resume_does_not_repeat_completed_activity` |
189
+ | G4 | At most one terminal event per workflow | `test_second_terminal_event_is_rejected` |
190
+ | G5 | A changed workflow body is detected as non-determinism | `test_changed_code_is_nondeterminism` |
191
+ | G6 | With an idempotent provider, every step is billed exactly once, whatever the crash point | `test_crash_matrix` |
192
+ | G7 | A durable timer fires exactly once, even if a worker dies while firing it | `test_timer_fires_once_after_crash_while_firing` |
193
+ | G8 | Each signal is received exactly once, in send order | `test_signals_are_taken_in_order_once_each`, `test_signals_sent_before_any_worker_runs_are_kept_in_order` |
194
+
195
+ ### Crash matrix
196
+
197
+ A worker is killed at each failpoint, a replacement finishes the workflow, and the fake
198
+ provider counts calls:
199
+
200
+ | Worker | Killed at | Workflow finishes | Duplicate provider calls | Steps billed twice |
201
+ |---|---|---|---|---|
202
+ | activity | `before_claim` | ✅ | 0 | 0 |
203
+ | activity | `after_claim` | ✅ | 0 | 0 |
204
+ | activity | `before_activity` | ✅ | 0 | 0 |
205
+ | activity | `after_effect_before_record` | ✅ | 1 | 0 |
206
+ | activity | `after_record_before_ack` | ✅ | 0 | 0 |
207
+ | workflow | `before_claim` | ✅ | 0 | 0 |
208
+ | workflow | `after_claim` | ✅ | 0 | 0 |
209
+ | workflow | `before_append` | ✅ | 0 | 0 |
210
+ | workflow | `after_record_before_ack` | ✅ | 0 | 0 |
211
+
212
+ The one duplicate is unavoidable: the provider did the work but the worker died before
213
+ recording it, so the retry calls again. It carries the same idempotency key, so an
214
+ idempotent provider does not bill it twice.
215
+
216
+ Side effects run **at least once**, with a stable idempotency key
217
+ (`workflow_id:activity_seq`). Exactly-once is impossible in general, so activities should
218
+ be idempotent.
219
+
220
+ ## Benchmarks
221
+
222
+ Generated by the scripts in [`experiments/`](https://github.com/tarang802/Durable-agent-runtime/tree/main/experiments) on a laptop (Docker Desktop on
223
+ Windows, 16 CPUs, Python 3.12, PostgreSQL 16). Full output: [RESULTS.md](https://github.com/tarang802/Durable-agent-runtime/blob/main/experiments/RESULTS.md).
224
+ Regenerate with:
225
+
226
+ ```bash
227
+ docker compose run --rm tests python -m experiments.run_all
228
+ ```
229
+
230
+ **Paid LLM calls when a 5-step agent's worker is killed after step k.** A naive restart
231
+ pays again for every completed step; durably pays for each step once.
232
+
233
+ | Killed after step | Naive restart: paid calls | durably: paid calls |
234
+ |---|---|---|
235
+ | 1 | 6 | 5 |
236
+ | 2 | 7 | 5 |
237
+ | 3 | 8 | 5 |
238
+ | 4 | 9 | 5 |
239
+
240
+ **Recovery time after a workflow worker dies**, from its claim to the workflow finishing on
241
+ a healthy worker. It tracks the lease length, so the lease is the knob.
242
+
243
+ | Lease (s) | Recovery (s) |
244
+ |---|---|
245
+ | 0.5 | 0.57 |
246
+ | 1 | 1.08 |
247
+ | 2 | 2.06 |
248
+ | 5 | 5.07 |
249
+
250
+ **Replay time vs history length** (load from Postgres plus replay). Continue-As-New keeps a
251
+ run at or below its limit, 1,000 events by default.
252
+
253
+ | Events in history | Load (ms) | Replay (ms) | Total (ms) |
254
+ |---|---|---|---|
255
+ | 99 | 0.7 | 0.3 | 1.0 |
256
+ | 999 | 3.3 | 2.0 | 5.4 |
257
+ | 4,999 | 17.6 | 10.2 | 27.8 |
258
+ | 9,999 | 32.2 | 21.5 | 53.7 |
259
+
260
+ **Activity throughput vs worker processes** (2,000 no-op activities, each a claim, run and
261
+ record). In this run two processes measured below one; throughput then grows with more
262
+ processes.
263
+
264
+ | Worker processes | Tasks per second |
265
+ |---|---|
266
+ | 1 | 177 |
267
+ | 2 | 151 |
268
+ | 4 | 296 |
269
+ | 8 | 530 |
270
+
271
+ ## When to use durably vs. Temporal
272
+
273
+ | | durably | Temporal |
274
+ |---|---|---|
275
+ | Infrastructure | The Postgres you already run | A Temporal server cluster (or Temporal Cloud) |
276
+ | Languages | Python | Go, Java, TypeScript, Python, .NET, PHP |
277
+ | Scale | One Postgres; thousands of workflows | Very large fleets, sharded history |
278
+ | Workflow versioning | Detects changed code as non-determinism | Mature versioning and worker build IDs |
279
+ | Setup | `pip install` + `durably init-db` | Deploy and operate a server |
280
+
281
+ Choose durably when you want crash-safe agents inside an existing Python + Postgres
282
+ backend without another service to run. Choose Temporal when you need multi-language
283
+ workflows or scale beyond a single database.
284
+
285
+ ## Known limitations
286
+
287
+ - Activities in one workflow run one at a time; `asyncio.gather` over activities is not
288
+ supported.
289
+ - An activity that runs longer than the worker lease (30 s by default) without a
290
+ `heartbeat_timeout` is treated as crashed and run again. Heartbeat long activities.
291
+ - Workflow code must make the same calls on every replay: use `ctx.now()`, `ctx.random()`
292
+ and `ctx.sleep()` rather than the clock, `random` or `asyncio.sleep`, and do network I/O
293
+ in activities.
294
+ - Replay reads a run's whole history, so workflows that loop indefinitely should use
295
+ Continue-As-New.
296
+ - No schema migrations: upgrading between 0.x releases may require re-creating tables.
297
+ - Side effects are at-least-once; make activities idempotent with the key provided.
298
+
299
+ ## Stack
300
+
301
+ Python 3.12+, asyncio and asyncpg (the only runtime dependency), PostgreSQL 16. Tests use
302
+ pytest, Hypothesis, Toxiproxy and Docker Compose.
303
+
304
+ ## Development
305
+
306
+ Tests run in a Linux container next to Postgres 16 and Toxiproxy, so they behave the same
307
+ on every machine (crash tests rely on POSIX signals). You only need Docker.
308
+
309
+ ```bash
310
+ docker compose run --rm --build tests
311
+ ```
312
+
313
+ Crash tests use failpoints: `FAILPOINT=<name>` makes a worker exit abruptly at that point,
314
+ and `FAILPOINT=<name>:pause` freezes it with SIGSTOP. You can use them to crash-test your
315
+ own workflows the same way.
316
+
317
+ What the suite covers:
318
+
319
+ - **Crash recovery** with real worker processes killed or frozen at each failpoint.
320
+ - **Dropped connections:** Toxiproxy cuts the workers' Postgres connections mid-activity;
321
+ they back off, reconnect and finish without billing a step twice.
322
+ - **Model-based testing:** a Hypothesis state machine runs random interleavings of
323
+ starts, signals, worker steps and crashes, and checks history against a reference model
324
+ after every step.
325
+
326
+ When the model test fails it prints the minimal failing steps and a
327
+ `@reproduce_failure(...)` line to paste above the test. To rerun a whole session
328
+ deterministically, pass a seed (more examples with `DURABLY_MODEL_EXAMPLES`):
329
+
330
+ ```bash
331
+ docker compose run --rm tests pytest tests/test_model.py --hypothesis-seed=12345
332
+ ```
333
+
334
+ To create the schema in your own Postgres:
335
+
336
+ ```bash
337
+ pip install -e .
338
+ durably init-db --database-url postgres://user:pass@host:5432/db
339
+ ```
340
+
341
+ ## License
342
+
343
+ MIT