morphdb 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.
morphdb-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MorphDB contributors
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.
morphdb-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.4
2
+ Name: morphdb
3
+ Version: 0.1.0
4
+ Summary: A schema-fluid, API-stable database for AI-generated apps. Reshape the schema as fast as your coding agent iterates; the frontend keeps calling the same generic endpoints.
5
+ Author: morphdb contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Savcab/morphdb
8
+ Project-URL: Repository, https://github.com/Savcab/morphdb
9
+ Project-URL: Issues, https://github.com/Savcab/morphdb/issues
10
+ Keywords: database,ai,agent,schema,sqlite,backend,vibe-coding
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # MorphDB
29
+
30
+ **A schema-fluid, API-stable database for AI-generated apps.**
31
+
32
+ Reshape the data model as fast as your coding agent iterates — the frontend
33
+ keeps calling the same small set of generic, deterministic endpoints.
34
+
35
+ ```
36
+ you (the coding agent) the frontend you build
37
+ ────────────────────── ──────────────────────
38
+ reshape the schema freely │ calls fixed generic endpoints
39
+ PUT /schema/{type} │ POST /objects/{type}
40
+ GET /schema │ GET /objects/{type}?field=…
41
+ DELETE /schema/{type} │ PATCH /objects/{type}/{guid}
42
+ │ │
43
+ └────────────── MorphDB ───────────┘
44
+ (one process · many apps · SQLite)
45
+ every call: X-App-Key: <app>
46
+ ```
47
+
48
+ ## Why
49
+
50
+ AI coding agents are great at building HTML/CSS/JS frontends but thrash hard on
51
+ backends: every UI iteration wants a slightly different data shape, and most
52
+ databases make schema change painful (migrations, downtime, rewriting rows). So
53
+ vibe-coded apps stay frontend-only and lose their data on refresh.
54
+
55
+ MorphDB removes the friction. The schema is just metadata; objects are JSON
56
+ blobs reinterpreted through the **current** schema on every read (lazy
57
+ invalidation). Adding, removing, or retyping a field is an O(1) metadata edit —
58
+ **no migration, no row rewrite, no downtime** — regardless of how much data
59
+ exists. Meanwhile the frontend talks to generic endpoints that never change.
60
+
61
+ ## The shape of it
62
+
63
+ One MorphDB process hosts **many apps** (one per website), fully isolated from
64
+ each other. Every schema and object request carries its app in the `X-App-Key`
65
+ header. There are three sets of endpoints:
66
+
67
+ - **App endpoints** — the tenant: `POST /app` to register a key you choose,
68
+ `DELETE /app/{key}` to delete it and cascade away everything under it. There
69
+ is no "list apps" — you only address an app whose key you already hold.
70
+ - **Schema endpoints** — the type model: `GET/PUT/DELETE /schema[/{type}]`.
71
+ You, the agent, reshape these constantly (drive them with the schema CLI).
72
+ - **Object endpoints** — the data: `/objects/{type}` and `/object/{guid}`.
73
+ Your frontend reads and writes here, and they never change as you morph the
74
+ schema.
75
+
76
+ Within an app, type names are unique; the same name may be reused in another app.
77
+
78
+ A **type** is one document with `fields` (raw values) and `relations` (links to
79
+ other types). Relations are declared once but read and written **like ordinary
80
+ fields** on the object body — so the frontend never learns a separate
81
+ "associations" API.
82
+
83
+ ```jsonc
84
+ // PUT /schema/task
85
+ {
86
+ "fields": {
87
+ "title": "string",
88
+ "done": { "type": "boolean", "default": false }
89
+ },
90
+ "relations": {
91
+ // declared once on `task`; `user.tasks` appears automatically
92
+ "assignee": { "to": "user", "cardinality": "many_to_one", "inverse": "tasks" }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ```jsonc
98
+ // GET /objects/task/<guid> → relations are right there, as guids
99
+ { "_guid": "task_…", "_type": "task", "title": "ship", "done": false,
100
+ "assignee": "user_…" }
101
+
102
+ // GET /objects/user/<guid> → the inverse side, automatically
103
+ { "_guid": "user_…", "_type": "user", "name": "Ann",
104
+ "tasks": ["task_…", "task_…"] }
105
+ ```
106
+
107
+ ```bash
108
+ # link them by writing the relation like a field
109
+ curl -X PATCH $BASE/objects/task/<t> -d '{"assignee":"<u>"}'
110
+ # to-many is a list; null or [] clears
111
+ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
112
+ ```
113
+
114
+ ## Features
115
+
116
+ - **Zero dependencies.** Pure Python standard library + SQLite. `python3 -m morphdb` and go.
117
+ - **Generic CRUD** over arbitrary object types with typed fields.
118
+ - **Instant schema morphing** with lazy invalidation — O(1) regardless of data size.
119
+ - **Relations as fields** — four cardinalities, bidirectional, declared once, read/written on the object.
120
+ - **Query layer**: filter operators, sorting, pagination — all generic.
121
+ - **Multi-tenant by app** — one process backs many isolated sites; every call is scoped by an `X-App-Key`, and deleting an app cascades away all its data.
122
+ - **Wide-open CORS** so any frontend origin can call it in dev.
123
+ - **A Claude Code skill** (`skill/SKILL.md`) with a schema CLI so the agent edits the model without hand-writing curl.
124
+
125
+ > Scope: a localhost-scale developer tool. Not built for multi-tenant auth,
126
+ > horizontal scale, or production durability guarantees.
127
+
128
+ ## Install / run
129
+
130
+ No install required:
131
+
132
+ ```bash
133
+ python3 -m morphdb --port 8787 --db ./app.sqlite3
134
+ ```
135
+
136
+ Or install the console script:
137
+
138
+ ```bash
139
+ pip install -e .
140
+ morphdb --port 8787 --db ./app.sqlite3
141
+ ```
142
+
143
+ Flags: `--host` (default `127.0.0.1`), `--port` (default `8787`),
144
+ `--db` (default `morphdb.sqlite3`; use `:memory:` for ephemeral).
145
+
146
+ Then: `curl http://127.0.0.1:8787/help` for a live reference.
147
+
148
+ ## Quickstart
149
+
150
+ ```bash
151
+ BASE=http://127.0.0.1:8787
152
+
153
+ # 0. register an app; send its key as X-App-Key on every schema/object call
154
+ curl -X POST $BASE/app -d '{"key":"my-site"}'
155
+ H="X-App-Key: my-site"
156
+
157
+ # 1. define types + a relation
158
+ curl -X PUT $BASE/schema/user -H "$H" -d '{"fields":{"name":"string"}}'
159
+ curl -X PUT $BASE/schema/task -H "$H" -d '{
160
+ "fields": {"title":"string","done":"boolean","priority":"number"},
161
+ "relations": {"assignee":{"to":"user","cardinality":"many_to_one","inverse":"tasks"}}}'
162
+
163
+ # 2. create + read + query
164
+ U=$(curl -s -X POST $BASE/objects/user -H "$H" -d '{"name":"Ann"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["_guid"])')
165
+ curl -X POST $BASE/objects/task -H "$H" -d "{\"title\":\"buy milk\",\"priority\":2,\"assignee\":\"$U\"}"
166
+ curl -H "$H" "$BASE/objects/task?done=false&sort=priority&order=desc"
167
+ curl -H "$H" "$BASE/objects/user/$U" # → includes "tasks":[…]
168
+
169
+ # 3. morph the schema later — existing rows just gain the new field as null
170
+ curl -X PUT $BASE/schema/task -H "$H" -d '{"merge":true,"fields":{"due":"datetime"}}'
171
+ ```
172
+
173
+ See `examples/todo/index.html` for a complete single-file frontend backed by MorphDB.
174
+
175
+ ## Data model
176
+
177
+ | Concept | What it is |
178
+ | --- | --- |
179
+ | **App** | A tenant: one website's isolated schema + data, addressed by a key sent in the `X-App-Key` header. |
180
+ | **Type** | A named schema: `fields` (raw values) + `relations` (links). The thing you morph. |
181
+ | **Object** | An instance: a `_guid`, a type, field values (JSON blob) + relation guids (edges). |
182
+ | **Relation** | A typed link with a cardinality, declared on one type, visible (as the inverse) on both. |
183
+ | **Edge** | One link between two object guids. Stored once; traversable from both ends. |
184
+
185
+ **Field types:** `string`, `number`, `boolean`, `json`, `datetime`.
186
+ Values are coerced to the declared type on write; unknown fields/relations are
187
+ rejected. `number` rejects NaN/Infinity; `datetime` is validated as ISO-8601
188
+ (or epoch seconds) and normalized. Field defaults are materialized into storage
189
+ on write, so a defaulted value is queryable like any other.
190
+
191
+ **System fields** on every object: `_guid`, `_type`, `_created_at`,
192
+ `_updated_at`. Field and relation names may not begin with `_`, and a relation
193
+ may not share a name with a field on the same type.
194
+
195
+ **Relations.** Declared inside a type under `relations`:
196
+
197
+ ```jsonc
198
+ "assignee": {
199
+ "to": "user", // neighbor type
200
+ "cardinality": "many_to_one", // many tasks → one user
201
+ "inverse": "tasks", // the name the user side sees
202
+ "description": "…", // optional
203
+ "inverse_description": "…" // optional
204
+ }
205
+ ```
206
+
207
+ Cardinality `X_to_Y` means the **from** side sees `Y` neighbors and the **to**
208
+ side sees `X`. So `many_to_one` gives `task.assignee` a single guid and
209
+ `user.tasks` a list. Reading an object includes all its relations (both
210
+ directions); writing a relation key sets that relation's full set
211
+ (set-as-field), with **last-write-wins** if a single-valued slot is already
212
+ taken. `null`/`[]` clears.
213
+
214
+ **Symmetric relations.** For a mutual relationship within one type (friends,
215
+ peers), set `symmetric: true` (requires `to` == the declaring type and a
216
+ cardinality of `one_to_one` or `many_to_many`). The edge A–B and B–A are then
217
+ the same edge — created idempotently in either order, counted once, traversed
218
+ from both ends under one shared label.
219
+
220
+ **List responses** are shaped `{"objects": [...], "total": <full filtered
221
+ count>, "limit": <int>, "offset": <int>}` — `total` is the count across the
222
+ whole filter, not just the returned page. Default `limit` is 100 (max 1000).
223
+
224
+ ## API reference
225
+
226
+ Every schema and object request must send the app key as the `X-App-Key` header
227
+ (missing → `400`, unknown → `404`); the app endpoints below are the exception.
228
+
229
+ ### App endpoints (one instance, many sites)
230
+
231
+ | Method & path | Body | Description |
232
+ | --- | --- | --- |
233
+ | `POST /app` | `{key}` | Register an app under a key you choose. `409` if taken. No list endpoint — remember the key. |
234
+ | `DELETE /app/{key}` | — | Delete an app and cascade-delete all its schemas, objects, relations, and edges. |
235
+
236
+ ### Schema endpoints (you, the agent)
237
+
238
+ | Method & path | Body | Description |
239
+ | --- | --- | --- |
240
+ | `GET /schema` | — | All type schemas (fields + relations + inverse relations) for the app. |
241
+ | `GET /schema/{type}` | — | One type's schema. |
242
+ | `PUT /schema/{type}` | `{fields?, relations?, merge?}` or a bare field map | Create/replace a type. `merge:true` adds without dropping. Absent `fields`/`relations` are left untouched. |
243
+ | `DELETE /schema/{type}` | — | Delete a type, its objects, and edges touching them. Neighbor objects survive. |
244
+
245
+ ### Object endpoints (your frontend)
246
+
247
+ | Method & path | Body / query | Description |
248
+ | --- | --- | --- |
249
+ | `POST /objects/{type}` | field + relation values | Create an object → returns it with `_guid`. |
250
+ | `GET /objects/{type}` | filters, `limit`, `offset`, `sort`, `order` | List / query. |
251
+ | `GET /objects/{type}/{guid}` | — | Read one (type-checked). |
252
+ | `GET /object/{guid}` | — | Read one by guid alone. |
253
+ | `PUT /objects/{type}/{guid}` | field + relation values | Replace fields (create if absent); set any relations present. |
254
+ | `PATCH /objects/{type}/{guid}` | partial fields + relations | Merge fields (create if absent); set any relations present. |
255
+ | `DELETE /objects/{type}/{guid}` | — | Delete object + its edges. |
256
+
257
+ ### Query operators
258
+
259
+ Append `__op` to a field name: `eq` (default), `ne`, `gt`, `gte`, `lt`, `lte`,
260
+ `contains` (substring), `in` (comma-separated), `exists` (`true`/`false`).
261
+ Filtering is on **fields**, not relations.
262
+
263
+ ```
264
+ GET /objects/task?priority__gte=3&title__contains=buy&done=false
265
+ GET /objects/task?status__in=open,blocked&sort=_created_at&order=desc&limit=50
266
+ ```
267
+
268
+ ## Errors
269
+
270
+ JSON shape: `{"error": {"code": "...", "message": "...", ...extra}}`.
271
+ Status codes: `400` bad request/validation, `404` not found, `405` method not
272
+ allowed, `413` body too large, `500` internal.
273
+
274
+ ## Design notes
275
+
276
+ - **Lazy invalidation.** Objects are stored as JSON blobs and projected through
277
+ the live schema on every read. Schema edits never touch stored rows, so they
278
+ are constant-time. A dropped field's data lingers in the blob (hidden) and
279
+ reappears if the field is re-added at the same type.
280
+ - **Relations are fields, edges are rows.** A relation is exposed as a field on
281
+ the object body but stored as a single canonical row per edge. Bidirectional
282
+ traversal queries both endpoint columns (both indexed). This avoids the
283
+ dual-write hazard of mirrored rows while letting an object surface all of its
284
+ links in one read.
285
+ - **Apps are the tenant boundary.** Every row carries an `app` foreign key
286
+ (`ON DELETE CASCADE`, with `PRAGMA foreign_keys=ON`); all reads and writes
287
+ filter by it, so apps can reuse type names and never see each other's data,
288
+ and deleting an app is a single cascading delete. Type identity is the
289
+ `(app, name)` pair, and relation targets must live in the same app.
290
+ - **One connection, one lock.** All access is serialized through a single
291
+ SQLite connection guarded by a reentrant lock — simple and correct at
292
+ localhost scale; threaded request handling stays safe.
293
+
294
+ ## Limitations
295
+
296
+ - **Schema morphing is purely lazy.** Every schema edit — add, drop, or retype
297
+ a field — rewrites only the one metadata row, never the stored objects (O(1)
298
+ regardless of data size). After a **type change**, a value still stored at the
299
+ old type simply reads as unset (the field's default, or null) until it's
300
+ written again; reads and queries apply this rule identically, so they always
301
+ agree. Re-adding a dropped field at the same type recovers its values.
302
+ - **Filtering is field-only.** Query operators apply to raw fields; relations
303
+ are read/written on the object body but not filtered server-side (yet).
304
+ - **Integer magnitude.** Numbers are stored and read back exactly at any size.
305
+ Filtering/sorting on integers beyond ±2⁶³ uses floating-point comparison (a
306
+ SQLite limitation), so equality/range queries on such huge integers may be
307
+ imprecise even though reads are exact.
308
+ - **HTTP verbs.** Only `GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD` are part of the
309
+ API; other verbs (e.g. `TRACE`) get the stdlib's plain `501`.
310
+ - **App keys are namespaces, not secrets.** The `X-App-Key` is an identifier in
311
+ a plain header — it isolates data between apps but is **not** authentication.
312
+ Anyone who knows a key can use that app; the absence of a list-apps endpoint is
313
+ light obscurity, not a security boundary.
314
+ - Scope is a localhost-scale developer tool — no auth, no horizontal scale.
315
+
316
+ ## Development
317
+
318
+ ```bash
319
+ python3 -m unittest discover -s tests # full suite, zero deps
320
+ ```
321
+
322
+ ## License
323
+
324
+ MIT — see `LICENSE`.
@@ -0,0 +1,297 @@
1
+ # MorphDB
2
+
3
+ **A schema-fluid, API-stable database for AI-generated apps.**
4
+
5
+ Reshape the data model as fast as your coding agent iterates — the frontend
6
+ keeps calling the same small set of generic, deterministic endpoints.
7
+
8
+ ```
9
+ you (the coding agent) the frontend you build
10
+ ────────────────────── ──────────────────────
11
+ reshape the schema freely │ calls fixed generic endpoints
12
+ PUT /schema/{type} │ POST /objects/{type}
13
+ GET /schema │ GET /objects/{type}?field=…
14
+ DELETE /schema/{type} │ PATCH /objects/{type}/{guid}
15
+ │ │
16
+ └────────────── MorphDB ───────────┘
17
+ (one process · many apps · SQLite)
18
+ every call: X-App-Key: <app>
19
+ ```
20
+
21
+ ## Why
22
+
23
+ AI coding agents are great at building HTML/CSS/JS frontends but thrash hard on
24
+ backends: every UI iteration wants a slightly different data shape, and most
25
+ databases make schema change painful (migrations, downtime, rewriting rows). So
26
+ vibe-coded apps stay frontend-only and lose their data on refresh.
27
+
28
+ MorphDB removes the friction. The schema is just metadata; objects are JSON
29
+ blobs reinterpreted through the **current** schema on every read (lazy
30
+ invalidation). Adding, removing, or retyping a field is an O(1) metadata edit —
31
+ **no migration, no row rewrite, no downtime** — regardless of how much data
32
+ exists. Meanwhile the frontend talks to generic endpoints that never change.
33
+
34
+ ## The shape of it
35
+
36
+ One MorphDB process hosts **many apps** (one per website), fully isolated from
37
+ each other. Every schema and object request carries its app in the `X-App-Key`
38
+ header. There are three sets of endpoints:
39
+
40
+ - **App endpoints** — the tenant: `POST /app` to register a key you choose,
41
+ `DELETE /app/{key}` to delete it and cascade away everything under it. There
42
+ is no "list apps" — you only address an app whose key you already hold.
43
+ - **Schema endpoints** — the type model: `GET/PUT/DELETE /schema[/{type}]`.
44
+ You, the agent, reshape these constantly (drive them with the schema CLI).
45
+ - **Object endpoints** — the data: `/objects/{type}` and `/object/{guid}`.
46
+ Your frontend reads and writes here, and they never change as you morph the
47
+ schema.
48
+
49
+ Within an app, type names are unique; the same name may be reused in another app.
50
+
51
+ A **type** is one document with `fields` (raw values) and `relations` (links to
52
+ other types). Relations are declared once but read and written **like ordinary
53
+ fields** on the object body — so the frontend never learns a separate
54
+ "associations" API.
55
+
56
+ ```jsonc
57
+ // PUT /schema/task
58
+ {
59
+ "fields": {
60
+ "title": "string",
61
+ "done": { "type": "boolean", "default": false }
62
+ },
63
+ "relations": {
64
+ // declared once on `task`; `user.tasks` appears automatically
65
+ "assignee": { "to": "user", "cardinality": "many_to_one", "inverse": "tasks" }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ```jsonc
71
+ // GET /objects/task/<guid> → relations are right there, as guids
72
+ { "_guid": "task_…", "_type": "task", "title": "ship", "done": false,
73
+ "assignee": "user_…" }
74
+
75
+ // GET /objects/user/<guid> → the inverse side, automatically
76
+ { "_guid": "user_…", "_type": "user", "name": "Ann",
77
+ "tasks": ["task_…", "task_…"] }
78
+ ```
79
+
80
+ ```bash
81
+ # link them by writing the relation like a field
82
+ curl -X PATCH $BASE/objects/task/<t> -d '{"assignee":"<u>"}'
83
+ # to-many is a list; null or [] clears
84
+ curl -X PATCH $BASE/objects/user/<u> -d '{"tasks":["<t1>","<t2>"]}'
85
+ ```
86
+
87
+ ## Features
88
+
89
+ - **Zero dependencies.** Pure Python standard library + SQLite. `python3 -m morphdb` and go.
90
+ - **Generic CRUD** over arbitrary object types with typed fields.
91
+ - **Instant schema morphing** with lazy invalidation — O(1) regardless of data size.
92
+ - **Relations as fields** — four cardinalities, bidirectional, declared once, read/written on the object.
93
+ - **Query layer**: filter operators, sorting, pagination — all generic.
94
+ - **Multi-tenant by app** — one process backs many isolated sites; every call is scoped by an `X-App-Key`, and deleting an app cascades away all its data.
95
+ - **Wide-open CORS** so any frontend origin can call it in dev.
96
+ - **A Claude Code skill** (`skill/SKILL.md`) with a schema CLI so the agent edits the model without hand-writing curl.
97
+
98
+ > Scope: a localhost-scale developer tool. Not built for multi-tenant auth,
99
+ > horizontal scale, or production durability guarantees.
100
+
101
+ ## Install / run
102
+
103
+ No install required:
104
+
105
+ ```bash
106
+ python3 -m morphdb --port 8787 --db ./app.sqlite3
107
+ ```
108
+
109
+ Or install the console script:
110
+
111
+ ```bash
112
+ pip install -e .
113
+ morphdb --port 8787 --db ./app.sqlite3
114
+ ```
115
+
116
+ Flags: `--host` (default `127.0.0.1`), `--port` (default `8787`),
117
+ `--db` (default `morphdb.sqlite3`; use `:memory:` for ephemeral).
118
+
119
+ Then: `curl http://127.0.0.1:8787/help` for a live reference.
120
+
121
+ ## Quickstart
122
+
123
+ ```bash
124
+ BASE=http://127.0.0.1:8787
125
+
126
+ # 0. register an app; send its key as X-App-Key on every schema/object call
127
+ curl -X POST $BASE/app -d '{"key":"my-site"}'
128
+ H="X-App-Key: my-site"
129
+
130
+ # 1. define types + a relation
131
+ curl -X PUT $BASE/schema/user -H "$H" -d '{"fields":{"name":"string"}}'
132
+ curl -X PUT $BASE/schema/task -H "$H" -d '{
133
+ "fields": {"title":"string","done":"boolean","priority":"number"},
134
+ "relations": {"assignee":{"to":"user","cardinality":"many_to_one","inverse":"tasks"}}}'
135
+
136
+ # 2. create + read + query
137
+ U=$(curl -s -X POST $BASE/objects/user -H "$H" -d '{"name":"Ann"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["_guid"])')
138
+ curl -X POST $BASE/objects/task -H "$H" -d "{\"title\":\"buy milk\",\"priority\":2,\"assignee\":\"$U\"}"
139
+ curl -H "$H" "$BASE/objects/task?done=false&sort=priority&order=desc"
140
+ curl -H "$H" "$BASE/objects/user/$U" # → includes "tasks":[…]
141
+
142
+ # 3. morph the schema later — existing rows just gain the new field as null
143
+ curl -X PUT $BASE/schema/task -H "$H" -d '{"merge":true,"fields":{"due":"datetime"}}'
144
+ ```
145
+
146
+ See `examples/todo/index.html` for a complete single-file frontend backed by MorphDB.
147
+
148
+ ## Data model
149
+
150
+ | Concept | What it is |
151
+ | --- | --- |
152
+ | **App** | A tenant: one website's isolated schema + data, addressed by a key sent in the `X-App-Key` header. |
153
+ | **Type** | A named schema: `fields` (raw values) + `relations` (links). The thing you morph. |
154
+ | **Object** | An instance: a `_guid`, a type, field values (JSON blob) + relation guids (edges). |
155
+ | **Relation** | A typed link with a cardinality, declared on one type, visible (as the inverse) on both. |
156
+ | **Edge** | One link between two object guids. Stored once; traversable from both ends. |
157
+
158
+ **Field types:** `string`, `number`, `boolean`, `json`, `datetime`.
159
+ Values are coerced to the declared type on write; unknown fields/relations are
160
+ rejected. `number` rejects NaN/Infinity; `datetime` is validated as ISO-8601
161
+ (or epoch seconds) and normalized. Field defaults are materialized into storage
162
+ on write, so a defaulted value is queryable like any other.
163
+
164
+ **System fields** on every object: `_guid`, `_type`, `_created_at`,
165
+ `_updated_at`. Field and relation names may not begin with `_`, and a relation
166
+ may not share a name with a field on the same type.
167
+
168
+ **Relations.** Declared inside a type under `relations`:
169
+
170
+ ```jsonc
171
+ "assignee": {
172
+ "to": "user", // neighbor type
173
+ "cardinality": "many_to_one", // many tasks → one user
174
+ "inverse": "tasks", // the name the user side sees
175
+ "description": "…", // optional
176
+ "inverse_description": "…" // optional
177
+ }
178
+ ```
179
+
180
+ Cardinality `X_to_Y` means the **from** side sees `Y` neighbors and the **to**
181
+ side sees `X`. So `many_to_one` gives `task.assignee` a single guid and
182
+ `user.tasks` a list. Reading an object includes all its relations (both
183
+ directions); writing a relation key sets that relation's full set
184
+ (set-as-field), with **last-write-wins** if a single-valued slot is already
185
+ taken. `null`/`[]` clears.
186
+
187
+ **Symmetric relations.** For a mutual relationship within one type (friends,
188
+ peers), set `symmetric: true` (requires `to` == the declaring type and a
189
+ cardinality of `one_to_one` or `many_to_many`). The edge A–B and B–A are then
190
+ the same edge — created idempotently in either order, counted once, traversed
191
+ from both ends under one shared label.
192
+
193
+ **List responses** are shaped `{"objects": [...], "total": <full filtered
194
+ count>, "limit": <int>, "offset": <int>}` — `total` is the count across the
195
+ whole filter, not just the returned page. Default `limit` is 100 (max 1000).
196
+
197
+ ## API reference
198
+
199
+ Every schema and object request must send the app key as the `X-App-Key` header
200
+ (missing → `400`, unknown → `404`); the app endpoints below are the exception.
201
+
202
+ ### App endpoints (one instance, many sites)
203
+
204
+ | Method & path | Body | Description |
205
+ | --- | --- | --- |
206
+ | `POST /app` | `{key}` | Register an app under a key you choose. `409` if taken. No list endpoint — remember the key. |
207
+ | `DELETE /app/{key}` | — | Delete an app and cascade-delete all its schemas, objects, relations, and edges. |
208
+
209
+ ### Schema endpoints (you, the agent)
210
+
211
+ | Method & path | Body | Description |
212
+ | --- | --- | --- |
213
+ | `GET /schema` | — | All type schemas (fields + relations + inverse relations) for the app. |
214
+ | `GET /schema/{type}` | — | One type's schema. |
215
+ | `PUT /schema/{type}` | `{fields?, relations?, merge?}` or a bare field map | Create/replace a type. `merge:true` adds without dropping. Absent `fields`/`relations` are left untouched. |
216
+ | `DELETE /schema/{type}` | — | Delete a type, its objects, and edges touching them. Neighbor objects survive. |
217
+
218
+ ### Object endpoints (your frontend)
219
+
220
+ | Method & path | Body / query | Description |
221
+ | --- | --- | --- |
222
+ | `POST /objects/{type}` | field + relation values | Create an object → returns it with `_guid`. |
223
+ | `GET /objects/{type}` | filters, `limit`, `offset`, `sort`, `order` | List / query. |
224
+ | `GET /objects/{type}/{guid}` | — | Read one (type-checked). |
225
+ | `GET /object/{guid}` | — | Read one by guid alone. |
226
+ | `PUT /objects/{type}/{guid}` | field + relation values | Replace fields (create if absent); set any relations present. |
227
+ | `PATCH /objects/{type}/{guid}` | partial fields + relations | Merge fields (create if absent); set any relations present. |
228
+ | `DELETE /objects/{type}/{guid}` | — | Delete object + its edges. |
229
+
230
+ ### Query operators
231
+
232
+ Append `__op` to a field name: `eq` (default), `ne`, `gt`, `gte`, `lt`, `lte`,
233
+ `contains` (substring), `in` (comma-separated), `exists` (`true`/`false`).
234
+ Filtering is on **fields**, not relations.
235
+
236
+ ```
237
+ GET /objects/task?priority__gte=3&title__contains=buy&done=false
238
+ GET /objects/task?status__in=open,blocked&sort=_created_at&order=desc&limit=50
239
+ ```
240
+
241
+ ## Errors
242
+
243
+ JSON shape: `{"error": {"code": "...", "message": "...", ...extra}}`.
244
+ Status codes: `400` bad request/validation, `404` not found, `405` method not
245
+ allowed, `413` body too large, `500` internal.
246
+
247
+ ## Design notes
248
+
249
+ - **Lazy invalidation.** Objects are stored as JSON blobs and projected through
250
+ the live schema on every read. Schema edits never touch stored rows, so they
251
+ are constant-time. A dropped field's data lingers in the blob (hidden) and
252
+ reappears if the field is re-added at the same type.
253
+ - **Relations are fields, edges are rows.** A relation is exposed as a field on
254
+ the object body but stored as a single canonical row per edge. Bidirectional
255
+ traversal queries both endpoint columns (both indexed). This avoids the
256
+ dual-write hazard of mirrored rows while letting an object surface all of its
257
+ links in one read.
258
+ - **Apps are the tenant boundary.** Every row carries an `app` foreign key
259
+ (`ON DELETE CASCADE`, with `PRAGMA foreign_keys=ON`); all reads and writes
260
+ filter by it, so apps can reuse type names and never see each other's data,
261
+ and deleting an app is a single cascading delete. Type identity is the
262
+ `(app, name)` pair, and relation targets must live in the same app.
263
+ - **One connection, one lock.** All access is serialized through a single
264
+ SQLite connection guarded by a reentrant lock — simple and correct at
265
+ localhost scale; threaded request handling stays safe.
266
+
267
+ ## Limitations
268
+
269
+ - **Schema morphing is purely lazy.** Every schema edit — add, drop, or retype
270
+ a field — rewrites only the one metadata row, never the stored objects (O(1)
271
+ regardless of data size). After a **type change**, a value still stored at the
272
+ old type simply reads as unset (the field's default, or null) until it's
273
+ written again; reads and queries apply this rule identically, so they always
274
+ agree. Re-adding a dropped field at the same type recovers its values.
275
+ - **Filtering is field-only.** Query operators apply to raw fields; relations
276
+ are read/written on the object body but not filtered server-side (yet).
277
+ - **Integer magnitude.** Numbers are stored and read back exactly at any size.
278
+ Filtering/sorting on integers beyond ±2⁶³ uses floating-point comparison (a
279
+ SQLite limitation), so equality/range queries on such huge integers may be
280
+ imprecise even though reads are exact.
281
+ - **HTTP verbs.** Only `GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD` are part of the
282
+ API; other verbs (e.g. `TRACE`) get the stdlib's plain `501`.
283
+ - **App keys are namespaces, not secrets.** The `X-App-Key` is an identifier in
284
+ a plain header — it isolates data between apps but is **not** authentication.
285
+ Anyone who knows a key can use that app; the absence of a list-apps endpoint is
286
+ light obscurity, not a security boundary.
287
+ - Scope is a localhost-scale developer tool — no auth, no horizontal scale.
288
+
289
+ ## Development
290
+
291
+ ```bash
292
+ python3 -m unittest discover -s tests # full suite, zero deps
293
+ ```
294
+
295
+ ## License
296
+
297
+ MIT — see `LICENSE`.
@@ -0,0 +1,7 @@
1
+ """MorphDB — a schema-fluid, API-stable database for AI-generated apps.
2
+
3
+ The schema can morph as fast as a coding agent iterates, while the frontend
4
+ keeps calling the same small set of generic, deterministic endpoints.
5
+ """
6
+
7
+ __version__ = "0.1.0"