1"""Version storage for designs: microversions + immutable releases.
3Payload-agnostic: it stores and returns whatever JSON dict it is given, which is
4why one module serves an engine design (``{"config": ...}``), a recovery config
5and a P&ID (``{"nodes": ..., "edges": ...}``) alike. The *working copy*
6(``current.json`` on the volume) is owned by the router
7(:mod:`stardesign.documents`); here we only keep version history.
9Two backends, chosen by :func:`make_backend` from the environment:
11* **S3Backend** when the app's bucket variable is set. Microversions ride on S3
12 object *versioning* of a per-document HEAD key
13 (``<prefix>/<user>/<id>/current.json``): every snapshot is a plain
14 ``put_object`` that S3 keeps as a new version, and a lifecycle rule prunes old
15 ones. Releases are separate immutable keys under ``.../releases/<label>.json``.
16 Credentials come from the standard ``AWS_*`` env vars.
18 This is also why designs are *not* stored in a flat, ownerless tree: the
19 microversion history of a design IS the S3 version history of one key, so
20 re-keying a design to a new owner-less path would flatten it.
22* **LocalBackend** otherwise (dev, and prod with no bucket set). Microversions
23 and releases are files on disk under the ``USERDATA_DIR`` tree, so history
27from __future__
import annotations
34from datetime
import datetime, timezone
35from pathlib
import Path
40Snapshot = dict[str, Any]
44 return datetime.now(timezone.utc).isoformat()
50 path.parent.mkdir(parents=
True, exist_ok=
True)
51 fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=
".tmp-", suffix=
".json")
53 with os.fdopen(fd,
"w", encoding=
"utf-8")
as fh:
54 json.dump(data, fh, indent=2)
58 if os.path.exists(tmp):
64 if not path.is_file():
67 return json.loads(path.read_text(
"utf-8"))
68 except (OSError, ValueError):
73 """Filesystem version history under ``<root>/<user>/<app>/<id>/``."""
78 def _doc_dir(self, user: str, doc_id: str) -> Path:
79 return self.
_ud.user_dir(user) / doc_id
81 def _sub(self, user: str, doc_id: str, name: str) -> Path:
82 d = self.
_doc_dir(user, doc_id) / name
83 d.mkdir(parents=
True, exist_ok=
True)
95 vid = f
"{time.time_ns():016x}{secrets.token_hex(4)}"
97 return {
"versionId": vid,
"savedAt":
_now_iso()}
99 def list_micro(self, user: str, doc_id: str, limit: int = 50) -> list[dict]:
100 """Snapshots newest first.
102 Sorted on ``st_mtime_ns`` then the id, not on the formatted ``savedAt``:
103 two snapshots written in the same filesystem timestamp tick otherwise
104 come back in whatever order ``glob`` happened to yield. The id carries a
105 nanosecond prefix (see :meth:`snapshot_micro`), so it breaks that tie in
106 real write order rather than arbitrarily. Ids written before that prefix
107 existed are fully random, but they are older files whose mtimes already
108 separate them from anything new.
110 rows: list[tuple[int, str, dict]] = []
111 for p
in self.
_sub(user, doc_id,
"versions").glob(
"*.json"):
112 if p.name.startswith(
"."):
115 rows.append((st.st_mtime_ns, p.stem, {
117 "savedAt": datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(),
120 rows.sort(key=
lambda r: (r[0], r[1]), reverse=
True)
121 return [r[2]
for r
in rows[:limit]]
123 def get_micro(self, user: str, doc_id: str, version_id: str) -> Snapshot |
None:
124 safe = slugify(version_id)
127 return _read_json(self.
_sub(user, doc_id,
"versions") / f
"{safe}.json")
130 recent = self.
list_micro(user, doc_id, limit=1)
131 return self.
get_micro(user, doc_id, recent[0][
"versionId"])
if recent
else None
133 def create_release(self, user: str, doc_id: str, label: str, data: Snapshot) -> dict:
134 p = self.
_sub(user, doc_id,
"releases") / f
"{label}.json"
136 raise FileExistsError(label)
138 return {
"label": label,
"savedAt":
_now_iso()}
142 for p
in self.
_sub(user, doc_id,
"releases").glob(
"*.json"):
143 if p.name.startswith(
"."):
148 "savedAt": datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(),
151 out.sort(key=
lambda r: r[
"savedAt"], reverse=
True)
154 def get_release(self, user: str, doc_id: str, label: str) -> Snapshot |
None:
155 safe = slugify(label)
158 return _read_json(self.
_sub(user, doc_id,
"releases") / f
"{safe}.json")
164 """Version history in a versioned S3 bucket. Microversions == object versions."""
166 def __init__(self, ud: UserData, bucket: str, prefix: str) ->
None:
170 self.
_s3 = boto3.client(
"s3")
175 return f
"{self.prefix}/{user}/{doc_id}/current.json"
178 return f
"{self.prefix}/{user}/{doc_id}/releases/"
180 def _put(self, key: str, data: Snapshot) ->
None:
182 Bucket=self.
bucket, Key=key,
183 Body=json.dumps(data, indent=2).encode(
"utf-8"),
184 ContentType=
"application/json",
187 def _get(self, key: str, **kw) -> Snapshot |
None:
188 from botocore.exceptions
import ClientError
190 obj = self.
_s3.get_object(Bucket=self.
bucket, Key=key, **kw)
193 return json.loads(obj[
"Body"].
read())
199 def list_micro(self, user: str, doc_id: str, limit: int = 50) -> list[dict]:
201 resp = self.
_s3.list_object_versions(Bucket=self.
bucket, Prefix=key, MaxKeys=limit)
203 {
"versionId": v[
"VersionId"],
204 "savedAt": v[
"LastModified"].isoformat(),
206 for v
in resp.get(
"Versions", [])
if v[
"Key"] == key
208 out.sort(key=
lambda v: v[
"savedAt"], reverse=
True)
211 def get_micro(self, user: str, doc_id: str, version_id: str) -> Snapshot |
None:
212 return self.
_get(self.
_head_key(user, doc_id), VersionId=version_id)
217 def create_release(self, user: str, doc_id: str, label: str, data: Snapshot) -> dict:
218 from botocore.exceptions
import ClientError
219 key = f
"{self._releases_prefix(user, doc_id)}{label}.json"
221 self.
_s3.head_object(Bucket=self.
bucket, Key=key)
222 except ClientError
as e:
223 if e.response.get(
"Error", {}).get(
"Code")
not in (
"404",
"NoSuchKey",
"NotFound"):
226 raise FileExistsError(label)
228 return {
"label": label,
"savedAt":
_now_iso()}
232 resp = self.
_s3.list_objects_v2(Bucket=self.
bucket, Prefix=prefix)
234 for o
in resp.get(
"Contents", []):
235 name = o[
"Key"][len(prefix):]
236 if name.endswith(
".json"):
238 out.append({
"label": name,
"savedAt": o[
"LastModified"].isoformat(),
"size": o[
"Size"]})
239 out.sort(key=
lambda r: r[
"savedAt"], reverse=
True)
242 def get_release(self, user: str, doc_id: str, label: str) -> Snapshot |
None:
243 return self.
_get(f
"{self._releases_prefix(user, doc_id)}{label}.json")
248def make_backend(ud: UserData, bucket_env: str, prefix_env: str, default_prefix: str):
249 """The version-history backend for one app, chosen from the environment.
251 Called once per app at import time, so a missing bucket means "local files"
252 for the life of the process rather than a per-request branch.
254 bucket = os.environ.get(bucket_env)
256 return S3Backend(ud, bucket, os.environ.get(prefix_env, default_prefix))
list[dict] list_releases(self, str user, str doc_id)
dict snapshot_micro(self, str user, str doc_id, Snapshot data)
dict create_release(self, str user, str doc_id, str label, Snapshot data)
Path _doc_dir(self, str user, str doc_id)
Snapshot|None get_release(self, str user, str doc_id, str label)
Snapshot|None latest_micro(self, str user, str doc_id)
Path _sub(self, str user, str doc_id, str name)
None __init__(self, UserData ud)
list[dict] list_micro(self, str user, str doc_id, int limit=50)
Snapshot|None get_micro(self, str user, str doc_id, str version_id)
Snapshot|None get_release(self, str user, str doc_id, str label)
Snapshot|None _get(self, str key, **kw)
str _head_key(self, str user, str doc_id)
list[dict] list_releases(self, str user, str doc_id)
None _put(self, str key, Snapshot data)
dict create_release(self, str user, str doc_id, str label, Snapshot data)
Snapshot|None get_micro(self, str user, str doc_id, str version_id)
list[dict] list_micro(self, str user, str doc_id, int limit=50)
str _releases_prefix(self, str user, str doc_id)
dict snapshot_micro(self, str user, str doc_id, Snapshot data)
Snapshot|None latest_micro(self, str user, str doc_id)
None __init__(self, UserData ud, str bucket, str prefix)
None _atomic_write(Path path, Snapshot data)
Snapshot|None _read_json(Path path)
make_backend(UserData ud, str bucket_env, str prefix_env, str default_prefix)
static double read(int32_t code)