post-graph 0.3.2__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: post-graph
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: High-performance PostgreSQL-backed graph database library supporting multi-tenant realms, space sub-grouping, schema-per-realm, shadow auditing, and append-only data history.
5
5
  Project-URL: Homepage, https://github.com/crajah/post-graph
6
6
  Project-URL: Repository, https://github.com/crajah/post-graph
@@ -5,16 +5,17 @@ from post_graph.errors import (
5
5
  TableExistsError,
6
6
  TableNotFoundError,
7
7
  CyclicReferenceError,
8
+ ReservedSpaceError,
8
9
  )
9
10
  from post_graph.models import Vertex, Edge, TraversalStep
10
- from post_graph.client_asyncpg import AsyncPostGraph
11
+ from post_graph.client_asyncpg import AsyncPostGraph, RESERVED_SPACE_ALL
11
12
 
12
13
  try:
13
14
  from post_graph.client_sqlalchemy import SQLAlchemyPostGraph
14
15
  except ImportError:
15
16
  SQLAlchemyPostGraph = None
16
17
 
17
- __version__ = "0.3.2"
18
+ __version__ = "0.4.0"
18
19
 
19
20
  __all__ = [
20
21
  "__version__",
@@ -24,9 +25,11 @@ __all__ = [
24
25
  "TableExistsError",
25
26
  "TableNotFoundError",
26
27
  "CyclicReferenceError",
28
+ "ReservedSpaceError",
27
29
  "Vertex",
28
30
  "Edge",
29
31
  "TraversalStep",
30
32
  "AsyncPostGraph",
31
33
  "SQLAlchemyPostGraph",
34
+ "RESERVED_SPACE_ALL",
32
35
  ]
@@ -10,12 +10,16 @@ from post_graph.errors import (
10
10
  TableExistsError,
11
11
  TableNotFoundError,
12
12
  PostGraphError,
13
+ ReservedSpaceError,
13
14
  )
14
15
  from post_graph.models import Vertex, Edge, DataRecord
15
16
 
16
17
  logger = logging.getLogger("post_graph")
17
18
 
18
19
 
20
+ RESERVED_SPACE_ALL = "__all__"
21
+
22
+
19
23
  class AsyncPostGraph:
20
24
  def __init__(
21
25
  self,
@@ -338,6 +342,7 @@ class AsyncPostGraph:
338
342
  CREATE TABLE IF NOT EXISTS {table_ref} (
339
343
  realm TEXT NOT NULL,
340
344
  id BIGSERIAL,
345
+ space VARCHAR(255) DEFAULT 'default' NOT NULL,
341
346
  uuid UUID DEFAULT gen_random_uuid() NOT NULL,
342
347
  fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{from_vertex_table}-{to_vertex_table}' || '/' || id::text) STORED NOT NULL,
343
348
  from_id BIGINT NOT NULL,
@@ -352,6 +357,8 @@ class AsyncPostGraph:
352
357
  );
353
358
  """
354
359
  await self._execute(query)
360
+ await self._execute(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS space VARCHAR(255) DEFAULT 'default';")
361
+ await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_space" ON {table_ref} (realm, space);')
355
362
  await self._execute(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{from_vertex_table}-{to_vertex_table}' || '/' || id::text) STORED;")
356
363
  await self._execute(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS uuid UUID DEFAULT gen_random_uuid();")
357
364
  await self._execute(f'CREATE UNIQUE INDEX IF NOT EXISTS "idx_{table_name}_uuid" ON {table_ref} (uuid);')
@@ -361,6 +368,7 @@ class AsyncPostGraph:
361
368
  CREATE TABLE IF NOT EXISTS {audit_table_ref} (
362
369
  audit_id BIGSERIAL PRIMARY KEY,
363
370
  realm TEXT NOT NULL,
371
+ space VARCHAR(255) DEFAULT 'default',
364
372
  action TEXT NOT NULL,
365
373
  changed_by TEXT,
366
374
  changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
@@ -376,12 +384,15 @@ class AsyncPostGraph:
376
384
  data_id BIGSERIAL PRIMARY KEY,
377
385
  realm TEXT NOT NULL,
378
386
  id BIGINT NOT NULL,
387
+ space VARCHAR(255) DEFAULT 'default' NOT NULL,
379
388
  payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
380
389
  timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
381
390
  FOREIGN KEY (realm, id) REFERENCES {table_ref}(realm, id) ON DELETE CASCADE
382
391
  );
383
392
  """
384
393
  await self._execute(data_query)
394
+ await self._execute(f"ALTER TABLE {data_table_ref} ADD COLUMN IF NOT EXISTS space VARCHAR(255) DEFAULT 'default';")
395
+ await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_space" ON {data_table_ref} (realm, space);')
385
396
  await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_id" ON {data_table_ref} (realm, id);')
386
397
  await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_payload" ON {data_table_ref} USING gin (payload);')
387
398
 
@@ -451,6 +462,8 @@ class AsyncPostGraph:
451
462
  ) -> Vertex:
452
463
  """Add a new vertex. Raises TableExistsError if it already exists."""
453
464
  self._validate_identifier(table_name)
465
+ if space == RESERVED_SPACE_ALL:
466
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
454
467
  payload_json = json.dumps(payload or {})
455
468
  table_ref = self._get_table_ref(table_name, realm)
456
469
  vec_str = f"[{','.join(str(x) for x in embedding)}]" if embedding else None
@@ -531,6 +544,8 @@ class AsyncPostGraph:
531
544
  ) -> Vertex:
532
545
  """Upsert a vertex (merges payload JSONB on conflict)."""
533
546
  self._validate_identifier(table_name)
547
+ if space == RESERVED_SPACE_ALL:
548
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
534
549
  payload_json = json.dumps(payload or {})
535
550
  table_ref = self._get_table_ref(table_name, realm)
536
551
  vec_str = f"[{','.join(str(x) for x in embedding)}]" if embedding else None
@@ -616,7 +631,7 @@ class AsyncPostGraph:
616
631
 
617
632
  async def _op(conn):
618
633
  query = f"""
619
- SELECT t.realm, t.id, t.fqid, t.payload, t.created_at, t.updated_at,
634
+ SELECT t.realm, t.id, t.space, t.fqid, t.payload, t.created_at, t.updated_at,
620
635
  t.uuid::text AS uuid_text,
621
636
  to_jsonb(t)->>'embedding' AS embedding_text
622
637
  FROM {table_ref} t
@@ -634,6 +649,7 @@ class AsyncPostGraph:
634
649
  return Vertex(
635
650
  realm=row['realm'],
636
651
  id=str(row['id']),
652
+ space=row.get('space') or 'default',
637
653
  fqid=row['fqid'],
638
654
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
639
655
  created_at=row['created_at'],
@@ -666,7 +682,7 @@ class AsyncPostGraph:
666
682
  async def _op(conn):
667
683
  params = [realm]
668
684
  space_clause = ""
669
- if space:
685
+ if space and space != RESERVED_SPACE_ALL:
670
686
  params.append(space)
671
687
  space_clause = f" AND (t.space = ${len(params)} OR (${len(params)} = 'default' AND (t.space IS NULL OR t.space = 'default')))"
672
688
 
@@ -721,7 +737,7 @@ class AsyncPostGraph:
721
737
 
722
738
  async def _op(conn):
723
739
  query = f"""
724
- SELECT t.realm, t.id, t.fqid, t.payload, t.created_at, t.updated_at,
740
+ SELECT t.realm, t.id, t.space, t.fqid, t.payload, t.created_at, t.updated_at,
725
741
  t.uuid::text AS uuid_text,
726
742
  to_jsonb(t)->>'embedding' AS embedding_text
727
743
  FROM {table_ref} t
@@ -739,6 +755,7 @@ class AsyncPostGraph:
739
755
  return Vertex(
740
756
  realm=row['realm'],
741
757
  id=str(row['id']),
758
+ space=row.get('space') or 'default',
742
759
  fqid=row['fqid'],
743
760
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
744
761
  created_at=row['created_at'],
@@ -796,15 +813,22 @@ class AsyncPostGraph:
796
813
  if search_data_table and scope == "main":
797
814
  scope = "data"
798
815
 
816
+ effective_space = space if space and space != RESERVED_SPACE_ALL else None
817
+ space_filter = ""
818
+ if effective_space:
819
+ space_filter = f" AND v.space = $4" if scope in ("data", "both") else f" AND t.space = $4"
820
+ space_filter_d = f" AND d.space = $4" if effective_space else ""
821
+ space_filter_combined = f" AND space = $4" if effective_space else ""
822
+
799
823
  if scope == "data":
800
824
  query = f"""
801
- SELECT v.realm, v.id, v.fqid, v.payload, v.created_at, v.updated_at,
825
+ SELECT v.realm, v.id, v.space, v.fqid, v.payload, v.created_at, v.updated_at,
802
826
  to_jsonb(v)->>'embedding' AS embedding_text,
803
827
  MIN(d.embedding {op} $2::vector) AS distance
804
828
  FROM {data_table_ref} d
805
829
  JOIN {table_ref} v ON d.realm = v.realm AND d.id = v.id
806
- WHERE d.realm = $1 AND d.embedding IS NOT NULL
807
- GROUP BY v.realm, v.id, v.fqid, v.payload, v.created_at, v.updated_at, to_jsonb(v)
830
+ WHERE d.realm = $1 AND d.embedding IS NOT NULL{space_filter_d}
831
+ GROUP BY v.realm, v.id, v.space, v.fqid, v.payload, v.created_at, v.updated_at, to_jsonb(v)
808
832
  ORDER BY distance ASC
809
833
  LIMIT $3
810
834
  """
@@ -813,20 +837,20 @@ class AsyncPostGraph:
813
837
  WITH combined AS (
814
838
  SELECT realm, id, (embedding {op} $2::vector) AS distance
815
839
  FROM {table_ref}
816
- WHERE realm = $1 AND embedding IS NOT NULL
840
+ WHERE realm = $1 AND embedding IS NOT NULL{space_filter_combined}
817
841
 
818
842
  UNION ALL
819
843
 
820
844
  SELECT realm, id, (embedding {op} $2::vector) AS distance
821
845
  FROM {data_table_ref}
822
- WHERE realm = $1 AND embedding IS NOT NULL
846
+ WHERE realm = $1 AND embedding IS NOT NULL{space_filter_combined}
823
847
  ),
824
848
  best AS (
825
849
  SELECT realm, id, MIN(distance) AS distance
826
850
  FROM combined
827
851
  GROUP BY realm, id
828
852
  )
829
- SELECT v.realm, v.id, v.fqid, v.payload, v.created_at, v.updated_at,
853
+ SELECT v.realm, v.id, v.space, v.fqid, v.payload, v.created_at, v.updated_at,
830
854
  to_jsonb(v)->>'embedding' AS embedding_text,
831
855
  b.distance
832
856
  FROM best b
@@ -836,18 +860,22 @@ class AsyncPostGraph:
836
860
  """
837
861
  else:
838
862
  query = f"""
839
- SELECT t.realm, t.id, t.fqid, t.payload, t.created_at, t.updated_at,
863
+ SELECT t.realm, t.id, t.space, t.fqid, t.payload, t.created_at, t.updated_at,
840
864
  to_jsonb(t)->>'embedding' AS embedding_text,
841
865
  (t.embedding {op} $2::vector) AS distance
842
866
  FROM {table_ref} t
843
- WHERE t.realm = $1 AND t.embedding IS NOT NULL
867
+ WHERE t.realm = $1 AND t.embedding IS NOT NULL{space_filter}
844
868
  ORDER BY t.embedding {op} $2::vector ASC
845
869
  LIMIT $3
846
870
  """
847
871
 
872
+ fetch_args = [realm, vec_str, top_k]
873
+ if effective_space:
874
+ fetch_args.append(effective_space)
875
+
848
876
  async def _op(conn):
849
877
  try:
850
- rows = await conn.fetch(query, realm, vec_str, top_k)
878
+ rows = await conn.fetch(query, *fetch_args)
851
879
  except asyncpg.UndefinedTableError:
852
880
  raise TableNotFoundError(f"Vertex table '{table_name}' does not exist.")
853
881
  except asyncpg.UndefinedColumnError:
@@ -862,6 +890,7 @@ class AsyncPostGraph:
862
890
  v = Vertex(
863
891
  realm=r['realm'],
864
892
  id=str(r['id']),
893
+ space=r.get('space') or 'default',
865
894
  fqid=r['fqid'],
866
895
  payload=r['payload'] if isinstance(r['payload'], dict) else json.loads(r['payload']),
867
896
  created_at=r['created_at'],
@@ -909,12 +938,16 @@ class AsyncPostGraph:
909
938
  edge_id: Optional[Union[str, int]] = None,
910
939
  payload: Optional[Dict[str, Any]] = None,
911
940
  user_id: Optional[str] = None,
912
- check_cycle: Union[bool, List[str]] = False
941
+ check_cycle: Union[bool, List[str]] = False,
942
+ space: Optional[str] = "default"
913
943
  ) -> Edge:
914
944
  """Add a new edge. Raises TableExistsError if it already exists."""
915
945
  self._validate_identifier(table_name)
946
+ if space == RESERVED_SPACE_ALL:
947
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
916
948
  payload_json = json.dumps(payload or {})
917
949
  table_ref = self._get_table_ref(table_name, realm)
950
+ eff_space = space or "default"
918
951
 
919
952
  async def _op(conn):
920
953
  nonlocal edge_id
@@ -954,12 +987,12 @@ class AsyncPostGraph:
954
987
  )
955
988
 
956
989
  query = f"""
957
- INSERT INTO {table_ref} (realm, id, from_id, to_id, relation_type, payload)
958
- VALUES ($1, $2, $3, $4, $5, $6::jsonb)
959
- RETURNING realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
990
+ INSERT INTO {table_ref} (realm, id, space, from_id, to_id, relation_type, payload)
991
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
992
+ RETURNING realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
960
993
  """
961
994
  try:
962
- row = await conn.fetchrow(query, realm, e_id_int, from_id_int, to_id_int, relation_type, payload_json)
995
+ row = await conn.fetchrow(query, realm, e_id_int, eff_space, from_id_int, to_id_int, relation_type, payload_json)
963
996
  if edge_id is not None:
964
997
  await conn.execute(
965
998
  f"SELECT setval(pg_get_serial_sequence('{table_ref_pg}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM {table_ref}))"
@@ -971,6 +1004,7 @@ class AsyncPostGraph:
971
1004
  from_id=str(row['from_id']),
972
1005
  to_id=str(row['to_id']),
973
1006
  relation_type=row['relation_type'],
1007
+ space=row.get('space') or 'default',
974
1008
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
975
1009
  created_at=row['created_at'],
976
1010
  updated_at=row['updated_at'],
@@ -999,12 +1033,16 @@ class AsyncPostGraph:
999
1033
  edge_id: Optional[Union[str, int]] = None,
1000
1034
  payload: Optional[Dict[str, Any]] = None,
1001
1035
  user_id: Optional[str] = None,
1002
- check_cycle: Union[bool, List[str]] = False
1036
+ check_cycle: Union[bool, List[str]] = False,
1037
+ space: Optional[str] = "default"
1003
1038
  ) -> Edge:
1004
1039
  """Upsert an edge (merges payload JSONB on conflict)."""
1005
1040
  self._validate_identifier(table_name)
1041
+ if space == RESERVED_SPACE_ALL:
1042
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
1006
1043
  payload_json = json.dumps(payload or {})
1007
1044
  table_ref = self._get_table_ref(table_name, realm)
1045
+ eff_space = space or "default"
1008
1046
 
1009
1047
  async def _op(conn):
1010
1048
  nonlocal edge_id
@@ -1045,18 +1083,19 @@ class AsyncPostGraph:
1045
1083
  )
1046
1084
 
1047
1085
  query = f"""
1048
- INSERT INTO {table_ref} (realm, id, from_id, to_id, relation_type, payload)
1049
- VALUES ($1, $2, $3, $4, $5, $6::jsonb)
1086
+ INSERT INTO {table_ref} (realm, id, space, from_id, to_id, relation_type, payload)
1087
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
1050
1088
  ON CONFLICT (realm, id) DO UPDATE
1051
- SET from_id = EXCLUDED.from_id,
1089
+ SET space = EXCLUDED.space,
1090
+ from_id = EXCLUDED.from_id,
1052
1091
  to_id = EXCLUDED.to_id,
1053
1092
  relation_type = EXCLUDED.relation_type,
1054
1093
  payload = {table_ref}.payload || EXCLUDED.payload,
1055
1094
  updated_at = CURRENT_TIMESTAMP
1056
- RETURNING realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1095
+ RETURNING realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1057
1096
  """
1058
1097
  try:
1059
- row = await conn.fetchrow(query, realm, e_id_int, from_id_int, to_id_int, relation_type, payload_json)
1098
+ row = await conn.fetchrow(query, realm, e_id_int, eff_space, from_id_int, to_id_int, relation_type, payload_json)
1060
1099
  return Edge(
1061
1100
  realm=row['realm'],
1062
1101
  id=str(row['id']),
@@ -1064,6 +1103,7 @@ class AsyncPostGraph:
1064
1103
  from_id=str(row['from_id']),
1065
1104
  to_id=str(row['to_id']),
1066
1105
  relation_type=row['relation_type'],
1106
+ space=row.get('space') or 'default',
1067
1107
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1068
1108
  created_at=row['created_at'],
1069
1109
  updated_at=row['updated_at'],
@@ -1088,8 +1128,8 @@ class AsyncPostGraph:
1088
1128
 
1089
1129
  table_ref = self._get_table_ref(table_name, realm)
1090
1130
  query = f"""
1091
- SELECT realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1092
- FROM {table_ref}
1131
+ SELECT realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1132
+ FROM {table_ref}
1093
1133
  WHERE realm = $1 AND ((CASE WHEN $2 ~ '^[0-9]+$' THEN id = $2::bigint ELSE FALSE END) OR fqid = $2)
1094
1134
  """
1095
1135
  try:
@@ -1103,6 +1143,7 @@ class AsyncPostGraph:
1103
1143
  from_id=str(row['from_id']),
1104
1144
  to_id=str(row['to_id']),
1105
1145
  relation_type=row['relation_type'],
1146
+ space=row.get('space') or 'default',
1106
1147
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1107
1148
  created_at=row['created_at'],
1108
1149
  updated_at=row['updated_at'],
@@ -1119,8 +1160,8 @@ class AsyncPostGraph:
1119
1160
  table_ref = self._get_table_ref(table_name, realm)
1120
1161
  uuid_str = str(uuid).strip()
1121
1162
  query = f"""
1122
- SELECT realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1123
- FROM {table_ref}
1163
+ SELECT realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1164
+ FROM {table_ref}
1124
1165
  WHERE realm = $1 AND uuid = $2::uuid
1125
1166
  """
1126
1167
  try:
@@ -1134,6 +1175,7 @@ class AsyncPostGraph:
1134
1175
  from_id=str(row['from_id']),
1135
1176
  to_id=str(row['to_id']),
1136
1177
  relation_type=row['relation_type'],
1178
+ space=row.get('space') or 'default',
1137
1179
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1138
1180
  created_at=row['created_at'],
1139
1181
  updated_at=row['updated_at'],
@@ -1280,9 +1322,9 @@ class AsyncPostGraph:
1280
1322
  queries.append((
1281
1323
  to_ref,
1282
1324
  f"""
1283
- SELECT
1284
- v.id AS v_id, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1285
- e.id AS e_id, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1325
+ SELECT
1326
+ v.id AS v_id, v.space AS v_space, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1327
+ e.id AS e_id, e.space AS e_space, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1286
1328
  FROM {edge_ref} e
1287
1329
  JOIN {to_ref_ref} v ON e.realm = v.realm AND e.to_id = v.id
1288
1330
  WHERE e.realm = $1 AND e.from_id = $2
@@ -1296,9 +1338,9 @@ class AsyncPostGraph:
1296
1338
  queries.append((
1297
1339
  from_ref,
1298
1340
  f"""
1299
- SELECT
1300
- v.id AS v_id, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1301
- e.id AS e_id, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1341
+ SELECT
1342
+ v.id AS v_id, v.space AS v_space, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1343
+ e.id AS e_id, e.space AS e_space, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1302
1344
  FROM {edge_ref} e
1303
1345
  JOIN {from_ref_ref} v ON e.realm = v.realm AND e.from_id = v.id
1304
1346
  WHERE e.realm = $1 AND e.to_id = $2
@@ -1311,6 +1353,7 @@ class AsyncPostGraph:
1311
1353
  v = Vertex(
1312
1354
  realm=realm,
1313
1355
  id=str(r['v_id']),
1356
+ space=r.get('v_space') or 'default',
1314
1357
  fqid=r['v_fqid'],
1315
1358
  payload=r['v_payload'] if isinstance(r['v_payload'], dict) else json.loads(r['v_payload']),
1316
1359
  created_at=r['v_created_at'],
@@ -1325,6 +1368,7 @@ class AsyncPostGraph:
1325
1368
  from_id=str(r['e_from']),
1326
1369
  to_id=str(r['e_to']),
1327
1370
  relation_type=r['e_rel'],
1371
+ space=r.get('e_space') or 'default',
1328
1372
  payload=r['e_payload'] if isinstance(r['e_payload'], dict) else json.loads(r['e_payload']),
1329
1373
  created_at=r['e_created_at'],
1330
1374
  updated_at=r['e_updated_at'],
@@ -12,11 +12,14 @@ from post_graph.errors import (
12
12
  TableExistsError,
13
13
  TableNotFoundError,
14
14
  PostGraphError,
15
+ ReservedSpaceError,
15
16
  )
16
17
  from post_graph.models import Vertex, Edge, DataRecord
17
18
 
18
19
  logger = logging.getLogger("post_graph")
19
20
 
21
+ RESERVED_SPACE_ALL = "__all__"
22
+
20
23
 
21
24
  class SQLAlchemyPostGraph:
22
25
  def __init__(self, engine_or_connection: Union[AsyncEngine, AsyncConnection], schema_per_realm: bool = False):
@@ -210,6 +213,7 @@ class SQLAlchemyPostGraph:
210
213
  CREATE TABLE IF NOT EXISTS {audit_table_ref} (
211
214
  audit_id BIGSERIAL PRIMARY KEY,
212
215
  realm TEXT NOT NULL,
216
+ space VARCHAR(255) DEFAULT 'default',
213
217
  action TEXT NOT NULL,
214
218
  changed_by TEXT,
215
219
  changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
@@ -219,6 +223,24 @@ class SQLAlchemyPostGraph:
219
223
  """
220
224
  await conn.execute(text(audit_query))
221
225
 
226
+ # 3. Create append-only data table
227
+ data_query = f"""
228
+ CREATE TABLE IF NOT EXISTS {data_table_ref} (
229
+ data_id BIGSERIAL PRIMARY KEY,
230
+ realm TEXT NOT NULL,
231
+ id BIGINT NOT NULL,
232
+ space VARCHAR(255) DEFAULT 'default' NOT NULL,
233
+ payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
234
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
235
+ FOREIGN KEY (realm, id) REFERENCES {table_ref}(realm, id) ON DELETE CASCADE
236
+ );
237
+ """
238
+ await conn.execute(text(data_query))
239
+ await conn.execute(text(f"ALTER TABLE {data_table_ref} ADD COLUMN IF NOT EXISTS space VARCHAR(255) DEFAULT 'default';"))
240
+ await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_space" ON {data_table_ref} (realm, space);'))
241
+ await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_id" ON {data_table_ref} (realm, id);'))
242
+ await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_payload" ON {data_table_ref} USING gin (payload);'))
243
+
222
244
  await conn.execute(text(f'DROP TRIGGER IF EXISTS "update_{table_name}_modtime" ON {table_ref};'))
223
245
  await conn.execute(text(f"""
224
246
  CREATE TRIGGER "update_{table_name}_modtime"
@@ -292,6 +314,7 @@ class SQLAlchemyPostGraph:
292
314
  CREATE TABLE IF NOT EXISTS {table_ref} (
293
315
  realm TEXT NOT NULL,
294
316
  id BIGSERIAL,
317
+ space VARCHAR(255) DEFAULT 'default' NOT NULL,
295
318
  uuid UUID DEFAULT gen_random_uuid() NOT NULL,
296
319
  fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{from_vertex_table}-{to_vertex_table}' || '/' || id::text) STORED NOT NULL,
297
320
  from_id BIGINT NOT NULL,
@@ -306,6 +329,8 @@ class SQLAlchemyPostGraph:
306
329
  );
307
330
  """
308
331
  await conn.execute(text(query))
332
+ await conn.execute(text(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS space VARCHAR(255) DEFAULT 'default';"))
333
+ await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_space" ON {table_ref} (realm, space);'))
309
334
  await conn.execute(text(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{from_vertex_table}-{to_vertex_table}' || '/' || id::text) STORED;"))
310
335
  await conn.execute(text(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS uuid UUID DEFAULT gen_random_uuid();"))
311
336
  await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS "idx_{table_name}_uuid" ON {table_ref} (uuid);'))
@@ -315,6 +340,7 @@ class SQLAlchemyPostGraph:
315
340
  CREATE TABLE IF NOT EXISTS {audit_table_ref} (
316
341
  audit_id BIGSERIAL PRIMARY KEY,
317
342
  realm TEXT NOT NULL,
343
+ space VARCHAR(255) DEFAULT 'default',
318
344
  action TEXT NOT NULL,
319
345
  changed_by TEXT,
320
346
  changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
@@ -330,12 +356,15 @@ class SQLAlchemyPostGraph:
330
356
  data_id BIGSERIAL PRIMARY KEY,
331
357
  realm TEXT NOT NULL,
332
358
  id BIGINT NOT NULL,
359
+ space VARCHAR(255) DEFAULT 'default' NOT NULL,
333
360
  payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
334
361
  timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
335
362
  FOREIGN KEY (realm, id) REFERENCES {table_ref}(realm, id) ON DELETE CASCADE
336
363
  );
337
364
  """
338
365
  await conn.execute(text(data_query))
366
+ await conn.execute(text(f"ALTER TABLE {data_table_ref} ADD COLUMN IF NOT EXISTS space VARCHAR(255) DEFAULT 'default';"))
367
+ await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_space" ON {data_table_ref} (realm, space);'))
339
368
  await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_id" ON {data_table_ref} (realm, id);'))
340
369
  await conn.execute(text(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_payload" ON {data_table_ref} USING gin (payload);'))
341
370
 
@@ -406,13 +435,17 @@ class SQLAlchemyPostGraph:
406
435
  vertex_id: Optional[Union[str, int]] = None,
407
436
  payload: Optional[Dict[str, Any]] = None,
408
437
  embedding: Optional[List[float]] = None,
409
- user_id: Optional[str] = None
438
+ user_id: Optional[str] = None,
439
+ space: Optional[str] = "default"
410
440
  ) -> Vertex:
411
441
  """Add a new vertex. Raises TableExistsError if it already exists."""
412
442
  self._validate_identifier(table_name)
443
+ if space == RESERVED_SPACE_ALL:
444
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
413
445
  payload_json = json.dumps(payload or {})
414
446
  table_ref = self._get_table_ref(table_name, realm)
415
447
  vec_str = f"[{','.join(str(x) for x in embedding)}]" if embedding else None
448
+ eff_space = space or "default"
416
449
 
417
450
  async def _op(conn):
418
451
  nonlocal vertex_id
@@ -427,18 +460,18 @@ class SQLAlchemyPostGraph:
427
460
 
428
461
  if vec_str:
429
462
  query = f"""
430
- INSERT INTO {table_ref} (realm, id, payload, embedding)
431
- VALUES (:realm, :id, CAST(:payload AS JSONB), CAST(:vec AS vector))
432
- RETURNING realm, id, fqid, payload, created_at, updated_at, uuid::text AS uuid_text, CAST(embedding AS TEXT) AS embedding_text
463
+ INSERT INTO {table_ref} (realm, id, space, payload, embedding)
464
+ VALUES (:realm, :id, :space, CAST(:payload AS JSONB), CAST(:vec AS vector))
465
+ RETURNING realm, id, space, fqid, payload, created_at, updated_at, uuid::text AS uuid_text, CAST(embedding AS TEXT) AS embedding_text
433
466
  """
434
- kwargs = {"realm": realm, "id": v_id_int, "payload": payload_json, "vec": vec_str}
467
+ kwargs = {"realm": realm, "id": v_id_int, "space": eff_space, "payload": payload_json, "vec": vec_str}
435
468
  else:
436
469
  query = f"""
437
- INSERT INTO {table_ref} (realm, id, payload)
438
- VALUES (:realm, :id, CAST(:payload AS JSONB))
439
- RETURNING realm, id, fqid, payload, created_at, updated_at, uuid::text AS uuid_text
470
+ INSERT INTO {table_ref} (realm, id, space, payload)
471
+ VALUES (:realm, :id, :space, CAST(:payload AS JSONB))
472
+ RETURNING realm, id, space, fqid, payload, created_at, updated_at, uuid::text AS uuid_text
440
473
  """
441
- kwargs = {"realm": realm, "id": v_id_int, "payload": payload_json}
474
+ kwargs = {"realm": realm, "id": v_id_int, "space": eff_space, "payload": payload_json}
442
475
 
443
476
  try:
444
477
  row = await self._fetchrow(conn, query, **kwargs)
@@ -452,6 +485,7 @@ class SQLAlchemyPostGraph:
452
485
  return Vertex(
453
486
  realm=row['realm'],
454
487
  id=str(row['id']),
488
+ space=row.get('space') or 'default',
455
489
  fqid=row['fqid'],
456
490
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
457
491
  created_at=row['created_at'],
@@ -481,13 +515,17 @@ class SQLAlchemyPostGraph:
481
515
  vertex_id: Optional[Union[str, int]] = None,
482
516
  payload: Optional[Dict[str, Any]] = None,
483
517
  embedding: Optional[List[float]] = None,
484
- user_id: Optional[str] = None
518
+ user_id: Optional[str] = None,
519
+ space: Optional[str] = "default"
485
520
  ) -> Vertex:
486
521
  """Upsert a vertex (merges payload JSONB on conflict)."""
487
522
  self._validate_identifier(table_name)
523
+ if space == RESERVED_SPACE_ALL:
524
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
488
525
  payload_json = json.dumps(payload or {})
489
526
  table_ref = self._get_table_ref(table_name, realm)
490
527
  vec_str = f"[{','.join(str(x) for x in embedding)}]" if embedding else None
528
+ eff_space = space or "default"
491
529
 
492
530
  async def _op(conn):
493
531
  nonlocal vertex_id
@@ -502,25 +540,27 @@ class SQLAlchemyPostGraph:
502
540
 
503
541
  if vec_str:
504
542
  query = f"""
505
- INSERT INTO {table_ref} (realm, id, payload, embedding)
506
- VALUES (:realm, :id, CAST(:payload AS JSONB), CAST(:vec AS vector))
543
+ INSERT INTO {table_ref} (realm, id, space, payload, embedding)
544
+ VALUES (:realm, :id, :space, CAST(:payload AS JSONB), CAST(:vec AS vector))
507
545
  ON CONFLICT (realm, id) DO UPDATE
508
- SET payload = {table_ref}.payload || EXCLUDED.payload,
546
+ SET space = EXCLUDED.space,
547
+ payload = {table_ref}.payload || EXCLUDED.payload,
509
548
  embedding = EXCLUDED.embedding,
510
549
  updated_at = CURRENT_TIMESTAMP
511
- RETURNING realm, id, fqid, payload, created_at, updated_at, uuid::text AS uuid_text, CAST(embedding AS TEXT) AS embedding_text
550
+ RETURNING realm, id, space, fqid, payload, created_at, updated_at, uuid::text AS uuid_text, CAST(embedding AS TEXT) AS embedding_text
512
551
  """
513
- kwargs = {"realm": realm, "id": v_id_int, "payload": payload_json, "vec": vec_str}
552
+ kwargs = {"realm": realm, "id": v_id_int, "space": eff_space, "payload": payload_json, "vec": vec_str}
514
553
  else:
515
554
  query = f"""
516
- INSERT INTO {table_ref} (realm, id, payload)
517
- VALUES (:realm, :id, CAST(:payload AS JSONB))
555
+ INSERT INTO {table_ref} (realm, id, space, payload)
556
+ VALUES (:realm, :id, :space, CAST(:payload AS JSONB))
518
557
  ON CONFLICT (realm, id) DO UPDATE
519
- SET payload = {table_ref}.payload || EXCLUDED.payload,
558
+ SET space = EXCLUDED.space,
559
+ payload = {table_ref}.payload || EXCLUDED.payload,
520
560
  updated_at = CURRENT_TIMESTAMP
521
- RETURNING realm, id, fqid, payload, created_at, updated_at, uuid::text AS uuid_text
561
+ RETURNING realm, id, space, fqid, payload, created_at, updated_at, uuid::text AS uuid_text
522
562
  """
523
- kwargs = {"realm": realm, "id": v_id_int, "payload": payload_json}
563
+ kwargs = {"realm": realm, "id": v_id_int, "space": eff_space, "payload": payload_json}
524
564
 
525
565
  try:
526
566
  row = await self._fetchrow(conn, query, **kwargs)
@@ -530,6 +570,7 @@ class SQLAlchemyPostGraph:
530
570
  return Vertex(
531
571
  realm=row['realm'],
532
572
  id=str(row['id']),
573
+ space=row.get('space') or 'default',
533
574
  fqid=row['fqid'],
534
575
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
535
576
  created_at=row['created_at'],
@@ -563,7 +604,7 @@ class SQLAlchemyPostGraph:
563
604
 
564
605
  async def _op(conn):
565
606
  query = f"""
566
- SELECT t.realm, t.id, t.fqid, t.payload, t.created_at, t.updated_at,
607
+ SELECT t.realm, t.id, t.space, t.fqid, t.payload, t.created_at, t.updated_at,
567
608
  t.uuid::text AS uuid_text,
568
609
  to_jsonb(t)->>'embedding' AS embedding_text
569
610
  FROM {table_ref} t
@@ -581,6 +622,7 @@ class SQLAlchemyPostGraph:
581
622
  return Vertex(
582
623
  realm=row['realm'],
583
624
  id=str(row['id']),
625
+ space=row.get('space') or 'default',
584
626
  fqid=row['fqid'],
585
627
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
586
628
  created_at=row['created_at'],
@@ -615,7 +657,7 @@ class SQLAlchemyPostGraph:
615
657
  async def _op(conn):
616
658
  params = {"realm": realm}
617
659
  space_clause = ""
618
- if space:
660
+ if space and space != RESERVED_SPACE_ALL:
619
661
  params["space"] = space
620
662
  space_clause = " AND (t.space = :space OR (:space = 'default' AND (t.space IS NULL OR t.space = 'default')))"
621
663
 
@@ -671,7 +713,7 @@ class SQLAlchemyPostGraph:
671
713
 
672
714
  async def _op(conn):
673
715
  query = f"""
674
- SELECT t.realm, t.id, t.fqid, t.payload, t.created_at, t.updated_at,
716
+ SELECT t.realm, t.id, t.space, t.fqid, t.payload, t.created_at, t.updated_at,
675
717
  t.uuid::text AS uuid_text,
676
718
  to_jsonb(t)->>'embedding' AS embedding_text
677
719
  FROM {table_ref} t
@@ -689,6 +731,7 @@ class SQLAlchemyPostGraph:
689
731
  return Vertex(
690
732
  realm=row['realm'],
691
733
  id=str(row['id']),
734
+ space=row.get('space') or 'default',
692
735
  fqid=row['fqid'],
693
736
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
694
737
  created_at=row['created_at'],
@@ -736,15 +779,20 @@ class SQLAlchemyPostGraph:
736
779
  if search_data_table and scope == "main":
737
780
  scope = "data"
738
781
 
782
+ effective_space = space if space and space != RESERVED_SPACE_ALL else None
783
+ space_filter_d = " AND d.space = :space" if effective_space else ""
784
+ space_filter_t = " AND t.space = :space" if effective_space else ""
785
+ space_filter_combined = " AND space = :space" if effective_space else ""
786
+
739
787
  if scope == "data":
740
788
  query = f"""
741
- SELECT v.realm, v.id, v.fqid, v.payload, v.created_at, v.updated_at,
789
+ SELECT v.realm, v.id, v.space, v.fqid, v.payload, v.created_at, v.updated_at,
742
790
  to_jsonb(v)->>'embedding' AS embedding_text,
743
791
  MIN(d.embedding {op} CAST(:vec AS vector)) AS distance
744
792
  FROM {data_table_ref} d
745
793
  JOIN {table_ref} v ON d.realm = v.realm AND d.id = v.id
746
- WHERE d.realm = :realm AND d.embedding IS NOT NULL
747
- GROUP BY v.realm, v.id, v.fqid, v.payload, v.created_at, v.updated_at, to_jsonb(v)
794
+ WHERE d.realm = :realm AND d.embedding IS NOT NULL{space_filter_d}
795
+ GROUP BY v.realm, v.id, v.space, v.fqid, v.payload, v.created_at, v.updated_at, to_jsonb(v)
748
796
  ORDER BY distance ASC
749
797
  LIMIT :top_k
750
798
  """
@@ -753,20 +801,20 @@ class SQLAlchemyPostGraph:
753
801
  WITH combined AS (
754
802
  SELECT realm, id, (embedding {op} CAST(:vec AS vector)) AS distance
755
803
  FROM {table_ref}
756
- WHERE realm = :realm AND embedding IS NOT NULL
804
+ WHERE realm = :realm AND embedding IS NOT NULL{space_filter_combined}
757
805
 
758
806
  UNION ALL
759
807
 
760
808
  SELECT realm, id, (embedding {op} CAST(:vec AS vector)) AS distance
761
809
  FROM {data_table_ref}
762
- WHERE realm = :realm AND embedding IS NOT NULL
810
+ WHERE realm = :realm AND embedding IS NOT NULL{space_filter_combined}
763
811
  ),
764
812
  best AS (
765
813
  SELECT realm, id, MIN(distance) AS distance
766
814
  FROM combined
767
815
  GROUP BY realm, id
768
816
  )
769
- SELECT v.realm, v.id, v.fqid, v.payload, v.created_at, v.updated_at,
817
+ SELECT v.realm, v.id, v.space, v.fqid, v.payload, v.created_at, v.updated_at,
770
818
  to_jsonb(v)->>'embedding' AS embedding_text,
771
819
  b.distance
772
820
  FROM best b
@@ -776,18 +824,22 @@ class SQLAlchemyPostGraph:
776
824
  """
777
825
  else:
778
826
  query = f"""
779
- SELECT t.realm, t.id, t.fqid, t.payload, t.created_at, t.updated_at,
827
+ SELECT t.realm, t.id, t.space, t.fqid, t.payload, t.created_at, t.updated_at,
780
828
  CAST(t.embedding AS TEXT) AS embedding_text,
781
829
  (t.embedding {op} CAST(:vec AS vector)) AS distance
782
830
  FROM {table_ref} t
783
- WHERE t.realm = :realm AND t.embedding IS NOT NULL
831
+ WHERE t.realm = :realm AND t.embedding IS NOT NULL{space_filter_t}
784
832
  ORDER BY t.embedding {op} CAST(:vec AS vector) ASC
785
833
  LIMIT :top_k
786
834
  """
787
835
 
836
+ fetch_params = {"realm": realm, "vec": vec_str, "top_k": top_k}
837
+ if effective_space:
838
+ fetch_params["space"] = effective_space
839
+
788
840
  async def _op(conn):
789
841
  try:
790
- rows = await self._fetch(conn, query, realm=realm, vec=vec_str, top_k=top_k)
842
+ rows = await self._fetch(conn, query, **fetch_params)
791
843
  except ProgrammingError as e:
792
844
  if "does not exist" in str(e).lower():
793
845
  raise TableNotFoundError(f"Vertex table '{table_name}' does not exist.")
@@ -802,6 +854,7 @@ class SQLAlchemyPostGraph:
802
854
  v = Vertex(
803
855
  realm=r['realm'],
804
856
  id=str(r['id']),
857
+ space=r.get('space') or 'default',
805
858
  fqid=r['fqid'],
806
859
  payload=r['payload'] if isinstance(r['payload'], dict) else json.loads(r['payload']),
807
860
  created_at=r['created_at'],
@@ -851,12 +904,16 @@ class SQLAlchemyPostGraph:
851
904
  edge_id: Optional[Union[str, int]] = None,
852
905
  payload: Optional[Dict[str, Any]] = None,
853
906
  user_id: Optional[str] = None,
854
- check_cycle: Union[bool, List[str]] = False
907
+ check_cycle: Union[bool, List[str]] = False,
908
+ space: Optional[str] = "default"
855
909
  ) -> Edge:
856
910
  """Add a new edge. Raises TableExistsError if it already exists."""
857
911
  self._validate_identifier(table_name)
912
+ if space == RESERVED_SPACE_ALL:
913
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
858
914
  payload_json = json.dumps(payload or {})
859
915
  table_ref = self._get_table_ref(table_name, realm)
916
+ eff_space = space or "default"
860
917
 
861
918
  async def _op(conn):
862
919
  nonlocal edge_id
@@ -896,14 +953,14 @@ class SQLAlchemyPostGraph:
896
953
  )
897
954
 
898
955
  query = f"""
899
- INSERT INTO {table_ref} (realm, id, from_id, to_id, relation_type, payload)
900
- VALUES (:realm, :id, :from_id, :to_id, :relation_type, CAST(:payload AS JSONB))
901
- RETURNING realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
956
+ INSERT INTO {table_ref} (realm, id, space, from_id, to_id, relation_type, payload)
957
+ VALUES (:realm, :id, :space, :from_id, :to_id, :relation_type, CAST(:payload AS JSONB))
958
+ RETURNING realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
902
959
  """
903
960
  try:
904
961
  row = await self._fetchrow(
905
962
  conn, query,
906
- realm=realm, id=e_id_int, from_id=from_id_int, to_id=to_id_int,
963
+ realm=realm, id=e_id_int, space=eff_space, from_id=from_id_int, to_id=to_id_int,
907
964
  relation_type=relation_type, payload=payload_json
908
965
  )
909
966
  if edge_id is not None:
@@ -917,6 +974,7 @@ class SQLAlchemyPostGraph:
917
974
  from_id=str(row['from_id']),
918
975
  to_id=str(row['to_id']),
919
976
  relation_type=row['relation_type'],
977
+ space=row.get('space') or 'default',
920
978
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
921
979
  created_at=row['created_at'],
922
980
  updated_at=row['updated_at'],
@@ -949,12 +1007,16 @@ class SQLAlchemyPostGraph:
949
1007
  edge_id: Optional[Union[str, int]] = None,
950
1008
  payload: Optional[Dict[str, Any]] = None,
951
1009
  user_id: Optional[str] = None,
952
- check_cycle: Union[bool, List[str]] = False
1010
+ check_cycle: Union[bool, List[str]] = False,
1011
+ space: Optional[str] = "default"
953
1012
  ) -> Edge:
954
1013
  """Upsert an edge (merges payload JSONB on conflict)."""
955
1014
  self._validate_identifier(table_name)
1015
+ if space == RESERVED_SPACE_ALL:
1016
+ raise ReservedSpaceError(f"'{RESERVED_SPACE_ALL}' is a reserved space name and cannot be used for creation. It is only valid as a query-time filter.")
956
1017
  payload_json = json.dumps(payload or {})
957
1018
  table_ref = self._get_table_ref(table_name, realm)
1019
+ eff_space = space or "default"
958
1020
 
959
1021
  async def _op(conn):
960
1022
  nonlocal edge_id
@@ -992,20 +1054,21 @@ class SQLAlchemyPostGraph:
992
1054
  raise CyclicReferenceError(f"Adding edge from '{from_id}' to '{to_id}' in table '{table_name}' would create a cycle.")
993
1055
 
994
1056
  query = f"""
995
- INSERT INTO {table_ref} (realm, id, from_id, to_id, relation_type, payload)
996
- VALUES (:realm, :id, :from_id, :to_id, :relation_type, CAST(:payload AS JSONB))
1057
+ INSERT INTO {table_ref} (realm, id, space, from_id, to_id, relation_type, payload)
1058
+ VALUES (:realm, :id, :space, :from_id, :to_id, :relation_type, CAST(:payload AS JSONB))
997
1059
  ON CONFLICT (realm, id) DO UPDATE
998
- SET from_id = EXCLUDED.from_id,
1060
+ SET space = EXCLUDED.space,
1061
+ from_id = EXCLUDED.from_id,
999
1062
  to_id = EXCLUDED.to_id,
1000
1063
  relation_type = EXCLUDED.relation_type,
1001
1064
  payload = {table_ref}.payload || EXCLUDED.payload,
1002
1065
  updated_at = CURRENT_TIMESTAMP
1003
- RETURNING realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1066
+ RETURNING realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1004
1067
  """
1005
1068
  try:
1006
1069
  row = await self._fetchrow(
1007
1070
  conn, query,
1008
- realm=realm, id=e_id_int, from_id=from_id_int, to_id=to_id_int,
1071
+ realm=realm, id=e_id_int, space=eff_space, from_id=from_id_int, to_id=to_id_int,
1009
1072
  relation_type=relation_type, payload=payload_json
1010
1073
  )
1011
1074
  return Edge(
@@ -1015,6 +1078,7 @@ class SQLAlchemyPostGraph:
1015
1078
  from_id=str(row['from_id']),
1016
1079
  to_id=str(row['to_id']),
1017
1080
  relation_type=row['relation_type'],
1081
+ space=row.get('space') or 'default',
1018
1082
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1019
1083
  created_at=row['created_at'],
1020
1084
  updated_at=row['updated_at'],
@@ -1043,11 +1107,11 @@ class SQLAlchemyPostGraph:
1043
1107
 
1044
1108
  table_ref = self._get_table_ref(table_name, realm)
1045
1109
  query = f"""
1046
- SELECT realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1047
- FROM {table_ref}
1110
+ SELECT realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1111
+ FROM {table_ref}
1048
1112
  WHERE realm = :realm AND ((CASE WHEN :id ~ '^[0-9]+$' THEN id = CAST(:id AS BIGINT) ELSE FALSE END) OR fqid = :id)
1049
1113
  """
1050
-
1114
+
1051
1115
  async def _op(conn):
1052
1116
  try:
1053
1117
  row = await self._fetchrow(conn, query, realm=realm, id=e_str)
@@ -1060,6 +1124,7 @@ class SQLAlchemyPostGraph:
1060
1124
  from_id=str(row['from_id']),
1061
1125
  to_id=str(row['to_id']),
1062
1126
  relation_type=row['relation_type'],
1127
+ space=row.get('space') or 'default',
1063
1128
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1064
1129
  created_at=row['created_at'],
1065
1130
  updated_at=row['updated_at'],
@@ -1084,11 +1149,11 @@ class SQLAlchemyPostGraph:
1084
1149
  table_ref = self._get_table_ref(table_name, realm)
1085
1150
  uuid_str = str(uuid).strip()
1086
1151
  query = f"""
1087
- SELECT realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1088
- FROM {table_ref}
1152
+ SELECT realm, id, space, fqid, from_id, to_id, relation_type, payload, created_at, updated_at, uuid::text AS uuid_text
1153
+ FROM {table_ref}
1089
1154
  WHERE realm = :realm AND uuid = CAST(:uuid AS UUID)
1090
1155
  """
1091
-
1156
+
1092
1157
  async def _op(conn):
1093
1158
  try:
1094
1159
  row = await self._fetchrow(conn, query, realm=realm, uuid=uuid_str)
@@ -1101,6 +1166,7 @@ class SQLAlchemyPostGraph:
1101
1166
  from_id=str(row['from_id']),
1102
1167
  to_id=str(row['to_id']),
1103
1168
  relation_type=row['relation_type'],
1169
+ space=row.get('space') or 'default',
1104
1170
  payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1105
1171
  created_at=row['created_at'],
1106
1172
  updated_at=row['updated_at'],
@@ -1252,9 +1318,9 @@ class SQLAlchemyPostGraph:
1252
1318
  queries.append((
1253
1319
  to_ref,
1254
1320
  f"""
1255
- SELECT
1256
- v.id AS v_id, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1257
- e.id AS e_id, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1321
+ SELECT
1322
+ v.id AS v_id, v.space AS v_space, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1323
+ e.id AS e_id, e.space AS e_space, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1258
1324
  FROM {edge_ref} e
1259
1325
  JOIN {to_ref_ref} v ON e.realm = v.realm AND e.to_id = v.id
1260
1326
  WHERE e.realm = :realm AND e.from_id = :vertex_id
@@ -1268,9 +1334,9 @@ class SQLAlchemyPostGraph:
1268
1334
  queries.append((
1269
1335
  from_ref,
1270
1336
  f"""
1271
- SELECT
1272
- v.id AS v_id, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1273
- e.id AS e_id, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1337
+ SELECT
1338
+ v.id AS v_id, v.space AS v_space, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
1339
+ e.id AS e_id, e.space AS e_space, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
1274
1340
  FROM {edge_ref} e
1275
1341
  JOIN {from_ref_ref} v ON e.realm = v.realm AND e.from_id = v.id
1276
1342
  WHERE e.realm = :realm AND e.to_id = :vertex_id
@@ -1283,6 +1349,7 @@ class SQLAlchemyPostGraph:
1283
1349
  v = Vertex(
1284
1350
  realm=realm,
1285
1351
  id=str(r['v_id']),
1352
+ space=r.get('v_space') or 'default',
1286
1353
  fqid=r['v_fqid'],
1287
1354
  payload=r['v_payload'] if isinstance(r['v_payload'], dict) else json.loads(r['v_payload']),
1288
1355
  created_at=r['v_created_at'],
@@ -1297,6 +1364,7 @@ class SQLAlchemyPostGraph:
1297
1364
  from_id=str(r['e_from']),
1298
1365
  to_id=str(r['e_to']),
1299
1366
  relation_type=r['e_rel'],
1367
+ space=r.get('e_space') or 'default',
1300
1368
  payload=r['e_payload'] if isinstance(r['e_payload'], dict) else json.loads(r['e_payload']),
1301
1369
  created_at=r['e_created_at'],
1302
1370
  updated_at=r['e_updated_at'],
@@ -26,3 +26,8 @@ class TableNotFoundError(PostGraphError):
26
26
  class CyclicReferenceError(PostGraphError):
27
27
  """Raised when adding an edge would introduce a cyclic reference in the graph."""
28
28
  pass
29
+
30
+
31
+ class ReservedSpaceError(PostGraphError):
32
+ """Raised when a reserved space name like '__all__' is used in a write operation."""
33
+ pass
@@ -69,13 +69,14 @@ class Vertex:
69
69
  edge_table: str,
70
70
  edge_id: Optional[str] = None,
71
71
  payload: Optional[Dict[str, Any]] = None,
72
- user_id: Optional[str] = None
72
+ user_id: Optional[str] = None,
73
+ space: Optional[str] = None
73
74
  ) -> 'Edge':
74
75
  """Create a new outgoing edge from this vertex to another vertex."""
75
76
  if not self._client:
76
77
  from post_graph.errors import PostGraphError
77
78
  raise PostGraphError("Vertex was not loaded with a database client reference. Edge creation is unavailable.")
78
-
79
+
79
80
  return await self._client.add_edge(
80
81
  table_name=edge_table,
81
82
  realm=self.realm,
@@ -84,7 +85,8 @@ class Vertex:
84
85
  to_id=to_id,
85
86
  relation_type=edge_table, # Default relation_type to the edge table name
86
87
  payload=payload,
87
- user_id=user_id
88
+ user_id=user_id,
89
+ space=space or self.space
88
90
  )
89
91
 
90
92
  async def add_edge_from(
@@ -93,13 +95,14 @@ class Vertex:
93
95
  edge_table: str,
94
96
  edge_id: Optional[str] = None,
95
97
  payload: Optional[Dict[str, Any]] = None,
96
- user_id: Optional[str] = None
98
+ user_id: Optional[str] = None,
99
+ space: Optional[str] = None
97
100
  ) -> 'Edge':
98
101
  """Create a new incoming edge from another vertex to this vertex."""
99
102
  if not self._client:
100
103
  from post_graph.errors import PostGraphError
101
104
  raise PostGraphError("Vertex was not loaded with a database client reference. Edge creation is unavailable.")
102
-
105
+
103
106
  return await self._client.add_edge(
104
107
  table_name=edge_table,
105
108
  realm=self.realm,
@@ -108,7 +111,8 @@ class Vertex:
108
111
  to_id=self.id,
109
112
  relation_type=edge_table,
110
113
  payload=payload,
111
- user_id=user_id
114
+ user_id=user_id,
115
+ space=space or self.space
112
116
  )
113
117
 
114
118
  async def delete(self, user_id: Optional[str] = None) -> bool:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "post-graph"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "High-performance PostgreSQL-backed graph database library supporting multi-tenant realms, space sub-grouping, schema-per-realm, shadow auditing, and append-only data history."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes