cfgit 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.
- cfg/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
cfg/adapters/postgres.py
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Postgres StorageAdapter for cfgit.
|
|
3
|
+
|
|
4
|
+
Runtime tables use the v1 Postgres contract: an id column named by `id_field`,
|
|
5
|
+
optional scalar columns used by `live_when`, and a `doc jsonb` column containing
|
|
6
|
+
the full versioned record.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cfg.adapters.base import (
|
|
16
|
+
AmbiguousConfig,
|
|
17
|
+
ApplyResult,
|
|
18
|
+
AtomicityReport,
|
|
19
|
+
HistoryEnvMismatch,
|
|
20
|
+
NoSuchConfig,
|
|
21
|
+
ReconcileReport,
|
|
22
|
+
StaleHead,
|
|
23
|
+
StaleLive,
|
|
24
|
+
history_env_mismatch_message,
|
|
25
|
+
)
|
|
26
|
+
from cfg.core.config import ProjectConfig
|
|
27
|
+
from cfg.core.hashing import hash_doc
|
|
28
|
+
|
|
29
|
+
try: # pragma: no cover - exercised only when postgres extra is installed
|
|
30
|
+
import psycopg
|
|
31
|
+
from psycopg.rows import dict_row
|
|
32
|
+
from psycopg.types.json import Jsonb
|
|
33
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
34
|
+
raise ModuleNotFoundError("install cfgit[postgres] to use PostgresAdapter") from exc
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PostgresAdapter:
|
|
41
|
+
def __init__(self, *, project: ProjectConfig, env_name: str):
|
|
42
|
+
env = project.envs[env_name]
|
|
43
|
+
if not env.uri:
|
|
44
|
+
raise ValueError(f"missing Postgres URI for env {env_name}")
|
|
45
|
+
self.project = project
|
|
46
|
+
self.env_name = env_name
|
|
47
|
+
self.conn = psycopg.connect(env.uri, autocommit=True, row_factory=dict_row)
|
|
48
|
+
self.history_table_name = project.history.history_collection
|
|
49
|
+
self.heads_table_name = project.history.heads_collection
|
|
50
|
+
self.refs_table_name = project.branches.refs_collection
|
|
51
|
+
self.history_table = _ident(project.history.history_collection)
|
|
52
|
+
self.heads_table = _ident(project.history.heads_collection)
|
|
53
|
+
self.refs_table = _ident(project.branches.refs_collection)
|
|
54
|
+
|
|
55
|
+
def get_record(self, collection: str, record_id: str) -> dict | None:
|
|
56
|
+
with self.conn.cursor() as cur:
|
|
57
|
+
cur.execute(
|
|
58
|
+
f"SELECT doc FROM {_ident(collection)} WHERE {self._runtime_where(collection)} LIMIT 2",
|
|
59
|
+
self._runtime_params(collection, record_id),
|
|
60
|
+
)
|
|
61
|
+
rows = cur.fetchall()
|
|
62
|
+
if len(rows) > 1:
|
|
63
|
+
raise AmbiguousConfig(f"{collection}:{record_id}")
|
|
64
|
+
return dict(rows[0]["doc"]) if rows else None
|
|
65
|
+
|
|
66
|
+
def put_record(self, collection: str, record_id: str, doc: dict) -> None:
|
|
67
|
+
with self.conn.transaction():
|
|
68
|
+
self._put_record(collection, record_id, doc)
|
|
69
|
+
|
|
70
|
+
def seed_record(self, collection: str, record_id: str, doc: dict) -> None:
|
|
71
|
+
if self.get_record(collection, record_id):
|
|
72
|
+
raise AmbiguousConfig(f"{collection}:{record_id}")
|
|
73
|
+
self._seed_record(collection, record_id, doc)
|
|
74
|
+
|
|
75
|
+
def _seed_record(self, collection: str, record_id: str, doc: dict) -> None:
|
|
76
|
+
coll = self.project.collection(collection)
|
|
77
|
+
columns = [_ident(coll.id_field)]
|
|
78
|
+
values: list[Any] = [record_id]
|
|
79
|
+
for key, configured_value in coll.live_when.items():
|
|
80
|
+
columns.append(_ident(key))
|
|
81
|
+
values.append(configured_value)
|
|
82
|
+
columns.append("doc")
|
|
83
|
+
values.append(Jsonb(_jsonable(self._runtime_doc(collection, record_id, doc))))
|
|
84
|
+
placeholders = ", ".join(["%s"] * len(values))
|
|
85
|
+
with self.conn.cursor() as cur:
|
|
86
|
+
cur.execute(
|
|
87
|
+
f"INSERT INTO {_ident(collection)} ({', '.join(columns)}) VALUES ({placeholders})",
|
|
88
|
+
values,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def list_record_ids(self, collection: str) -> list[str]:
|
|
92
|
+
coll = self.project.collection(collection)
|
|
93
|
+
sql = (
|
|
94
|
+
f"SELECT DISTINCT {_ident(coll.id_field)} AS record_id "
|
|
95
|
+
f"FROM {_ident(collection)} WHERE {self._live_where(collection)} ORDER BY 1"
|
|
96
|
+
)
|
|
97
|
+
with self.conn.cursor() as cur:
|
|
98
|
+
cur.execute(sql, self._live_params(collection))
|
|
99
|
+
return [str(row["record_id"]) for row in cur.fetchall() if row["record_id"] is not None]
|
|
100
|
+
|
|
101
|
+
def get_head(self, collection: str, record_id: str) -> dict | None:
|
|
102
|
+
with self.conn.cursor() as cur:
|
|
103
|
+
cur.execute(
|
|
104
|
+
f"""
|
|
105
|
+
SELECT h.*
|
|
106
|
+
FROM {self.history_table} h
|
|
107
|
+
JOIN {self.heads_table} p
|
|
108
|
+
ON p.env = h.env
|
|
109
|
+
AND p.collection_name = h.collection_name
|
|
110
|
+
AND p.record_id = h.record_id
|
|
111
|
+
AND p.head_seq = h.seq
|
|
112
|
+
WHERE p.env = %s AND p.collection_name = %s AND p.record_id = %s
|
|
113
|
+
""",
|
|
114
|
+
[self.env_name, collection, record_id],
|
|
115
|
+
)
|
|
116
|
+
row = cur.fetchone()
|
|
117
|
+
return _history_row(row) if row else None
|
|
118
|
+
|
|
119
|
+
def query_history(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
collection: str | None = None,
|
|
123
|
+
record_id: str | None = None,
|
|
124
|
+
ref: str | None = None,
|
|
125
|
+
as_of_recorded: datetime | None = None,
|
|
126
|
+
as_of_valid: datetime | None = None,
|
|
127
|
+
tag: str | None = None,
|
|
128
|
+
git_sha: str | None = None,
|
|
129
|
+
limit: int | None = None,
|
|
130
|
+
order: str = "desc",
|
|
131
|
+
with_doc: bool = False,
|
|
132
|
+
) -> list[dict]:
|
|
133
|
+
clauses = ["env = %s"]
|
|
134
|
+
params: list[Any] = [self.env_name]
|
|
135
|
+
envless_clauses: list[str] = []
|
|
136
|
+
envless_params: list[Any] = []
|
|
137
|
+
|
|
138
|
+
def add_clause(clause: str, *values: Any) -> None:
|
|
139
|
+
clauses.append(clause)
|
|
140
|
+
params.extend(values)
|
|
141
|
+
envless_clauses.append(clause)
|
|
142
|
+
envless_params.extend(values)
|
|
143
|
+
|
|
144
|
+
if collection is not None:
|
|
145
|
+
add_clause("collection_name = %s", collection)
|
|
146
|
+
if record_id is not None:
|
|
147
|
+
add_clause("record_id = %s", record_id)
|
|
148
|
+
if tag is not None:
|
|
149
|
+
add_clause("%s = ANY(tags)", tag)
|
|
150
|
+
if git_sha is not None:
|
|
151
|
+
add_clause("%s = ANY(git_shas)", git_sha)
|
|
152
|
+
if as_of_recorded is not None:
|
|
153
|
+
add_clause("recorded_at <= %s", as_of_recorded)
|
|
154
|
+
if as_of_valid is not None:
|
|
155
|
+
add_clause("valid_from <= %s", as_of_valid)
|
|
156
|
+
add_clause("(valid_to IS NULL OR valid_to > %s)", as_of_valid)
|
|
157
|
+
if ref is not None:
|
|
158
|
+
if ref.startswith("@"):
|
|
159
|
+
add_clause("seq = %s", int(ref[1:]))
|
|
160
|
+
else:
|
|
161
|
+
oid = ref.removeprefix("sha256:").removeprefix("#")
|
|
162
|
+
add_clause("oid LIKE %s", f"{oid}%")
|
|
163
|
+
|
|
164
|
+
direction = "DESC" if order == "desc" else "ASC"
|
|
165
|
+
sql = (
|
|
166
|
+
f"SELECT * FROM {self.history_table} "
|
|
167
|
+
f"WHERE {' AND '.join(clauses)} "
|
|
168
|
+
f"ORDER BY collection_name ASC, record_id ASC, seq {direction}"
|
|
169
|
+
)
|
|
170
|
+
if limit is not None:
|
|
171
|
+
sql += " LIMIT %s"
|
|
172
|
+
params.append(limit)
|
|
173
|
+
with self.conn.cursor() as cur:
|
|
174
|
+
cur.execute(sql, params)
|
|
175
|
+
rows = cur.fetchall()
|
|
176
|
+
result = [_history_row(row, with_doc=with_doc) for row in rows]
|
|
177
|
+
if not result and limit != 0 and collection is not None and record_id is not None:
|
|
178
|
+
self._raise_env_mismatch_if_history_exists(
|
|
179
|
+
collection,
|
|
180
|
+
record_id,
|
|
181
|
+
envless_clauses=envless_clauses,
|
|
182
|
+
envless_params=envless_params,
|
|
183
|
+
)
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
def list_tags(self) -> list[dict]:
|
|
187
|
+
with self.conn.cursor() as cur:
|
|
188
|
+
cur.execute(
|
|
189
|
+
f"""
|
|
190
|
+
SELECT tag, count(*) AS count
|
|
191
|
+
FROM {self.history_table}, unnest(tags) AS tag
|
|
192
|
+
WHERE env = %s
|
|
193
|
+
GROUP BY tag
|
|
194
|
+
ORDER BY tag
|
|
195
|
+
""",
|
|
196
|
+
[self.env_name],
|
|
197
|
+
)
|
|
198
|
+
return [dict(row) for row in cur.fetchall()]
|
|
199
|
+
|
|
200
|
+
def put_ref(self, doc: dict) -> None:
|
|
201
|
+
stored = dict(doc)
|
|
202
|
+
stored["env"] = self.env_name
|
|
203
|
+
stored["id"] = str(stored["id"])
|
|
204
|
+
stored["type"] = str(stored["type"])
|
|
205
|
+
with self.conn.cursor() as cur:
|
|
206
|
+
cur.execute(
|
|
207
|
+
f"""
|
|
208
|
+
INSERT INTO {self.refs_table}
|
|
209
|
+
(env, type, id, branch, status, created_at, updated_at, doc)
|
|
210
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
211
|
+
ON CONFLICT (env, type, id)
|
|
212
|
+
DO UPDATE SET
|
|
213
|
+
branch = EXCLUDED.branch,
|
|
214
|
+
status = EXCLUDED.status,
|
|
215
|
+
updated_at = EXCLUDED.updated_at,
|
|
216
|
+
doc = EXCLUDED.doc
|
|
217
|
+
""",
|
|
218
|
+
[
|
|
219
|
+
self.env_name,
|
|
220
|
+
stored["type"],
|
|
221
|
+
stored["id"],
|
|
222
|
+
stored.get("branch") or stored.get("head_branch") or stored.get("name"),
|
|
223
|
+
stored.get("status"),
|
|
224
|
+
stored.get("created_at") or self.now(),
|
|
225
|
+
stored.get("updated_at") or stored.get("created_at") or self.now(),
|
|
226
|
+
Jsonb(_jsonable(stored)),
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def get_ref(self, ref_type: str, ref_id: str) -> dict | None:
|
|
231
|
+
with self.conn.cursor() as cur:
|
|
232
|
+
cur.execute(
|
|
233
|
+
f"SELECT doc FROM {self.refs_table} WHERE env = %s AND type = %s AND id = %s",
|
|
234
|
+
[self.env_name, ref_type, ref_id],
|
|
235
|
+
)
|
|
236
|
+
row = cur.fetchone()
|
|
237
|
+
return dict(row["doc"]) if row else None
|
|
238
|
+
|
|
239
|
+
def list_refs(self, ref_type: str, **filters) -> list[dict]:
|
|
240
|
+
clauses = ["env = %s", "type = %s"]
|
|
241
|
+
params: list[Any] = [self.env_name, ref_type]
|
|
242
|
+
for key, value in filters.items():
|
|
243
|
+
if value is None:
|
|
244
|
+
continue
|
|
245
|
+
clauses.append("doc ->> %s = %s")
|
|
246
|
+
params.extend([key, str(value)])
|
|
247
|
+
with self.conn.cursor() as cur:
|
|
248
|
+
cur.execute(
|
|
249
|
+
f"SELECT doc FROM {self.refs_table} WHERE {' AND '.join(clauses)} ORDER BY created_at ASC, id ASC",
|
|
250
|
+
params,
|
|
251
|
+
)
|
|
252
|
+
return [dict(row["doc"]) for row in cur.fetchall()]
|
|
253
|
+
|
|
254
|
+
def delete_ref(self, ref_type: str, ref_id: str) -> None:
|
|
255
|
+
with self.conn.cursor() as cur:
|
|
256
|
+
cur.execute(
|
|
257
|
+
f"DELETE FROM {self.refs_table} WHERE env = %s AND type = %s AND id = %s",
|
|
258
|
+
[self.env_name, ref_type, ref_id],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def apply(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
collection: str,
|
|
265
|
+
record_id: str,
|
|
266
|
+
new_doc: dict | None,
|
|
267
|
+
entry: dict,
|
|
268
|
+
expected_head_oid: str | None,
|
|
269
|
+
expected_live_oid: str | None = None,
|
|
270
|
+
make_head: bool = True,
|
|
271
|
+
seed_missing: bool = False,
|
|
272
|
+
) -> ApplyResult:
|
|
273
|
+
for attempt in range(3):
|
|
274
|
+
try:
|
|
275
|
+
return self._apply_once(
|
|
276
|
+
collection=collection,
|
|
277
|
+
record_id=record_id,
|
|
278
|
+
new_doc=new_doc,
|
|
279
|
+
entry=entry,
|
|
280
|
+
expected_head_oid=expected_head_oid,
|
|
281
|
+
expected_live_oid=expected_live_oid,
|
|
282
|
+
make_head=make_head,
|
|
283
|
+
seed_missing=seed_missing,
|
|
284
|
+
)
|
|
285
|
+
except psycopg.Error as exc:
|
|
286
|
+
if attempt >= 2 or getattr(exc, "sqlstate", None) not in {"40001", "40P01"}:
|
|
287
|
+
raise
|
|
288
|
+
raise RuntimeError("unreachable")
|
|
289
|
+
|
|
290
|
+
def _apply_once(
|
|
291
|
+
self,
|
|
292
|
+
*,
|
|
293
|
+
collection: str,
|
|
294
|
+
record_id: str,
|
|
295
|
+
new_doc: dict | None,
|
|
296
|
+
entry: dict,
|
|
297
|
+
expected_head_oid: str | None,
|
|
298
|
+
expected_live_oid: str | None,
|
|
299
|
+
make_head: bool,
|
|
300
|
+
seed_missing: bool,
|
|
301
|
+
) -> ApplyResult:
|
|
302
|
+
coll_cfg = self.project.collection(collection)
|
|
303
|
+
with self.conn.transaction():
|
|
304
|
+
with self.conn.cursor() as cur:
|
|
305
|
+
cur.execute(
|
|
306
|
+
f"""
|
|
307
|
+
SELECT * FROM {self.heads_table}
|
|
308
|
+
WHERE env = %s AND collection_name = %s AND record_id = %s
|
|
309
|
+
FOR UPDATE
|
|
310
|
+
""",
|
|
311
|
+
[self.env_name, collection, record_id],
|
|
312
|
+
)
|
|
313
|
+
ptr = cur.fetchone()
|
|
314
|
+
current_head = ptr["head_oid"] if ptr else None
|
|
315
|
+
if current_head != expected_head_oid:
|
|
316
|
+
raise StaleHead(current_head)
|
|
317
|
+
|
|
318
|
+
if expected_live_oid is not None:
|
|
319
|
+
live = self._get_record_for_update(collection, record_id)
|
|
320
|
+
if live is None:
|
|
321
|
+
raise NoSuchConfig(f"{collection}:{record_id}")
|
|
322
|
+
live_oid = hash_doc(live, coll_cfg)
|
|
323
|
+
if live_oid != expected_live_oid:
|
|
324
|
+
raise StaleLive(live_oid)
|
|
325
|
+
|
|
326
|
+
seq = int(ptr["head_seq"]) + 1 if ptr else 1
|
|
327
|
+
entry = dict(entry)
|
|
328
|
+
entry.update(
|
|
329
|
+
{
|
|
330
|
+
"env": self.env_name,
|
|
331
|
+
"collection": collection,
|
|
332
|
+
"record_id": record_id,
|
|
333
|
+
"seq": seq,
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
self._insert_history(entry)
|
|
337
|
+
|
|
338
|
+
if current_head:
|
|
339
|
+
cur.execute(
|
|
340
|
+
f"""
|
|
341
|
+
UPDATE {self.history_table}
|
|
342
|
+
SET valid_to = %s
|
|
343
|
+
WHERE env = %s
|
|
344
|
+
AND collection_name = %s
|
|
345
|
+
AND record_id = %s
|
|
346
|
+
AND seq = %s
|
|
347
|
+
AND valid_to IS NULL
|
|
348
|
+
""",
|
|
349
|
+
[entry["valid_from"], self.env_name, collection, record_id, ptr["head_seq"]],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if new_doc is not None:
|
|
353
|
+
if seed_missing:
|
|
354
|
+
if self._get_record_for_update(collection, record_id) is not None:
|
|
355
|
+
raise StaleLive(f"{collection}:{record_id} reappeared before restore")
|
|
356
|
+
self._seed_record(collection, record_id, new_doc)
|
|
357
|
+
else:
|
|
358
|
+
self._put_record(collection, record_id, new_doc)
|
|
359
|
+
|
|
360
|
+
if make_head:
|
|
361
|
+
cur.execute(
|
|
362
|
+
f"""
|
|
363
|
+
INSERT INTO {self.heads_table}
|
|
364
|
+
(env, collection_name, record_id, head_oid, head_seq, updated_at)
|
|
365
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
366
|
+
ON CONFLICT (env, collection_name, record_id)
|
|
367
|
+
DO UPDATE SET
|
|
368
|
+
head_oid = EXCLUDED.head_oid,
|
|
369
|
+
head_seq = EXCLUDED.head_seq,
|
|
370
|
+
updated_at = EXCLUDED.updated_at
|
|
371
|
+
""",
|
|
372
|
+
[
|
|
373
|
+
self.env_name,
|
|
374
|
+
collection,
|
|
375
|
+
record_id,
|
|
376
|
+
entry["oid"],
|
|
377
|
+
seq,
|
|
378
|
+
entry["recorded_at"],
|
|
379
|
+
],
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return ApplyResult(
|
|
383
|
+
collection=collection,
|
|
384
|
+
record_id=record_id,
|
|
385
|
+
seq=seq,
|
|
386
|
+
oid=entry["oid"],
|
|
387
|
+
head_oid=entry["oid"],
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def add_tag(self, *, collection: str, record_id: str, seq: int, tag: str) -> None:
|
|
391
|
+
with self.conn.cursor() as cur:
|
|
392
|
+
cur.execute(
|
|
393
|
+
f"""
|
|
394
|
+
UPDATE {self.history_table}
|
|
395
|
+
SET tags = CASE WHEN %s = ANY(tags) THEN tags ELSE array_append(tags, %s) END
|
|
396
|
+
WHERE env = %s AND collection_name = %s AND record_id = %s AND seq = %s
|
|
397
|
+
""",
|
|
398
|
+
[tag, tag, self.env_name, collection, record_id, seq],
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def remove_tag(self, *, collection: str, record_id: str, seq: int, tag: str) -> None:
|
|
402
|
+
with self.conn.cursor() as cur:
|
|
403
|
+
cur.execute(
|
|
404
|
+
f"""
|
|
405
|
+
UPDATE {self.history_table}
|
|
406
|
+
SET tags = array_remove(tags, %s)
|
|
407
|
+
WHERE env = %s AND collection_name = %s AND record_id = %s AND seq = %s
|
|
408
|
+
""",
|
|
409
|
+
[tag, self.env_name, collection, record_id, seq],
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def list_pending(self) -> list[dict]:
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
def reconcile(self) -> ReconcileReport:
|
|
416
|
+
return ReconcileReport(rolled_forward=[], rolled_back=[])
|
|
417
|
+
|
|
418
|
+
def ensure_schema(self) -> None:
|
|
419
|
+
with self.conn.cursor() as cur:
|
|
420
|
+
cur.execute(
|
|
421
|
+
f"""
|
|
422
|
+
CREATE TABLE IF NOT EXISTS {self.history_table} (
|
|
423
|
+
env text NOT NULL,
|
|
424
|
+
collection_name text NOT NULL,
|
|
425
|
+
record_id text NOT NULL,
|
|
426
|
+
seq bigint NOT NULL,
|
|
427
|
+
oid text NOT NULL,
|
|
428
|
+
parent_oid text,
|
|
429
|
+
doc jsonb NOT NULL,
|
|
430
|
+
message text NOT NULL,
|
|
431
|
+
author text NOT NULL,
|
|
432
|
+
recorded_at timestamptz NOT NULL,
|
|
433
|
+
valid_from timestamptz NOT NULL,
|
|
434
|
+
valid_to timestamptz,
|
|
435
|
+
valid_from_estimated boolean NOT NULL DEFAULT false,
|
|
436
|
+
op text NOT NULL,
|
|
437
|
+
git_shas text[] NOT NULL DEFAULT '{{}}',
|
|
438
|
+
tags text[] NOT NULL DEFAULT '{{}}',
|
|
439
|
+
meta jsonb NOT NULL DEFAULT '{{}}',
|
|
440
|
+
PRIMARY KEY (env, collection_name, record_id, seq)
|
|
441
|
+
)
|
|
442
|
+
"""
|
|
443
|
+
)
|
|
444
|
+
cur.execute(
|
|
445
|
+
f"""
|
|
446
|
+
CREATE TABLE IF NOT EXISTS {self.heads_table} (
|
|
447
|
+
env text NOT NULL,
|
|
448
|
+
collection_name text NOT NULL,
|
|
449
|
+
record_id text NOT NULL,
|
|
450
|
+
head_oid text NOT NULL,
|
|
451
|
+
head_seq bigint NOT NULL,
|
|
452
|
+
updated_at timestamptz NOT NULL,
|
|
453
|
+
PRIMARY KEY (env, collection_name, record_id)
|
|
454
|
+
)
|
|
455
|
+
"""
|
|
456
|
+
)
|
|
457
|
+
cur.execute(
|
|
458
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.history_table_name + '_oid_idx')} "
|
|
459
|
+
f"ON {self.history_table} (env, collection_name, record_id, oid)"
|
|
460
|
+
)
|
|
461
|
+
cur.execute(
|
|
462
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.history_table_name + '_recorded_idx')} "
|
|
463
|
+
f"ON {self.history_table} (env, recorded_at)"
|
|
464
|
+
)
|
|
465
|
+
cur.execute(
|
|
466
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.history_table_name + '_valid_from_idx')} "
|
|
467
|
+
f"ON {self.history_table} (env, valid_from)"
|
|
468
|
+
)
|
|
469
|
+
cur.execute(
|
|
470
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.history_table_name + '_valid_to_idx')} "
|
|
471
|
+
f"ON {self.history_table} (env, valid_to)"
|
|
472
|
+
)
|
|
473
|
+
cur.execute(
|
|
474
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.history_table_name + '_tags_idx')} "
|
|
475
|
+
f"ON {self.history_table} USING GIN (tags)"
|
|
476
|
+
)
|
|
477
|
+
if self.project.branches.enabled:
|
|
478
|
+
cur.execute(
|
|
479
|
+
f"""
|
|
480
|
+
CREATE TABLE IF NOT EXISTS {self.refs_table} (
|
|
481
|
+
env text NOT NULL,
|
|
482
|
+
type text NOT NULL,
|
|
483
|
+
id text NOT NULL,
|
|
484
|
+
branch text,
|
|
485
|
+
status text,
|
|
486
|
+
created_at timestamptz NOT NULL,
|
|
487
|
+
updated_at timestamptz NOT NULL,
|
|
488
|
+
doc jsonb NOT NULL,
|
|
489
|
+
PRIMARY KEY (env, type, id)
|
|
490
|
+
)
|
|
491
|
+
"""
|
|
492
|
+
)
|
|
493
|
+
cur.execute(
|
|
494
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.refs_table_name + '_branch_idx')} "
|
|
495
|
+
f"ON {self.refs_table} (env, type, branch, created_at)"
|
|
496
|
+
)
|
|
497
|
+
cur.execute(
|
|
498
|
+
f"CREATE INDEX IF NOT EXISTS {_ident(self.refs_table_name + '_status_idx')} "
|
|
499
|
+
f"ON {self.refs_table} (env, type, status, updated_at)"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def check_runtime_invariant(self, collection: str | None = None) -> list[str]:
|
|
503
|
+
names = [collection] if collection else [c.name for c in self.project.collections]
|
|
504
|
+
violations: list[str] = []
|
|
505
|
+
for name in names:
|
|
506
|
+
coll = self.project.collection(name)
|
|
507
|
+
sql = (
|
|
508
|
+
f"SELECT {_ident(coll.id_field)} AS record_id, count(*) AS n "
|
|
509
|
+
f"FROM {_ident(name)} WHERE {self._live_where(name)} "
|
|
510
|
+
f"GROUP BY {_ident(coll.id_field)} HAVING count(*) > 1 ORDER BY 1"
|
|
511
|
+
)
|
|
512
|
+
with self.conn.cursor() as cur:
|
|
513
|
+
cur.execute(sql, self._live_params(name))
|
|
514
|
+
for row in cur.fetchall():
|
|
515
|
+
violations.append(f"{name}:{row['record_id']} ({row['n']} live records)")
|
|
516
|
+
return violations
|
|
517
|
+
|
|
518
|
+
def check_atomicity_scope(self) -> AtomicityReport:
|
|
519
|
+
return AtomicityReport(
|
|
520
|
+
atomic=True,
|
|
521
|
+
runtime_cluster=self._server_name(),
|
|
522
|
+
history_cluster=self._server_name(),
|
|
523
|
+
reason="ok",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def backend_name(self) -> str:
|
|
527
|
+
return "postgres"
|
|
528
|
+
|
|
529
|
+
def supports_transactions(self) -> bool:
|
|
530
|
+
return True
|
|
531
|
+
|
|
532
|
+
def authenticated_principal(self) -> str | None:
|
|
533
|
+
with self.conn.cursor() as cur:
|
|
534
|
+
cur.execute("SELECT current_user AS principal")
|
|
535
|
+
row = cur.fetchone()
|
|
536
|
+
principal = str(row["principal"] or "").strip()
|
|
537
|
+
return principal or None
|
|
538
|
+
|
|
539
|
+
def now(self) -> datetime:
|
|
540
|
+
return datetime.now(timezone.utc)
|
|
541
|
+
|
|
542
|
+
def _insert_history(self, entry: dict[str, Any]) -> None:
|
|
543
|
+
with self.conn.cursor() as cur:
|
|
544
|
+
cur.execute(
|
|
545
|
+
f"""
|
|
546
|
+
INSERT INTO {self.history_table} (
|
|
547
|
+
env, collection_name, record_id, seq, oid, parent_oid, doc, message,
|
|
548
|
+
author, recorded_at, valid_from, valid_to, valid_from_estimated,
|
|
549
|
+
op, git_shas, tags, meta
|
|
550
|
+
)
|
|
551
|
+
VALUES (
|
|
552
|
+
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
|
553
|
+
)
|
|
554
|
+
""",
|
|
555
|
+
[
|
|
556
|
+
entry["env"],
|
|
557
|
+
entry["collection"],
|
|
558
|
+
entry["record_id"],
|
|
559
|
+
entry["seq"],
|
|
560
|
+
entry["oid"],
|
|
561
|
+
entry.get("parent_oid"),
|
|
562
|
+
Jsonb(_jsonable(entry["doc"])),
|
|
563
|
+
entry["message"],
|
|
564
|
+
entry["author"],
|
|
565
|
+
entry["recorded_at"],
|
|
566
|
+
entry["valid_from"],
|
|
567
|
+
entry.get("valid_to"),
|
|
568
|
+
bool(entry.get("valid_from_estimated", False)),
|
|
569
|
+
entry["op"],
|
|
570
|
+
list(entry.get("git_shas") or []),
|
|
571
|
+
list(entry.get("tags") or []),
|
|
572
|
+
Jsonb(_jsonable(entry.get("meta") or {})),
|
|
573
|
+
],
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _get_record_for_update(self, collection: str, record_id: str) -> dict | None:
|
|
577
|
+
with self.conn.cursor() as cur:
|
|
578
|
+
cur.execute(
|
|
579
|
+
f"SELECT doc FROM {_ident(collection)} WHERE {self._runtime_where(collection)} FOR UPDATE",
|
|
580
|
+
self._runtime_params(collection, record_id),
|
|
581
|
+
)
|
|
582
|
+
rows = cur.fetchall()
|
|
583
|
+
if len(rows) > 1:
|
|
584
|
+
raise AmbiguousConfig(f"{collection}:{record_id}")
|
|
585
|
+
return dict(rows[0]["doc"]) if rows else None
|
|
586
|
+
|
|
587
|
+
def _put_record(self, collection: str, record_id: str, doc: dict) -> None:
|
|
588
|
+
coll = self.project.collection(collection)
|
|
589
|
+
current = self._get_record_for_update(collection, record_id)
|
|
590
|
+
if current is None:
|
|
591
|
+
raise NoSuchConfig(f"{collection}:{record_id}")
|
|
592
|
+
|
|
593
|
+
effective = self._runtime_doc(collection, record_id, doc)
|
|
594
|
+
for path in coll.secret_fields:
|
|
595
|
+
if _get_path(effective, path) is None:
|
|
596
|
+
secret_value = _get_path(current, path)
|
|
597
|
+
if secret_value is not None:
|
|
598
|
+
_set_path(effective, path, secret_value)
|
|
599
|
+
|
|
600
|
+
set_parts = ["doc = %s", f"{_ident(coll.id_field)} = %s"]
|
|
601
|
+
params: list[Any] = [Jsonb(_jsonable(effective)), record_id]
|
|
602
|
+
for key, configured_value in coll.live_when.items():
|
|
603
|
+
set_parts.append(f"{_ident(key)} = %s")
|
|
604
|
+
params.append(effective.get(key, configured_value))
|
|
605
|
+
params.extend(self._runtime_params(collection, record_id))
|
|
606
|
+
|
|
607
|
+
with self.conn.cursor() as cur:
|
|
608
|
+
cur.execute(
|
|
609
|
+
f"UPDATE {_ident(collection)} SET {', '.join(set_parts)} "
|
|
610
|
+
f"WHERE {self._runtime_where(collection)}",
|
|
611
|
+
params,
|
|
612
|
+
)
|
|
613
|
+
if cur.rowcount == 0:
|
|
614
|
+
raise NoSuchConfig(f"{collection}:{record_id}")
|
|
615
|
+
|
|
616
|
+
def _runtime_doc(self, collection: str, record_id: str, doc: dict) -> dict[str, Any]:
|
|
617
|
+
coll = self.project.collection(collection)
|
|
618
|
+
effective = deepcopy(doc)
|
|
619
|
+
effective[coll.id_field] = record_id
|
|
620
|
+
for key, configured_value in coll.live_when.items():
|
|
621
|
+
effective[key] = configured_value
|
|
622
|
+
return effective
|
|
623
|
+
|
|
624
|
+
def _runtime_where(self, collection: str) -> str:
|
|
625
|
+
coll = self.project.collection(collection)
|
|
626
|
+
return f"{_ident(coll.id_field)} = %s AND {self._live_where(collection)}"
|
|
627
|
+
|
|
628
|
+
def _runtime_params(self, collection: str, record_id: str) -> list[Any]:
|
|
629
|
+
return [record_id, *self._live_params(collection)]
|
|
630
|
+
|
|
631
|
+
def _live_where(self, collection: str) -> str:
|
|
632
|
+
coll = self.project.collection(collection)
|
|
633
|
+
if not coll.live_when:
|
|
634
|
+
return "TRUE"
|
|
635
|
+
parts = []
|
|
636
|
+
for key, value in coll.live_when.items():
|
|
637
|
+
if value is None:
|
|
638
|
+
parts.append(f"{_ident(key)} IS NULL")
|
|
639
|
+
else:
|
|
640
|
+
parts.append(f"{_ident(key)} = %s")
|
|
641
|
+
return " AND ".join(parts)
|
|
642
|
+
|
|
643
|
+
def _live_params(self, collection: str) -> list[Any]:
|
|
644
|
+
coll = self.project.collection(collection)
|
|
645
|
+
return [value for value in coll.live_when.values() if value is not None]
|
|
646
|
+
|
|
647
|
+
def _server_name(self) -> str:
|
|
648
|
+
with self.conn.cursor() as cur:
|
|
649
|
+
cur.execute("SELECT inet_server_addr()::text AS addr, inet_server_port() AS port")
|
|
650
|
+
row = cur.fetchone()
|
|
651
|
+
return f"{row['addr']}:{row['port']}"
|
|
652
|
+
|
|
653
|
+
def _raise_env_mismatch_if_history_exists(
|
|
654
|
+
self,
|
|
655
|
+
collection: str,
|
|
656
|
+
record_id: str,
|
|
657
|
+
*,
|
|
658
|
+
envless_clauses: list[str],
|
|
659
|
+
envless_params: list[Any],
|
|
660
|
+
) -> None:
|
|
661
|
+
envs: set[str] = set()
|
|
662
|
+
where = " AND ".join(envless_clauses)
|
|
663
|
+
with self.conn.cursor() as cur:
|
|
664
|
+
cur.execute(
|
|
665
|
+
f"SELECT DISTINCT env FROM {self.history_table} WHERE {where}",
|
|
666
|
+
envless_params,
|
|
667
|
+
)
|
|
668
|
+
envs.update(str(row["env"]) for row in cur.fetchall() if row.get("env"))
|
|
669
|
+
if envs and self.env_name in envs:
|
|
670
|
+
return
|
|
671
|
+
if envless_clauses == ["collection_name = %s", "record_id = %s"]:
|
|
672
|
+
cur.execute(
|
|
673
|
+
f"""
|
|
674
|
+
SELECT DISTINCT env
|
|
675
|
+
FROM {self.heads_table}
|
|
676
|
+
WHERE collection_name = %s AND record_id = %s
|
|
677
|
+
""",
|
|
678
|
+
[collection, record_id],
|
|
679
|
+
)
|
|
680
|
+
envs.update(str(row["env"]) for row in cur.fetchall() if row.get("env"))
|
|
681
|
+
if self.env_name in envs:
|
|
682
|
+
return
|
|
683
|
+
other_envs = sorted(env for env in envs if env != self.env_name)
|
|
684
|
+
if other_envs:
|
|
685
|
+
raise HistoryEnvMismatch(
|
|
686
|
+
history_env_mismatch_message(
|
|
687
|
+
collection=collection,
|
|
688
|
+
record_id=record_id,
|
|
689
|
+
current_env=self.env_name,
|
|
690
|
+
other_envs=other_envs,
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _history_row(row: dict[str, Any], *, with_doc: bool = True) -> dict[str, Any]:
|
|
696
|
+
out = {
|
|
697
|
+
"env": row["env"],
|
|
698
|
+
"collection": row["collection_name"],
|
|
699
|
+
"record_id": row["record_id"],
|
|
700
|
+
"seq": row["seq"],
|
|
701
|
+
"oid": row["oid"],
|
|
702
|
+
"parent_oid": row["parent_oid"],
|
|
703
|
+
"message": row["message"],
|
|
704
|
+
"author": row["author"],
|
|
705
|
+
"recorded_at": row["recorded_at"],
|
|
706
|
+
"valid_from": row["valid_from"],
|
|
707
|
+
"valid_to": row["valid_to"],
|
|
708
|
+
"valid_from_estimated": row["valid_from_estimated"],
|
|
709
|
+
"op": row["op"],
|
|
710
|
+
"git_shas": list(row["git_shas"] or []),
|
|
711
|
+
"tags": list(row["tags"] or []),
|
|
712
|
+
"meta": dict(row["meta"] or {}),
|
|
713
|
+
}
|
|
714
|
+
if with_doc:
|
|
715
|
+
out["doc"] = dict(row["doc"])
|
|
716
|
+
return out
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _ident(value: str) -> str:
|
|
720
|
+
if not _IDENT_RE.match(value):
|
|
721
|
+
raise ValueError(f"unsafe SQL identifier: {value}")
|
|
722
|
+
return f'"{value}"'
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _jsonable(value: Any) -> Any:
|
|
726
|
+
if isinstance(value, dict):
|
|
727
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
728
|
+
if isinstance(value, (list, tuple)):
|
|
729
|
+
return [_jsonable(v) for v in value]
|
|
730
|
+
if isinstance(value, datetime):
|
|
731
|
+
dt = value
|
|
732
|
+
if dt.tzinfo is None:
|
|
733
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
734
|
+
return dt.astimezone(timezone.utc).isoformat()
|
|
735
|
+
return value
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _get_path(doc: dict[str, Any], dotted: str) -> Any:
|
|
739
|
+
cur: Any = doc
|
|
740
|
+
for part in dotted.split("."):
|
|
741
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
742
|
+
return None
|
|
743
|
+
cur = cur[part]
|
|
744
|
+
return cur
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _set_path(doc: dict[str, Any], dotted: str, value: Any) -> None:
|
|
748
|
+
cur: Any = doc
|
|
749
|
+
parts = dotted.split(".")
|
|
750
|
+
for part in parts[:-1]:
|
|
751
|
+
nxt = cur.get(part)
|
|
752
|
+
if not isinstance(nxt, dict):
|
|
753
|
+
nxt = {}
|
|
754
|
+
cur[part] = nxt
|
|
755
|
+
cur = nxt
|
|
756
|
+
cur[parts[-1]] = value
|