morphdb 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"
morphdb/__main__.py ADDED
@@ -0,0 +1,34 @@
1
+ """CLI entry point: ``python -m morphdb`` or the ``morphdb`` console script."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__
7
+ from .server import serve
8
+
9
+
10
+ def main(argv=None):
11
+ parser = argparse.ArgumentParser(
12
+ prog="morphdb",
13
+ description="MorphDB — a schema-fluid, API-stable database for AI-generated apps.",
14
+ )
15
+ parser.add_argument("--host", default="127.0.0.1",
16
+ help="Host/interface to bind (default: 127.0.0.1).")
17
+ parser.add_argument("--port", type=int, default=8787,
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).")
21
+ parser.add_argument("--version", action="version",
22
+ version=f"morphdb {__version__}")
23
+ args = parser.parse_args(argv)
24
+
25
+ try:
26
+ serve(host=args.host, port=args.port, db_path=args.db)
27
+ except OSError as e:
28
+ sys.stderr.write(f"[morphdb] failed to start: {e}\n")
29
+ return 1
30
+ return 0
31
+
32
+
33
+ if __name__ == "__main__":
34
+ raise SystemExit(main())
morphdb/apps.py ADDED
@@ -0,0 +1,86 @@
1
+ """Apps — the multi-tenant root.
2
+
3
+ One MorphDB process can back many independent websites. Each is an **app**,
4
+ identified by a unique *key* the client picks at registration (no UUID needed —
5
+ any reasonable string). Every schema and object lives under exactly one app and
6
+ is invisible to the others, so two apps may reuse the same type names without
7
+ colliding.
8
+
9
+ Wire protocol: every schema and object request carries its app via the
10
+ ``X-App-Key`` HTTP header. A request with a missing or unknown key is refused —
11
+ there is no global, app-less namespace.
12
+
13
+ By design there is **no "list all apps" endpoint**: an agent only ever talks to
14
+ an app whose key it already knows (the key it registered). The surface is just
15
+ two calls, ``POST /app`` (register) and ``DELETE /app/{key}`` (delete + cascade).
16
+ """
17
+
18
+ import re
19
+
20
+ from . import db
21
+ from .errors import bad_request, conflict, not_found
22
+ from .util import now_iso
23
+
24
+ # Header- and path-safe: letters, digits, '.', '_', '-'; must start
25
+ # alphanumeric; 1–128 chars. \Z (not $) so a trailing newline can't sneak in.
26
+ _APP_KEY_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
27
+
28
+
29
+ def validate_app_key(key):
30
+ if not isinstance(key, str) or not _APP_KEY_RE.match(key):
31
+ raise bad_request(
32
+ "Invalid app key. Use 1-128 characters from letters, digits, '.', "
33
+ "'_', or '-', starting with a letter or digit."
34
+ )
35
+ return key
36
+
37
+
38
+ def register_app(key):
39
+ """Create a new app under ``key``. Rejects (409) if the key already exists."""
40
+ validate_app_key(key)
41
+ with db.transaction() as c:
42
+ if c.execute("SELECT 1 FROM apps WHERE key = ?", (key,)).fetchone():
43
+ raise conflict(
44
+ f"App '{key}' already exists. Pick a different, unused key."
45
+ )
46
+ c.execute("INSERT INTO apps (key, created_at) VALUES (?, ?)", (key, now_iso()))
47
+ return {"key": key, "created": True}
48
+
49
+
50
+ def delete_app(key):
51
+ """Delete an app and (via ON DELETE CASCADE) all of its schemas, objects,
52
+ relationship definitions, and edges. Other apps are untouched.
53
+ """
54
+ with db.transaction() as c:
55
+ if c.execute("SELECT 1 FROM apps WHERE key = ?", (key,)).fetchone() is None:
56
+ raise not_found(f"No app '{key}'.")
57
+ c.execute("DELETE FROM apps WHERE key = ?", (key,))
58
+ return {"deleted": key}
59
+
60
+
61
+ def app_exists(key):
62
+ return db.conn().execute(
63
+ "SELECT 1 FROM apps WHERE key = ?", (key,)
64
+ ).fetchone() is not None
65
+
66
+
67
+ def require_app(req):
68
+ """Resolve and validate the app key for a schema/object request.
69
+
70
+ Reads the ``X-App-Key`` header, checks its format, and confirms the app is
71
+ registered. Raises 400 if the header is missing/malformed, 404 if the app is
72
+ unknown. Returns the validated key for the handler to scope its work to.
73
+ """
74
+ key = req.headers.get("X-App-Key")
75
+ if key is None or not key.strip():
76
+ raise bad_request(
77
+ "Missing X-App-Key header. Register an app with POST /app, then send "
78
+ "its key as X-App-Key on every schema and object request."
79
+ )
80
+ key = key.strip()
81
+ validate_app_key(key)
82
+ if not app_exists(key):
83
+ raise not_found(
84
+ f"Unknown app '{key}'. Register it first with POST /app."
85
+ )
86
+ return key
@@ -0,0 +1,494 @@
1
+ """Relationships between objects — exposed as relation *fields* on a type.
2
+
3
+ A relationship is declared inside an object type's schema, under ``relations``::
4
+
5
+ "assignee": {"to": "user", "cardinality": "many_to_one", "inverse": "tasks"}
6
+
7
+ Declared once (on the ``from`` side), it automatically shows up on the other
8
+ type as the inverse relation (``user.tasks``). The frontend then reads and
9
+ writes relations exactly like ordinary fields: a relation value is a neighbor
10
+ guid (to-one) or a list of neighbor guids (to-many), right inside the object
11
+ body. Behind the scenes each relationship is still its own type in the
12
+ ``association_schemas`` table and each edge is a single canonical row in
13
+ ``associations`` — single-row storage keeps bidirectional traversal consistent
14
+ with no dual-write hazard.
15
+
16
+ Everything here is scoped to one **app**: every query carries the app key, so
17
+ relationships and edges never cross tenant boundaries (a relation target must be
18
+ an object in the same app).
19
+
20
+ Multiplicity, from a given object's point of view:
21
+ cardinality "X_to_Y" → the from-side sees Y neighbors, the to-side sees X.
22
+ So ``many_to_one`` (many tasks → one user): a task's ``assignee`` is one guid;
23
+ a user's ``tasks`` is a list.
24
+ """
25
+
26
+ from . import db
27
+ from .errors import bad_request
28
+ from .fieldtypes import validate_member_name
29
+ from .util import now_iso
30
+
31
+ CARDINALITIES = {"one_to_one", "one_to_many", "many_to_one", "many_to_many"}
32
+
33
+
34
+ def _assoc_name(from_type, forward_name):
35
+ """Internal name for a relationship: ``{from_type}__{forward_name}``.
36
+
37
+ Unique within an app (paired with the app key in the table's primary key);
38
+ never exposed in the API — the agent addresses relations by their field name
39
+ on a type, not by this synthetic id.
40
+ """
41
+ return f"{from_type}__{forward_name}"
42
+
43
+
44
+ def _mult(cardinality, side):
45
+ """Multiplicity ('one'/'many') seen from one end of a relationship.
46
+
47
+ side='from' → the to-part of the cardinality; side='to' → the from-part.
48
+ """
49
+ frm, to = cardinality.split("_to_")
50
+ return to if side == "from" else frm
51
+
52
+
53
+ # --- declaring relations (called by schema.py inside a transaction) -----------
54
+
55
+
56
+ def _parse_bool(v):
57
+ if isinstance(v, str):
58
+ return v.strip().lower() in ("true", "1", "yes", "on")
59
+ return bool(v)
60
+
61
+
62
+ def normalize_relation_def(from_type, key, raw):
63
+ """Validate one ``relations`` entry and return a normalized assoc-schema dict.
64
+
65
+ ``key`` is the relation's name on ``from_type`` (its forward_name).
66
+ """
67
+ validate_member_name(key, "relation")
68
+ if not isinstance(raw, dict):
69
+ raise bad_request(
70
+ f"Relation '{key}' must be an object like "
71
+ "{\"to\": \"user\", \"cardinality\": \"many_to_one\", \"inverse\": \"tasks\"}."
72
+ )
73
+ to_type = raw.get("to")
74
+ if not to_type or not isinstance(to_type, str):
75
+ raise bad_request(f"Relation '{key}' needs a 'to' object type.")
76
+ cardinality = raw.get("cardinality")
77
+ if cardinality not in CARDINALITIES:
78
+ raise bad_request(
79
+ f"Relation '{key}' has unknown cardinality {cardinality!r}. "
80
+ f"One of {sorted(CARDINALITIES)}."
81
+ )
82
+ symmetric = _parse_bool(raw.get("symmetric", False))
83
+ fdesc = raw.get("description")
84
+ idesc = raw.get("inverse_description")
85
+
86
+ if symmetric:
87
+ # A symmetric relationship is mutual (friends, peers): edge A–B == B–A.
88
+ # Only meaningful within one type, with a single shared label.
89
+ if to_type != from_type:
90
+ raise bad_request(
91
+ f"Relation '{key}' is symmetric, so 'to' must be '{from_type}'."
92
+ )
93
+ if cardinality not in ("one_to_one", "many_to_many"):
94
+ raise bad_request(
95
+ f"Symmetric relation '{key}' must be one_to_one or many_to_many."
96
+ )
97
+ inverse = key # one label describes both ends
98
+ idesc = fdesc
99
+ else:
100
+ inverse = raw.get("inverse")
101
+ if not inverse or not isinstance(inverse, str):
102
+ raise bad_request(
103
+ f"Relation '{key}' needs an 'inverse' name (the label the other "
104
+ "side sees), e.g. \"inverse\": \"tasks\"."
105
+ )
106
+ validate_member_name(inverse, "relation")
107
+ if from_type == to_type and inverse == key:
108
+ raise bad_request(
109
+ f"Relation '{key}' is a self-relation with identical forward and "
110
+ "inverse names; either give the inverse a different name or set "
111
+ "\"symmetric\": true."
112
+ )
113
+
114
+ return {
115
+ "name": _assoc_name(from_type, key),
116
+ "from_type": from_type,
117
+ "to_type": to_type,
118
+ "forward_name": key,
119
+ "inverse_name": inverse,
120
+ "cardinality": cardinality,
121
+ "symmetric": symmetric,
122
+ "forward_description": fdesc,
123
+ "inverse_description": idesc,
124
+ }
125
+
126
+
127
+ def upsert_relation(c, app, from_type, key, raw):
128
+ """Create or update one relationship declared on ``from_type`` in ``app``.
129
+
130
+ Runs inside the caller's transaction cursor ``c``. The ``to`` type must
131
+ already exist in the same app. Symmetric edges are canonicalized if the flag
132
+ is (re)set.
133
+ """
134
+ d = normalize_relation_def(from_type, key, raw)
135
+ # The neighbor type must exist (in this app) so traversal/validation works.
136
+ if c.execute("SELECT 1 FROM object_schemas WHERE app=? AND name=?",
137
+ (app, d["to_type"])).fetchone() is None:
138
+ raise bad_request(
139
+ f"Relation '{key}' points to unknown type '{d['to_type']}'. Define it first."
140
+ )
141
+ ts = now_iso()
142
+ exists = c.execute(
143
+ "SELECT 1 FROM association_schemas WHERE app=? AND name=?", (app, d["name"])
144
+ ).fetchone()
145
+ if exists:
146
+ c.execute(
147
+ "UPDATE association_schemas SET from_type=?, to_type=?, forward_name=?, "
148
+ "inverse_name=?, cardinality=?, symmetric=?, forward_description=?, "
149
+ "inverse_description=?, updated_at=? WHERE app=? AND name=?",
150
+ (d["from_type"], d["to_type"], d["forward_name"], d["inverse_name"],
151
+ d["cardinality"], int(d["symmetric"]), d["forward_description"],
152
+ d["inverse_description"], ts, app, d["name"]),
153
+ )
154
+ else:
155
+ c.execute(
156
+ "INSERT INTO association_schemas (app, name, from_type, to_type, "
157
+ "forward_name, inverse_name, cardinality, symmetric, "
158
+ "forward_description, inverse_description, created_at, updated_at) "
159
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
160
+ (app, d["name"], d["from_type"], d["to_type"], d["forward_name"],
161
+ d["inverse_name"], d["cardinality"], int(d["symmetric"]),
162
+ d["forward_description"], d["inverse_description"], ts, ts),
163
+ )
164
+ if d["symmetric"]:
165
+ _canonicalize_symmetric_edges(c, app, d["name"])
166
+ return d
167
+
168
+
169
+ def prune_forward_relations(c, app, from_type, keep_keys):
170
+ """On a replace (merge=false), drop relations authored on ``from_type`` that
171
+ are no longer listed — along with their edges. Inverse relations (authored on
172
+ the other type) are never touched here.
173
+ """
174
+ rows = c.execute(
175
+ "SELECT name, forward_name FROM association_schemas WHERE app=? AND from_type=?",
176
+ (app, from_type),
177
+ ).fetchall()
178
+ for r in rows:
179
+ if r["forward_name"] not in keep_keys:
180
+ c.execute("DELETE FROM associations WHERE app=? AND assoc_name=?",
181
+ (app, r["name"]))
182
+ c.execute("DELETE FROM association_schemas WHERE app=? AND name=?",
183
+ (app, r["name"]))
184
+
185
+
186
+ def delete_relations_touching_type(c, app, type_name):
187
+ """Delete every relationship (and its edges) in ``app`` where ``type_name``
188
+ is an endpoint. Used when a whole object type is deleted. Neighbor objects on
189
+ the other side are NOT removed by this — only the relationship metadata + edges.
190
+ """
191
+ rows = c.execute(
192
+ "SELECT name FROM association_schemas WHERE app=? AND (from_type=? OR to_type=?)",
193
+ (app, type_name, type_name),
194
+ ).fetchall()
195
+ for r in rows:
196
+ c.execute("DELETE FROM associations WHERE app=? AND assoc_name=?",
197
+ (app, r["name"]))
198
+ c.execute(
199
+ "DELETE FROM association_schemas WHERE app=? AND (from_type=? OR to_type=?)",
200
+ (app, type_name, type_name),
201
+ )
202
+
203
+
204
+ def _canonicalize_symmetric_edges(c, app, name):
205
+ """Rewrite a now-symmetric relationship's edges into canonical (sorted)
206
+ order and drop reverse duplicates, so A–B and B–A collapse to one row.
207
+ """
208
+ rows = c.execute(
209
+ "SELECT from_guid, to_guid, created_at FROM associations WHERE app=? AND assoc_name=?",
210
+ (app, name),
211
+ ).fetchall()
212
+ canon = {}
213
+ for r in rows:
214
+ a, b = sorted((r["from_guid"], r["to_guid"]))
215
+ prev = canon.get((a, b))
216
+ if prev is None or r["created_at"] < prev:
217
+ canon[(a, b)] = r["created_at"]
218
+ c.execute("DELETE FROM associations WHERE app=? AND assoc_name=?", (app, name))
219
+ for (a, b), created in canon.items():
220
+ c.execute(
221
+ "INSERT INTO associations (app, assoc_name, from_guid, to_guid, created_at) "
222
+ "VALUES (?, ?, ?, ?, ?)",
223
+ (app, name, a, b, created),
224
+ )
225
+
226
+
227
+ # --- relation "views" (one per relation a type exposes) -----------------------
228
+
229
+
230
+ def _all_assoc_schemas(app, c=None):
231
+ ex = (c or db.conn()).execute
232
+ return ex("SELECT * FROM association_schemas WHERE app=?", (app,)).fetchall()
233
+
234
+
235
+ def relation_views(app, type_name, c=None):
236
+ """All relations visible on ``type_name`` in ``app``, each as a view dict:
237
+
238
+ {key, assoc_name, side, mult, neighbor_type, cardinality, symmetric}
239
+
240
+ ``side`` is 'from', 'to', or 'sym' — how this type sits on the edge.
241
+ ``mult`` is 'one'/'many' — whether the value is a scalar guid or a list.
242
+ A self-relation (non-symmetric) yields two views: forward and inverse.
243
+ """
244
+ views = []
245
+ for s in _all_assoc_schemas(app, c):
246
+ sym = bool(s["symmetric"])
247
+ if sym:
248
+ if type_name in (s["from_type"], s["to_type"]):
249
+ views.append({
250
+ "key": s["forward_name"], "assoc_name": s["name"], "side": "sym",
251
+ "mult": _mult(s["cardinality"], "from"),
252
+ "neighbor_type": s["from_type"], # == to_type for symmetric
253
+ "cardinality": s["cardinality"], "symmetric": True,
254
+ })
255
+ continue
256
+ if s["from_type"] == type_name:
257
+ views.append({
258
+ "key": s["forward_name"], "assoc_name": s["name"], "side": "from",
259
+ "mult": _mult(s["cardinality"], "from"), "neighbor_type": s["to_type"],
260
+ "cardinality": s["cardinality"], "symmetric": False,
261
+ })
262
+ if s["to_type"] == type_name:
263
+ views.append({
264
+ "key": s["inverse_name"], "assoc_name": s["name"], "side": "to",
265
+ "mult": _mult(s["cardinality"], "to"), "neighbor_type": s["from_type"],
266
+ "cardinality": s["cardinality"], "symmetric": False,
267
+ })
268
+ return views
269
+
270
+
271
+ def relation_keys(app, type_name, c=None):
272
+ return {v["key"] for v in relation_views(app, type_name, c)}
273
+
274
+
275
+ def schema_relations(app, type_name, c=None):
276
+ """Return (relations, inverse_relations) dicts for a type's schema document.
277
+
278
+ ``relations`` are authored on this type (editable); ``inverse_relations``
279
+ are the read-only mirror of relations authored on another type.
280
+ """
281
+ relations, inverse = {}, {}
282
+ for s in _all_assoc_schemas(app, c):
283
+ sym = bool(s["symmetric"])
284
+ if sym and type_name in (s["from_type"], s["to_type"]):
285
+ relations[s["forward_name"]] = {
286
+ "to": s["to_type"], "cardinality": s["cardinality"],
287
+ "symmetric": True, "description": s["forward_description"],
288
+ }
289
+ continue
290
+ if not sym and s["from_type"] == type_name:
291
+ relations[s["forward_name"]] = {
292
+ "to": s["to_type"], "cardinality": s["cardinality"],
293
+ "inverse": s["inverse_name"], "symmetric": False,
294
+ "description": s["forward_description"],
295
+ "inverse_description": s["inverse_description"],
296
+ }
297
+ if not sym and s["to_type"] == type_name:
298
+ inverse[s["inverse_name"]] = {
299
+ "to": s["from_type"], "cardinality": _flip(s["cardinality"]),
300
+ "inverse": s["forward_name"], "symmetric": False,
301
+ "via_type": s["from_type"], "via_relation": s["forward_name"],
302
+ "readonly": True, "description": s["inverse_description"],
303
+ }
304
+ return relations, inverse
305
+
306
+
307
+ def _flip(cardinality):
308
+ frm, to = cardinality.split("_to_")
309
+ return f"{to}_to_{frm}"
310
+
311
+
312
+ # --- projecting relations onto object bodies (read) ---------------------------
313
+
314
+
315
+ def project_relations(app, guids, type_name):
316
+ """Map each guid to its relation values: {guid: {relation_key: guid|[guid]}}.
317
+
318
+ Batched: one query per relationship type touching the page of objects, so a
319
+ list read does not fan out into a query per object. Scoped to ``app``.
320
+ """
321
+ views = relation_views(app, type_name)
322
+ result = {g: {} for g in guids}
323
+ if not guids:
324
+ return result
325
+ # Seed every relation with its empty value so the shape is stable.
326
+ for g in guids:
327
+ for v in views:
328
+ result[g][v["key"]] = [] if v["mult"] == "many" else None
329
+ if not views:
330
+ return result
331
+
332
+ qmarks = ",".join("?" * len(guids))
333
+ gset = set(guids)
334
+ by_assoc = {}
335
+ for v in views:
336
+ by_assoc.setdefault(v["assoc_name"], []).append(v)
337
+
338
+ for assoc_name, vs in by_assoc.items():
339
+ rows = db.conn().execute(
340
+ f"SELECT from_guid, to_guid FROM associations WHERE app=? AND assoc_name=? "
341
+ f"AND (from_guid IN ({qmarks}) OR to_guid IN ({qmarks})) "
342
+ f"ORDER BY created_at, id",
343
+ [app, assoc_name, *guids, *guids],
344
+ ).fetchall()
345
+ for v in vs:
346
+ side, key, mult = v["side"], v["key"], v["mult"]
347
+ for r in rows:
348
+ fg, tg = r["from_guid"], r["to_guid"]
349
+ if side == "from":
350
+ if fg in gset:
351
+ _assign(result[fg], key, mult, tg)
352
+ elif side == "to":
353
+ if tg in gset:
354
+ _assign(result[tg], key, mult, fg)
355
+ else: # symmetric: object may be on either end
356
+ if fg in gset:
357
+ _assign(result[fg], key, mult, tg)
358
+ if tg in gset:
359
+ _assign(result[tg], key, mult, fg)
360
+ return result
361
+
362
+
363
+ def _assign(bucket, key, mult, neighbor):
364
+ if mult == "many":
365
+ bucket[key].append(neighbor)
366
+ elif bucket.get(key) is None:
367
+ bucket[key] = neighbor
368
+
369
+
370
+ # --- writing relations as fields (set-as-field, last-write-wins) --------------
371
+
372
+
373
+ def apply_relation_writes(c, app, obj_guid, type_name, rel_part):
374
+ """Apply the relation keys present in an object write.
375
+
376
+ ``rel_part`` maps relation keys to their desired value (a guid / list of
377
+ guids, or null/[] to clear). Only listed relations are touched; others are
378
+ left as-is. Set semantics: the listed value becomes the relation's full set.
379
+ """
380
+ views = {v["key"]: v for v in relation_views(app, type_name, c)}
381
+ for key, value in rel_part.items():
382
+ view = views[key]
383
+ desired = _desired_guids(key, view, value)
384
+ _set_relation(c, app, obj_guid, view, desired)
385
+
386
+
387
+ def _desired_guids(key, view, value):
388
+ if view["mult"] == "one":
389
+ if value in (None, ""):
390
+ return []
391
+ if not isinstance(value, str):
392
+ raise bad_request(
393
+ f"Relation '{key}' is to-one; expected a single guid string or null, "
394
+ f"got {type(value).__name__}."
395
+ )
396
+ return [value]
397
+ # to-many
398
+ if value is None:
399
+ return []
400
+ if not isinstance(value, (list, tuple)):
401
+ raise bad_request(
402
+ f"Relation '{key}' is to-many; expected a list of guids (or [] to clear), "
403
+ f"got {type(value).__name__}."
404
+ )
405
+ seen, out = set(), []
406
+ for g in value:
407
+ if not isinstance(g, str):
408
+ raise bad_request(f"Relation '{key}' values must be guid strings.")
409
+ if g not in seen:
410
+ seen.add(g)
411
+ out.append(g)
412
+ return out
413
+
414
+
415
+ def _set_relation(c, app, obj_guid, view, desired):
416
+ name = view["assoc_name"]
417
+ side = view["side"]
418
+
419
+ # Current neighbors for this relation, from obj's point of view.
420
+ if side == "from":
421
+ rows = c.execute(
422
+ "SELECT id, to_guid AS nb FROM associations WHERE app=? AND assoc_name=? AND from_guid=?",
423
+ (app, name, obj_guid)).fetchall()
424
+ elif side == "to":
425
+ rows = c.execute(
426
+ "SELECT id, from_guid AS nb FROM associations WHERE app=? AND assoc_name=? AND to_guid=?",
427
+ (app, name, obj_guid)).fetchall()
428
+ else: # sym
429
+ rows = c.execute(
430
+ "SELECT id, from_guid, to_guid FROM associations WHERE app=? AND assoc_name=? "
431
+ "AND (from_guid=? OR to_guid=?)", (app, name, obj_guid, obj_guid)).fetchall()
432
+ current = {}
433
+ for r in rows:
434
+ nb = r["nb"] if side != "sym" else (
435
+ r["to_guid"] if r["from_guid"] == obj_guid else r["from_guid"])
436
+ current[nb] = r["id"]
437
+
438
+ desired_set = set(desired)
439
+ # Remove edges no longer wanted.
440
+ for nb, eid in current.items():
441
+ if nb not in desired_set:
442
+ c.execute("DELETE FROM associations WHERE id=?", (eid,))
443
+
444
+ other_mult = _mult(view["cardinality"], "to" if side == "from" else "from")
445
+ ts = now_iso()
446
+ for nb in desired:
447
+ if nb in current:
448
+ continue
449
+ _validate_target(c, app, view, obj_guid, nb)
450
+ # Last-write-wins: if the neighbor's slot on the other side is single and
451
+ # already taken, steal it (delete the conflicting edge).
452
+ if other_mult == "one":
453
+ _free_target_slot(c, app, name, side, nb)
454
+ fg, tg = _edge_endpoints(side, obj_guid, nb)
455
+ c.execute(
456
+ "INSERT OR IGNORE INTO associations (app, assoc_name, from_guid, to_guid, created_at) "
457
+ "VALUES (?, ?, ?, ?, ?)", (app, name, fg, tg, ts))
458
+
459
+
460
+ def _edge_endpoints(side, obj_guid, neighbor):
461
+ if side == "from":
462
+ return obj_guid, neighbor
463
+ if side == "to":
464
+ return neighbor, obj_guid
465
+ return tuple(sorted((obj_guid, neighbor))) # symmetric: canonical order
466
+
467
+
468
+ def _free_target_slot(c, app, name, side, neighbor):
469
+ if side == "from":
470
+ # neighbor sits on the to-side; its single inbound slot must be freed.
471
+ c.execute("DELETE FROM associations WHERE app=? AND assoc_name=? AND to_guid=?",
472
+ (app, name, neighbor))
473
+ elif side == "to":
474
+ c.execute("DELETE FROM associations WHERE app=? AND assoc_name=? AND from_guid=?",
475
+ (app, name, neighbor))
476
+ else: # symmetric one_to_one
477
+ c.execute("DELETE FROM associations WHERE app=? AND assoc_name=? AND (from_guid=? OR to_guid=?)",
478
+ (app, name, neighbor, neighbor))
479
+
480
+
481
+ def _validate_target(c, app, view, obj_guid, neighbor):
482
+ if neighbor == obj_guid:
483
+ raise bad_request("Self-referential edges (an object related to itself) are not allowed.")
484
+ # The target must be an object in the SAME app — relations never cross apps.
485
+ row = c.execute(
486
+ "SELECT object_type FROM objects WHERE app=? AND guid=?",
487
+ (app, neighbor)).fetchone()
488
+ if row is None:
489
+ raise bad_request(
490
+ f"Relation '{view['key']}' target '{neighbor}' does not exist.")
491
+ if row["object_type"] != view["neighbor_type"]:
492
+ raise bad_request(
493
+ f"Relation '{view['key']}' expects a '{view['neighbor_type']}', but "
494
+ f"'{neighbor}' is a '{row['object_type']}'.")