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.
@@ -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