morphdb 0.1.5__tar.gz → 0.2.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 (44) hide show
  1. {morphdb-0.1.5 → morphdb-0.2.0}/PKG-INFO +56 -16
  2. {morphdb-0.1.5 → morphdb-0.2.0}/README.md +52 -14
  3. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/__init__.py +1 -1
  4. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/__main__.py +4 -2
  5. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/associations.py +2 -2
  6. morphdb-0.2.0/morphdb/backend.py +320 -0
  7. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/cli/dashboard.py +38 -29
  8. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/cli/main.py +6 -4
  9. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/cli/service.py +32 -8
  10. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/db.py +87 -55
  11. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/objects.py +8 -6
  12. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/server.py +8 -3
  13. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb.egg-info/PKG-INFO +56 -16
  14. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb.egg-info/SOURCES.txt +2 -0
  15. morphdb-0.2.0/morphdb.egg-info/requires.txt +6 -0
  16. {morphdb-0.1.5 → morphdb-0.2.0}/pyproject.toml +6 -3
  17. morphdb-0.2.0/tests/test_backend.py +104 -0
  18. morphdb-0.1.5/morphdb.egg-info/requires.txt +0 -3
  19. {morphdb-0.1.5 → morphdb-0.2.0}/LICENSE +0 -0
  20. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/apps.py +0 -0
  21. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/cli/__init__.py +0 -0
  22. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/cli/mcp.py +0 -0
  23. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/cli/skill.py +0 -0
  24. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/errors.py +0 -0
  25. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/fieldindex.py +0 -0
  26. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/fieldtypes.py +0 -0
  27. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/router.py +0 -0
  28. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/routes.py +0 -0
  29. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/schema.py +0 -0
  30. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/skill/SKILL.md +0 -0
  31. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/skill/scripts/morphdb_schema.py +0 -0
  32. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb/util.py +0 -0
  33. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb.egg-info/dependency_links.txt +0 -0
  34. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb.egg-info/entry_points.txt +0 -0
  35. {morphdb-0.1.5 → morphdb-0.2.0}/morphdb.egg-info/top_level.txt +0 -0
  36. {morphdb-0.1.5 → morphdb-0.2.0}/setup.cfg +0 -0
  37. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_apps.py +0 -0
  38. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_cli.py +0 -0
  39. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_core.py +0 -0
  40. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_field_index.py +0 -0
  41. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_hardening.py +0 -0
  42. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_includes.py +0 -0
  43. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_mcp.py +0 -0
  44. {morphdb-0.1.5 → morphdb-0.2.0}/tests/test_relations.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphdb
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: A coding-agent-friendly, multi-tenant backend for vibe-coded websites. One process hosts many isolated apps; reshape each app's schema as fast as your agent iterates while the frontend keeps calling the same generic endpoints.
5
5
  Author: morphdb contributors
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/Savcab/morphdb
8
8
  Project-URL: Repository, https://github.com/Savcab/morphdb
9
9
  Project-URL: Issues, https://github.com/Savcab/morphdb/issues
10
- Keywords: database,ai,agent,schema,sqlite,backend,vibe-coding
10
+ Keywords: database,ai,agent,schema,sqlite,postgres,postgresql,backend,vibe-coding
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
@@ -23,6 +23,8 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Provides-Extra: postgres
27
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
26
28
  Dynamic: license-file
27
29
 
28
30
  # MorphDB
@@ -31,8 +33,9 @@ Dynamic: license-file
31
33
 
32
34
  Reshape the data model as fast as your coding agent iterates — the frontend
33
35
  keeps calling the same small set of generic, deterministic endpoints. One
34
- process hosts many isolated apps (one per site), zero dependencies, backed by
35
- SQLite.
36
+ process hosts many isolated apps (one per site). Zero dependencies on the
37
+ default SQLite engine; point it at PostgreSQL when you want a networked, managed
38
+ database — same API, same code.
36
39
 
37
40
  📖 **[Visual explainer → morphdb.pages.dev](https://morphdb.pages.dev)** — the whole idea (schema-fluid, API-stable), the agent/frontend split, relations, and how Claude plugs in over MCP, on one page.
38
41
 
@@ -53,8 +56,9 @@ morphdb dashboard # read-only web view of every app + its tables
53
56
  morphdb install-skill # install the MorphDB Claude Code skill (into ~/.claude)
54
57
  ```
55
58
 
56
- Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH` or
57
- `--db :memory:`; move the state dir with `$MORPHDB_HOME`). Server flags:
59
+ Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH`,
60
+ `--db :memory:`, or a Postgres `--db postgresql://…` URL; move the state dir with
61
+ `$MORPHDB_HOME`). Server flags:
58
62
  `--host`, `--port`, `--db`. From a source checkout with no install, the
59
63
  foreground server is `python3 -m morphdb --port 8787 --db ./app.sqlite3`.
60
64
 
@@ -67,6 +71,29 @@ to reload the new code (data in `~/.morphdb` is preserved across `0.1.x`).
67
71
  of localhost. It's a client-side setting that names a *backend*, not a database
68
72
  connection string.
69
73
 
74
+ ### Persistence: SQLite (default) or PostgreSQL
75
+
76
+ By default MorphDB is an embedded SQLite database — zero dependencies, one file.
77
+ To persist to **PostgreSQL** instead (a managed/networked database — RDS, Neon,
78
+ Supabase, or your own server), install the extra and point the server at a
79
+ connection URL:
80
+
81
+ ```bash
82
+ pip install 'morphdb[postgres]' # adds the psycopg driver
83
+
84
+ # pass a URL as --db …
85
+ morphdb start --db postgresql://user:pass@host:5432/mydb
86
+ # … or set it in the environment (handy for containers / serverless)
87
+ export MORPHDB_DATABASE_URL=postgresql://user:pass@host:5432/mydb
88
+ morphdb start
89
+ ```
90
+
91
+ Nothing else changes — the same endpoints, schema model, queries, includes, CLI,
92
+ and dashboard work identically; the engine just talks to Postgres. This makes the
93
+ MorphDB process a **stateless API tier** you can run as a container (or several,
94
+ against one Postgres) with the durable state in your managed database. The core
95
+ stays zero-dependency; `psycopg` is pulled in only for the Postgres backend.
96
+
70
97
  ## Use it
71
98
 
72
99
  With the server running (`morphdb start`):
@@ -116,7 +143,8 @@ via a pid file under the state dir.
116
143
  | `morphdb --version` | Print the version. |
117
144
 
118
145
  `start` / `run` accept `--host` (default `127.0.0.1`), `--port` (default `8787`),
119
- and `--db` (a SQLite path or `:memory:`; default `~/.morphdb/data.sqlite3`).
146
+ and `--db` (a SQLite path, `:memory:`, or a `postgresql://…` URL; default
147
+ `$MORPHDB_DATABASE_URL` or `~/.morphdb/data.sqlite3`).
120
148
  `dashboard` accepts `--port` (default `8788`), `--db`, and `--no-open`. Service
121
149
  state (pid, log, the default db) lives under `~/.morphdb` — relocate it with
122
150
  `$MORPHDB_HOME`.
@@ -169,7 +197,7 @@ exists. Meanwhile the frontend talks to generic endpoints that never change.
169
197
  DELETE /schema/{type} │ PATCH /objects/{type}/{guid}
170
198
  │ │
171
199
  └────────────── MorphDB ───────────┘
172
- (one process · many apps · SQLite)
200
+ (one process · many apps · SQLite or Postgres)
173
201
  every call: X-App-Key: <app>
174
202
  ```
175
203
 
@@ -228,7 +256,7 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
228
256
 
229
257
  ## Features
230
258
 
231
- - **Zero dependencies.** Pure Python standard library + SQLite. `python3 -m morphdb` and go.
259
+ - **Zero dependencies by default.** Pure Python standard library + embedded SQLite (`python3 -m morphdb` and go). An optional PostgreSQL backend (`pip install morphdb[postgres]`) swaps in a networked, managed database with no other changes.
232
260
  - **Generic CRUD** over arbitrary object types with typed fields.
233
261
  - **Instant schema morphing** with lazy invalidation — O(1) regardless of data size.
234
262
  - **Relations as fields** — four cardinalities, bidirectional, declared once, read/written on the object.
@@ -238,8 +266,9 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
238
266
  - **A management CLI** — `morphdb start/status/stop`, a read-only admin dashboard, and one-command skill install.
239
267
  - **A Claude Code skill** (`morphdb/skill/SKILL.md`, install with `morphdb install-skill`) with a schema CLI so the agent edits the model without hand-writing curl.
240
268
 
241
- > Scope: a localhost-scale developer tool. Not built for multi-tenant auth,
242
- > horizontal scale, or production durability guarantees.
269
+ > Scope: a small-scale developer tool. With the PostgreSQL backend it can run as
270
+ > one or more stateless instances behind a managed database, but it ships no
271
+ > multi-tenant auth or production durability guarantees.
243
272
 
244
273
  ## Data model
245
274
 
@@ -399,9 +428,12 @@ allowed, `413` body too large, `500` internal.
399
428
  filter by it, so apps can reuse type names and never see each other's data,
400
429
  and deleting an app is a single cascading delete. Type identity is the
401
430
  `(app, name)` pair, and relation targets must live in the same app.
402
- - **One connection, one lock.** All access is serialized through a single
403
- SQLite connection guarded by a reentrant lock simple and correct at
404
- localhost scale; threaded request handling stays safe.
431
+ - **Pluggable persistence.** The engine writes one SQLite-flavored dialect of
432
+ SQL behind a thin backend seam (`morphdb/backend.py`); SQLite (default) and
433
+ PostgreSQL are both first-class, selected by the `--db` target. All access is
434
+ serialized through a single connection guarded by a reentrant lock — simple and
435
+ correct at single-instance scale; threaded request handling stays safe. Run
436
+ several stateless instances against one Postgres for horizontal scale.
405
437
 
406
438
  ## Limitations
407
439
 
@@ -425,12 +457,20 @@ allowed, `413` body too large, `500` internal.
425
457
  a plain header — it isolates data between apps but is **not** authentication.
426
458
  Anyone who knows a key can use that app; the absence of a list-apps endpoint is
427
459
  light obscurity, not a security boundary.
428
- - Scope is a localhost-scale developer tool no auth, no horizontal scale.
460
+ - **Scale & auth.** With the default SQLite engine it's a localhost-scale tool;
461
+ with the PostgreSQL backend it can run as one or more stateless instances
462
+ against a managed database. Either way it ships no built-in authentication or
463
+ multi-tenant authorization.
429
464
 
430
465
  ## Development
431
466
 
432
467
  ```bash
433
- python3 -m unittest discover -s tests # full suite, zero deps
468
+ python3 -m unittest discover -s tests # full suite on SQLite, zero deps
469
+
470
+ # run the same engine suite against PostgreSQL too:
471
+ pip install 'morphdb[postgres,dev]'
472
+ MORPHDB_TEST_DATABASE_URL=postgresql://localhost/morphdb_test \
473
+ python3 -m pytest tests/ # SQLite-specific tests auto-skip
434
474
  ```
435
475
 
436
476
  ## License
@@ -4,8 +4,9 @@
4
4
 
5
5
  Reshape the data model as fast as your coding agent iterates — the frontend
6
6
  keeps calling the same small set of generic, deterministic endpoints. One
7
- process hosts many isolated apps (one per site), zero dependencies, backed by
8
- SQLite.
7
+ process hosts many isolated apps (one per site). Zero dependencies on the
8
+ default SQLite engine; point it at PostgreSQL when you want a networked, managed
9
+ database — same API, same code.
9
10
 
10
11
  📖 **[Visual explainer → morphdb.pages.dev](https://morphdb.pages.dev)** — the whole idea (schema-fluid, API-stable), the agent/frontend split, relations, and how Claude plugs in over MCP, on one page.
11
12
 
@@ -26,8 +27,9 @@ morphdb dashboard # read-only web view of every app + its tables
26
27
  morphdb install-skill # install the MorphDB Claude Code skill (into ~/.claude)
27
28
  ```
28
29
 
29
- Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH` or
30
- `--db :memory:`; move the state dir with `$MORPHDB_HOME`). Server flags:
30
+ Data lives in `~/.morphdb/data.sqlite3` (change it with `--db PATH`,
31
+ `--db :memory:`, or a Postgres `--db postgresql://…` URL; move the state dir with
32
+ `$MORPHDB_HOME`). Server flags:
31
33
  `--host`, `--port`, `--db`. From a source checkout with no install, the
32
34
  foreground server is `python3 -m morphdb --port 8787 --db ./app.sqlite3`.
33
35
 
@@ -40,6 +42,29 @@ to reload the new code (data in `~/.morphdb` is preserved across `0.1.x`).
40
42
  of localhost. It's a client-side setting that names a *backend*, not a database
41
43
  connection string.
42
44
 
45
+ ### Persistence: SQLite (default) or PostgreSQL
46
+
47
+ By default MorphDB is an embedded SQLite database — zero dependencies, one file.
48
+ To persist to **PostgreSQL** instead (a managed/networked database — RDS, Neon,
49
+ Supabase, or your own server), install the extra and point the server at a
50
+ connection URL:
51
+
52
+ ```bash
53
+ pip install 'morphdb[postgres]' # adds the psycopg driver
54
+
55
+ # pass a URL as --db …
56
+ morphdb start --db postgresql://user:pass@host:5432/mydb
57
+ # … or set it in the environment (handy for containers / serverless)
58
+ export MORPHDB_DATABASE_URL=postgresql://user:pass@host:5432/mydb
59
+ morphdb start
60
+ ```
61
+
62
+ Nothing else changes — the same endpoints, schema model, queries, includes, CLI,
63
+ and dashboard work identically; the engine just talks to Postgres. This makes the
64
+ MorphDB process a **stateless API tier** you can run as a container (or several,
65
+ against one Postgres) with the durable state in your managed database. The core
66
+ stays zero-dependency; `psycopg` is pulled in only for the Postgres backend.
67
+
43
68
  ## Use it
44
69
 
45
70
  With the server running (`morphdb start`):
@@ -89,7 +114,8 @@ via a pid file under the state dir.
89
114
  | `morphdb --version` | Print the version. |
90
115
 
91
116
  `start` / `run` accept `--host` (default `127.0.0.1`), `--port` (default `8787`),
92
- and `--db` (a SQLite path or `:memory:`; default `~/.morphdb/data.sqlite3`).
117
+ and `--db` (a SQLite path, `:memory:`, or a `postgresql://…` URL; default
118
+ `$MORPHDB_DATABASE_URL` or `~/.morphdb/data.sqlite3`).
93
119
  `dashboard` accepts `--port` (default `8788`), `--db`, and `--no-open`. Service
94
120
  state (pid, log, the default db) lives under `~/.morphdb` — relocate it with
95
121
  `$MORPHDB_HOME`.
@@ -142,7 +168,7 @@ exists. Meanwhile the frontend talks to generic endpoints that never change.
142
168
  DELETE /schema/{type} │ PATCH /objects/{type}/{guid}
143
169
  │ │
144
170
  └────────────── MorphDB ───────────┘
145
- (one process · many apps · SQLite)
171
+ (one process · many apps · SQLite or Postgres)
146
172
  every call: X-App-Key: <app>
147
173
  ```
148
174
 
@@ -201,7 +227,7 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
201
227
 
202
228
  ## Features
203
229
 
204
- - **Zero dependencies.** Pure Python standard library + SQLite. `python3 -m morphdb` and go.
230
+ - **Zero dependencies by default.** Pure Python standard library + embedded SQLite (`python3 -m morphdb` and go). An optional PostgreSQL backend (`pip install morphdb[postgres]`) swaps in a networked, managed database with no other changes.
205
231
  - **Generic CRUD** over arbitrary object types with typed fields.
206
232
  - **Instant schema morphing** with lazy invalidation — O(1) regardless of data size.
207
233
  - **Relations as fields** — four cardinalities, bidirectional, declared once, read/written on the object.
@@ -211,8 +237,9 @@ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
211
237
  - **A management CLI** — `morphdb start/status/stop`, a read-only admin dashboard, and one-command skill install.
212
238
  - **A Claude Code skill** (`morphdb/skill/SKILL.md`, install with `morphdb install-skill`) with a schema CLI so the agent edits the model without hand-writing curl.
213
239
 
214
- > Scope: a localhost-scale developer tool. Not built for multi-tenant auth,
215
- > horizontal scale, or production durability guarantees.
240
+ > Scope: a small-scale developer tool. With the PostgreSQL backend it can run as
241
+ > one or more stateless instances behind a managed database, but it ships no
242
+ > multi-tenant auth or production durability guarantees.
216
243
 
217
244
  ## Data model
218
245
 
@@ -372,9 +399,12 @@ allowed, `413` body too large, `500` internal.
372
399
  filter by it, so apps can reuse type names and never see each other's data,
373
400
  and deleting an app is a single cascading delete. Type identity is the
374
401
  `(app, name)` pair, and relation targets must live in the same app.
375
- - **One connection, one lock.** All access is serialized through a single
376
- SQLite connection guarded by a reentrant lock simple and correct at
377
- localhost scale; threaded request handling stays safe.
402
+ - **Pluggable persistence.** The engine writes one SQLite-flavored dialect of
403
+ SQL behind a thin backend seam (`morphdb/backend.py`); SQLite (default) and
404
+ PostgreSQL are both first-class, selected by the `--db` target. All access is
405
+ serialized through a single connection guarded by a reentrant lock — simple and
406
+ correct at single-instance scale; threaded request handling stays safe. Run
407
+ several stateless instances against one Postgres for horizontal scale.
378
408
 
379
409
  ## Limitations
380
410
 
@@ -398,12 +428,20 @@ allowed, `413` body too large, `500` internal.
398
428
  a plain header — it isolates data between apps but is **not** authentication.
399
429
  Anyone who knows a key can use that app; the absence of a list-apps endpoint is
400
430
  light obscurity, not a security boundary.
401
- - Scope is a localhost-scale developer tool no auth, no horizontal scale.
431
+ - **Scale & auth.** With the default SQLite engine it's a localhost-scale tool;
432
+ with the PostgreSQL backend it can run as one or more stateless instances
433
+ against a managed database. Either way it ships no built-in authentication or
434
+ multi-tenant authorization.
402
435
 
403
436
  ## Development
404
437
 
405
438
  ```bash
406
- python3 -m unittest discover -s tests # full suite, zero deps
439
+ python3 -m unittest discover -s tests # full suite on SQLite, zero deps
440
+
441
+ # run the same engine suite against PostgreSQL too:
442
+ pip install 'morphdb[postgres,dev]'
443
+ MORPHDB_TEST_DATABASE_URL=postgresql://localhost/morphdb_test \
444
+ python3 -m pytest tests/ # SQLite-specific tests auto-skip
407
445
  ```
408
446
 
409
447
  ## License
@@ -5,4 +5,4 @@ coding agent iterates, while the frontend keeps calling the same small set of
5
5
  generic, deterministic endpoints.
6
6
  """
7
7
 
8
- __version__ = "0.1.5"
8
+ __version__ = "0.2.0"
@@ -16,8 +16,10 @@ def main(argv=None):
16
16
  help="Host/interface to bind (default: 127.0.0.1).")
17
17
  parser.add_argument("--port", type=int, default=8787,
18
18
  help="Port to listen on (default: 8787).")
19
- parser.add_argument("--db", default="morphdb.sqlite3",
20
- help="SQLite file path, or ':memory:' (default: morphdb.sqlite3).")
19
+ parser.add_argument("--db", default=None,
20
+ help="SQLite file path, ':memory:', or a Postgres URL "
21
+ "(postgresql://...). Defaults to $MORPHDB_DATABASE_URL "
22
+ "or morphdb.sqlite3.")
21
23
  parser.add_argument("--version", action="version",
22
24
  version=f"morphdb {__version__}")
23
25
  args = parser.parse_args(argv)
@@ -145,7 +145,7 @@ def upsert_relation(c, app, from_type, key, raw):
145
145
  if exists:
146
146
  c.execute(
147
147
  "UPDATE association_schemas SET from_type=?, to_type=?, forward_name=?, "
148
- "inverse_name=?, cardinality=?, symmetric=?, forward_description=?, "
148
+ "inverse_name=?, cardinality=?, \"symmetric\"=?, forward_description=?, "
149
149
  "inverse_description=?, updated_at=? WHERE app=? AND name=?",
150
150
  (d["from_type"], d["to_type"], d["forward_name"], d["inverse_name"],
151
151
  d["cardinality"], int(d["symmetric"]), d["forward_description"],
@@ -154,7 +154,7 @@ def upsert_relation(c, app, from_type, key, raw):
154
154
  else:
155
155
  c.execute(
156
156
  "INSERT INTO association_schemas (app, name, from_type, to_type, "
157
- "forward_name, inverse_name, cardinality, symmetric, "
157
+ "forward_name, inverse_name, cardinality, \"symmetric\", "
158
158
  "forward_description, inverse_description, created_at, updated_at) "
159
159
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
160
160
  (app, d["name"], d["from_type"], d["to_type"], d["forward_name"],
@@ -0,0 +1,320 @@
1
+ """Storage backend abstraction — target SQLite or PostgreSQL behind one interface.
2
+
3
+ MorphDB was born talking SQLite directly. This module pulls that coupling into a
4
+ single seam so the engine can persist to either:
5
+
6
+ * **SQLite** (default, zero-dependency) — an embedded file, exactly as before.
7
+ * **PostgreSQL** (optional: ``pip install morphdb[postgres]``) — a networked,
8
+ managed database (RDS / Neon / Supabase / a plain server). The MorphDB process
9
+ becomes a stateless API tier; the durable state lives in Postgres.
10
+
11
+ The rest of the codebase keeps writing ONE dialect of SQL — SQLite-flavored:
12
+ ``?`` placeholders, ``INSERT OR IGNORE`` — and the backend translates it per
13
+ engine at execute time. That works because the data model is vanilla relational
14
+ (a JSON blob per object + a typed EAV index table + an edge table); the only real
15
+ differences are dialect surface:
16
+
17
+ placeholders ? -> %s
18
+ upsert INSERT OR IGNORE -> INSERT ... ON CONFLICT DO NOTHING
19
+ autoincrement PK INTEGER ... AUTOINCREMENT -> BIGINT GENERATED ... AS IDENTITY
20
+ case-insensitive LIKE -> ILIKE
21
+ schema version PRAGMA user_version -> a morphdb_meta table
22
+ column introspection PRAGMA table_info -> information_schema.columns
23
+ booleans (stored as 0/1 ints; a Python bool is coerced on bind)
24
+
25
+ Concurrency: as in the original design, a single connection guarded by a
26
+ reentrant lock serializes all access — simple and correct at single-instance
27
+ scale. Multiple MorphDB instances against the same Postgres each hold their own
28
+ connection and rely on Postgres (MVCC + unique constraints) for cross-process
29
+ consistency. A connection pool is a future optimization, not a correctness need.
30
+ """
31
+
32
+ import os
33
+ import re
34
+ from contextlib import contextmanager
35
+
36
+
37
+ def is_url(target):
38
+ """True if ``target`` is a Postgres connection URL (vs a SQLite path)."""
39
+ return isinstance(target, str) and (
40
+ target.startswith("postgresql://") or target.startswith("postgres://"))
41
+
42
+
43
+ def from_target(target=None):
44
+ """Build a backend from a target.
45
+
46
+ ``target`` may be a Postgres URL, a SQLite file path, ``":memory:"``, or
47
+ ``None`` — in which case ``$MORPHDB_DATABASE_URL`` is consulted, else error.
48
+ """
49
+ if target is None:
50
+ target = os.environ.get("MORPHDB_DATABASE_URL")
51
+ if not target:
52
+ raise ValueError(
53
+ "No database target given and $MORPHDB_DATABASE_URL is unset.")
54
+ if is_url(target):
55
+ return PostgresBackend(target)
56
+ return SqliteBackend(target)
57
+
58
+
59
+ def adapt_params(params):
60
+ """Normalize bind parameters across backends.
61
+
62
+ MorphDB stores booleans as 0/1 integers (there is no native boolean column),
63
+ so a Python ``bool`` must bind as an ``int`` — required for Postgres' INTEGER
64
+ columns, and a harmless no-op for SQLite (where ``bool`` already adapts to
65
+ 0/1). Everything else passes through unchanged.
66
+ """
67
+ if not params:
68
+ return params
69
+ return [int(p) if isinstance(p, bool) else p for p in params]
70
+
71
+
72
+ def _split_sql(sql):
73
+ """Split a multi-statement DDL string into individual statements.
74
+
75
+ Strips ``--`` line comments first (some carry a ``;`` mid-comment, which a
76
+ naive split would mishandle), then splits on ``;``.
77
+ """
78
+ no_comments = re.sub(r"--[^\n]*", "", sql)
79
+ return [s.strip() for s in no_comments.split(";") if s.strip()]
80
+
81
+
82
+ class _Result:
83
+ """A buffered query result.
84
+
85
+ Rows are fetched eagerly while the shared lock is held, so a bare read is
86
+ atomic on the single shared connection. Exposes only the slice of the DB-API
87
+ the engine actually uses.
88
+ """
89
+
90
+ __slots__ = ("_rows",)
91
+
92
+ def __init__(self, rows):
93
+ self._rows = rows
94
+
95
+ def fetchone(self):
96
+ return self._rows[0] if self._rows else None
97
+
98
+ def fetchall(self):
99
+ return self._rows
100
+
101
+ def __iter__(self):
102
+ return iter(self._rows)
103
+
104
+
105
+ class Connection:
106
+ """Backend-agnostic connection facade used throughout the engine.
107
+
108
+ ``execute`` / ``executemany`` accept the engine's SQLite-flavored SQL; the
109
+ backend translates it, parameters are adapted, and every call is serialized
110
+ by the shared reentrant lock so the one underlying DB-API connection is safe
111
+ to use from the server's request threads.
112
+ """
113
+
114
+ def __init__(self, backend, raw, lock):
115
+ self.backend = backend
116
+ self.raw = raw
117
+ self._lock = lock
118
+
119
+ def execute(self, sql, params=()):
120
+ sql2 = self.backend.translate(sql)
121
+ params2 = adapt_params(params)
122
+ with self._lock:
123
+ cur = self.raw.cursor()
124
+ try:
125
+ cur.execute(sql2, params2)
126
+ rows = cur.fetchall() if cur.description is not None else []
127
+ finally:
128
+ cur.close()
129
+ return _Result(rows)
130
+
131
+ def executemany(self, sql, seq):
132
+ seq2 = [adapt_params(p) for p in seq]
133
+ if not seq2:
134
+ return
135
+ sql2 = self.backend.translate(sql)
136
+ with self._lock:
137
+ cur = self.raw.cursor()
138
+ try:
139
+ cur.executemany(sql2, seq2)
140
+ finally:
141
+ cur.close()
142
+
143
+ def commit(self):
144
+ self.raw.commit()
145
+
146
+ def rollback(self):
147
+ self.raw.rollback()
148
+
149
+ def close(self):
150
+ try:
151
+ self.raw.close()
152
+ except Exception:
153
+ pass
154
+
155
+
156
+ # --- SQLite -------------------------------------------------------------------
157
+
158
+
159
+ class SqliteBackend:
160
+ """The default, zero-dependency backend: an embedded SQLite file."""
161
+
162
+ name = "sqlite"
163
+
164
+ def __init__(self, path):
165
+ self.path = path
166
+
167
+ def describe(self):
168
+ return self.path
169
+
170
+ def connect(self):
171
+ import sqlite3
172
+ conn = sqlite3.connect(self.path, check_same_thread=False)
173
+ conn.row_factory = sqlite3.Row
174
+ conn.execute("PRAGMA journal_mode=WAL;")
175
+ conn.execute("PRAGMA synchronous=NORMAL;")
176
+ conn.execute("PRAGMA busy_timeout=5000;")
177
+ conn.execute("PRAGMA foreign_keys=ON;") # enforce the app cascade + FKs
178
+ return conn
179
+
180
+ def translate(self, sql):
181
+ return sql # the engine speaks SQLite already
182
+
183
+ def like_ci(self):
184
+ return "LIKE" # SQLite LIKE is ASCII case-insensitive
185
+
186
+ def create_schema(self, raw, schema_sql):
187
+ raw.executescript(schema_sql)
188
+ raw.commit()
189
+
190
+ def reset(self, raw):
191
+ """Drop every MorphDB table (test isolation). Rarely used: ``:memory:``
192
+ is already fresh, so this only matters for a reused file."""
193
+ for t in ("field_index", "associations", "association_schemas",
194
+ "objects", "object_schemas", "apps", "morphdb_meta"):
195
+ raw.execute(f"DROP TABLE IF EXISTS {t}")
196
+ raw.commit()
197
+
198
+ @contextmanager
199
+ def transaction(self, raw):
200
+ try:
201
+ yield
202
+ raw.commit()
203
+ except Exception:
204
+ raw.rollback()
205
+ raise
206
+
207
+ def get_user_version(self, raw):
208
+ return raw.execute("PRAGMA user_version").fetchone()[0]
209
+
210
+ def set_user_version(self, raw, version):
211
+ raw.execute(f"PRAGMA user_version = {int(version)}")
212
+
213
+ def table_columns(self, raw, table):
214
+ # Ordered by definition (cid) so callers can render columns in order.
215
+ rows = raw.execute(f"PRAGMA table_info({table})").fetchall()
216
+ return [r["name"] for r in rows]
217
+
218
+ def list_tables(self, raw):
219
+ rows = raw.execute(
220
+ "SELECT name FROM sqlite_master WHERE type='table' "
221
+ "AND name NOT LIKE 'sqlite_%'").fetchall()
222
+ return [r["name"] for r in rows]
223
+
224
+
225
+ # --- PostgreSQL ---------------------------------------------------------------
226
+
227
+
228
+ class PostgresBackend:
229
+ """Optional backend targeting PostgreSQL via psycopg (v3).
230
+
231
+ Requires ``pip install morphdb[postgres]``. The same engine SQL is translated
232
+ to the Postgres dialect; identity columns, upserts, versioning, and column
233
+ introspection use their Postgres equivalents.
234
+ """
235
+
236
+ name = "postgres"
237
+
238
+ def __init__(self, url):
239
+ self.url = url
240
+
241
+ def describe(self):
242
+ # Never echo credentials embedded in the URL.
243
+ return re.sub(r"//[^@/]+@", "//***@", self.url)
244
+
245
+ def connect(self):
246
+ try:
247
+ import psycopg
248
+ from psycopg.rows import dict_row
249
+ except ImportError as e: # pragma: no cover - environment-dependent
250
+ raise RuntimeError(
251
+ "PostgreSQL support needs the psycopg driver. Install it with:\n"
252
+ " pip install 'morphdb[postgres]'\n"
253
+ f"(import error: {e})")
254
+ return psycopg.connect(self.url, autocommit=True, row_factory=dict_row)
255
+
256
+ def translate(self, sql):
257
+ s = sql
258
+ if "INSERT OR IGNORE" in s:
259
+ s = s.replace("INSERT OR IGNORE", "INSERT", 1) + " ON CONFLICT DO NOTHING"
260
+ # Escape any literal % (psycopg treats the query as a format string when
261
+ # params are passed), then convert ? placeholders to %s.
262
+ s = s.replace("%", "%%").replace("?", "%s")
263
+ return s
264
+
265
+ def like_ci(self):
266
+ return "ILIKE" # Postgres LIKE is case-sensitive
267
+
268
+ def _ddl(self, schema_sql):
269
+ return schema_sql.replace(
270
+ "INTEGER PRIMARY KEY AUTOINCREMENT",
271
+ "BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY")
272
+
273
+ def create_schema(self, raw, schema_sql):
274
+ with raw.cursor() as cur:
275
+ for stmt in _split_sql(self._ddl(schema_sql)):
276
+ cur.execute(stmt)
277
+ cur.execute(
278
+ "CREATE TABLE IF NOT EXISTS morphdb_meta (version INTEGER NOT NULL)")
279
+
280
+ def reset(self, raw):
281
+ with raw.cursor() as cur:
282
+ cur.execute("DROP SCHEMA IF EXISTS public CASCADE")
283
+ cur.execute("CREATE SCHEMA public")
284
+
285
+ @contextmanager
286
+ def transaction(self, raw):
287
+ # psycopg manages BEGIN/COMMIT/ROLLBACK; works even in autocommit mode.
288
+ with raw.transaction():
289
+ yield
290
+
291
+ def get_user_version(self, raw):
292
+ with raw.cursor() as cur:
293
+ cur.execute(
294
+ "CREATE TABLE IF NOT EXISTS morphdb_meta (version INTEGER NOT NULL)")
295
+ cur.execute("SELECT version FROM morphdb_meta LIMIT 1")
296
+ row = cur.fetchone()
297
+ if row is None:
298
+ cur.execute("INSERT INTO morphdb_meta (version) VALUES (0)")
299
+ return 0
300
+ return row["version"]
301
+
302
+ def set_user_version(self, raw, version):
303
+ with raw.cursor() as cur:
304
+ cur.execute("UPDATE morphdb_meta SET version = %s", (int(version),))
305
+
306
+ def table_columns(self, raw, table):
307
+ with raw.cursor() as cur:
308
+ cur.execute(
309
+ "SELECT column_name FROM information_schema.columns "
310
+ "WHERE table_schema = current_schema() AND table_name = %s "
311
+ "ORDER BY ordinal_position",
312
+ (table,))
313
+ return [r["column_name"] for r in cur.fetchall()]
314
+
315
+ def list_tables(self, raw):
316
+ with raw.cursor() as cur:
317
+ cur.execute(
318
+ "SELECT table_name FROM information_schema.tables "
319
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'")
320
+ return [r["table_name"] for r in cur.fetchall()]