queryview 0.0.2__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.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/gitsync.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Git sync: per-entity backup/restore of predefined queries and dashboards to
|
|
2
|
+
each workspace's git remote. Operations take a resolved workspaces.WorkspaceRec;
|
|
3
|
+
every workspace has its own clone and lock. Versions are git commits — store
|
|
4
|
+
makes one commit per entity, restore reads objects at a ref (git show) and
|
|
5
|
+
upserts the DB row; HEAD never moves. Docs: docs/gitsync.md, docs/workspace.md."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from .workspaces import WorkspaceRec
|
|
18
|
+
from .yamlio import YamlIOError, dashboard_from_data, dump_yaml, query_from_data, query_to_data, slug
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitSyncError(Exception):
|
|
22
|
+
"""Git-sync failure carrying an HTTP-ish status for the API layer:
|
|
23
|
+
409 unconfigured, 404 entity/ref not found, 502 git or parse failure."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status: int = 502):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Serialization ---------------------------------------------------------
|
|
31
|
+
# The entity <-> mapping codec (and the literal-block dumper and slug) lives
|
|
32
|
+
# in yamlio.py, shared with YAML export/import; this section only owns the
|
|
33
|
+
# repo file layout: query files carry no `type` (the path does), a dashboard
|
|
34
|
+
# splits into meta.yaml / dashboard.html / queries.yaml.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def query_relpath(conn_type: str, name: str) -> str:
|
|
38
|
+
return f"queries/{slug(conn_type)}/{slug(name)}.yaml"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dashboard_reldir(name: str) -> str:
|
|
42
|
+
return f"dashboards/{slug(name)}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def query_to_yaml(row: dict[str, Any]) -> str:
|
|
46
|
+
"""One predefined-query row (as returned by list_predefined_queries) as
|
|
47
|
+
YAML. cell_view stays a verbatim string; order_by/fields are stored in the
|
|
48
|
+
DB as JSON text and exported as parsed YAML values. None keys are omitted."""
|
|
49
|
+
return dump_yaml(query_to_data(row))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def query_from_yaml(text: str) -> dict[str, Any]:
|
|
53
|
+
"""Inverse of query_to_yaml, back to the DB row shape (order_by/fields as
|
|
54
|
+
JSON text or None)."""
|
|
55
|
+
try:
|
|
56
|
+
data = yaml.safe_load(text)
|
|
57
|
+
except yaml.YAMLError as e:
|
|
58
|
+
raise GitSyncError(f"malformed query file: {e}") from e
|
|
59
|
+
try:
|
|
60
|
+
return query_from_data(data)
|
|
61
|
+
except YamlIOError as e:
|
|
62
|
+
raise GitSyncError(str(e)) from e
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def dashboard_to_files(d: dict[str, Any]) -> dict[str, str]:
|
|
66
|
+
"""A dashboard (as returned by get_dashboard) as its three repo files."""
|
|
67
|
+
return {
|
|
68
|
+
"meta.yaml": dump_yaml({"name": d["name"], "connection": d["connection"]}),
|
|
69
|
+
"dashboard.html": d["html"],
|
|
70
|
+
"queries.yaml": dump_yaml(d["queries"] or {}),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dashboard_from_files(files: dict[str, str]) -> dict[str, Any]:
|
|
75
|
+
"""Inverse of dashboard_to_files."""
|
|
76
|
+
try:
|
|
77
|
+
meta = yaml.safe_load(files.get("meta.yaml") or "")
|
|
78
|
+
queries = yaml.safe_load(files.get("queries.yaml") or "")
|
|
79
|
+
except yaml.YAMLError as e:
|
|
80
|
+
raise GitSyncError(f"malformed dashboard file: {e}") from e
|
|
81
|
+
data = {**(meta if isinstance(meta, dict) else {}), "html": files.get("dashboard.html", ""), "queries": queries}
|
|
82
|
+
try:
|
|
83
|
+
return dashboard_from_data(data, require_html=False)
|
|
84
|
+
except YamlIOError as e:
|
|
85
|
+
raise GitSyncError(str(e)) from e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Configuration ---------------------------------------------------------
|
|
89
|
+
# Runtime config lives on the workspace row (GIT_SYNC_REMOTE/GIT_SYNC_BRANCH
|
|
90
|
+
# are read once, by the workspaces migration, to seed the default workspace).
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require_remote(ws: WorkspaceRec) -> str:
|
|
94
|
+
if not ws.remote:
|
|
95
|
+
raise GitSyncError(f"workspace {ws.name!r} has no git remote configured", status=409)
|
|
96
|
+
return ws.remote
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _workdir(ws: WorkspaceRec) -> Path:
|
|
100
|
+
"""This workspace's clone: {base}/{workspace id}. Keyed by id so renaming
|
|
101
|
+
a workspace never orphans its clone. GIT_SYNC_DIR overrides the base."""
|
|
102
|
+
env = os.environ.get("GIT_SYNC_DIR")
|
|
103
|
+
if env:
|
|
104
|
+
return Path(env) / str(ws.id)
|
|
105
|
+
from .connect import _db_path
|
|
106
|
+
|
|
107
|
+
return Path(f"{_db_path()}.gitsync") / str(ws.id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def configured(ws: WorkspaceRec) -> bool:
|
|
111
|
+
return bool(ws.remote)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- Git plumbing ----------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
# One git operation at a time per workspace; each workdir is shared mutable
|
|
117
|
+
# state, but different workspaces' clones are independent, so their syncs
|
|
118
|
+
# don't serialize each other. The lock is per (event loop, workspace):
|
|
119
|
+
# asyncio.Lock binds to the loop that first acquires it, and tests run each
|
|
120
|
+
# operation under a fresh asyncio.run loop; production has a single loop.
|
|
121
|
+
_locks: dict[tuple[int, int], asyncio.Lock] = {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _lock(ws: WorkspaceRec) -> asyncio.Lock:
|
|
125
|
+
key = (id(asyncio.get_running_loop()), ws.id)
|
|
126
|
+
lock = _locks.get(key)
|
|
127
|
+
if lock is None:
|
|
128
|
+
lock = _locks[key] = asyncio.Lock()
|
|
129
|
+
return lock
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _git(*args: str, cwd: Path | None = None) -> str:
|
|
133
|
+
proc = await asyncio.create_subprocess_exec(
|
|
134
|
+
"git",
|
|
135
|
+
*args,
|
|
136
|
+
cwd=str(cwd) if cwd else None,
|
|
137
|
+
stdout=asyncio.subprocess.PIPE,
|
|
138
|
+
stderr=asyncio.subprocess.PIPE,
|
|
139
|
+
)
|
|
140
|
+
out, err = await proc.communicate()
|
|
141
|
+
if proc.returncode != 0:
|
|
142
|
+
tail = err.decode("utf-8", "replace").strip()[-500:]
|
|
143
|
+
raise GitSyncError(f"git {args[0]} failed: {tail}")
|
|
144
|
+
return out.decode("utf-8", "replace")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _ensure_repo(ws: WorkspaceRec) -> Path:
|
|
148
|
+
"""The workspace's sync clone, cloning (or, for an empty remote, init +
|
|
149
|
+
remote add) on first use. Idempotent."""
|
|
150
|
+
remote, branch, wd = _require_remote(ws), ws.branch, _workdir(ws)
|
|
151
|
+
if (wd / ".git").exists():
|
|
152
|
+
return wd
|
|
153
|
+
wd.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
try:
|
|
155
|
+
await _git("clone", "--branch", branch, remote, str(wd))
|
|
156
|
+
except GitSyncError as clone_err:
|
|
157
|
+
# Clone can fail either because the branch genuinely doesn't exist yet
|
|
158
|
+
# on an empty remote (the only case we should paper over with a local
|
|
159
|
+
# init) or because the remote itself is unreachable/misconfigured. Ask
|
|
160
|
+
# the remote directly to tell the two apart.
|
|
161
|
+
try:
|
|
162
|
+
heads = await _git("ls-remote", "--heads", remote, branch)
|
|
163
|
+
except GitSyncError as probe_err:
|
|
164
|
+
raise GitSyncError(f"git remote unreachable: {probe_err}") from probe_err
|
|
165
|
+
if heads.strip():
|
|
166
|
+
raise clone_err
|
|
167
|
+
# Empty remote (branch doesn't exist yet): start locally, attach remote.
|
|
168
|
+
if wd.exists():
|
|
169
|
+
shutil.rmtree(wd) # clean up any partial clone before init
|
|
170
|
+
await _git("init", "-b", branch, str(wd))
|
|
171
|
+
await _git("remote", "add", "origin", remote, cwd=wd)
|
|
172
|
+
await _git("config", "user.name", "queryview", cwd=wd)
|
|
173
|
+
await _git("config", "user.email", "queryview@localhost", cwd=wd)
|
|
174
|
+
return wd
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _origin_head(wd: Path, ws: WorkspaceRec) -> str | None:
|
|
178
|
+
"""Fetch, then the remote branch ref if it exists (None on an empty remote).
|
|
179
|
+
Network/auth failures raise."""
|
|
180
|
+
await _git("fetch", "origin", cwd=wd)
|
|
181
|
+
ref = f"origin/{ws.branch}"
|
|
182
|
+
try:
|
|
183
|
+
await _git("rev-parse", "--verify", ref, cwd=wd)
|
|
184
|
+
except GitSyncError:
|
|
185
|
+
return None
|
|
186
|
+
return ref
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- Entities --------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_kind(kind: str, conn_type: str | None) -> None:
|
|
193
|
+
"""Validate kind/conn_type here so every caller (REST, MCP, future ones)
|
|
194
|
+
inherits it instead of each layer re-implementing the check."""
|
|
195
|
+
if kind not in ("query", "dashboard"):
|
|
196
|
+
raise GitSyncError(f"unknown kind {kind!r} (expected 'query' or 'dashboard')", status=400)
|
|
197
|
+
if kind == "query" and not conn_type:
|
|
198
|
+
raise GitSyncError("conn_type is required for queries", status=400)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def entity_relpath(kind: str, name: str, conn_type: str | None) -> str:
|
|
202
|
+
return query_relpath(conn_type or "", name) if kind == "query" else dashboard_reldir(name)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _load_entity(ws: WorkspaceRec, kind: str, name: str, conn_type: str | None) -> dict[str, Any]:
|
|
206
|
+
if kind == "query":
|
|
207
|
+
from .queries import get_predefined_query
|
|
208
|
+
|
|
209
|
+
row = await get_predefined_query(conn_type or "", name, ws.id)
|
|
210
|
+
if row is None:
|
|
211
|
+
raise GitSyncError(f"query {name!r} not found", status=404)
|
|
212
|
+
return row
|
|
213
|
+
from .dashboards import get_dashboard
|
|
214
|
+
|
|
215
|
+
d = await get_dashboard(name, ws.id)
|
|
216
|
+
if d is None:
|
|
217
|
+
raise GitSyncError(f"dashboard {name!r} not found", status=404)
|
|
218
|
+
return d
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- Operations ------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def store(
|
|
225
|
+
ws: WorkspaceRec,
|
|
226
|
+
kind: str,
|
|
227
|
+
name: str,
|
|
228
|
+
conn_type: str | None = None,
|
|
229
|
+
message: str | None = None,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
"""Export one entity's saved DB state into the clone, commit, push.
|
|
232
|
+
The workdir is reset to the remote head first — exports are deterministic
|
|
233
|
+
from the DB and each commit touches one entity, so this is always safe and
|
|
234
|
+
avoids push rejections."""
|
|
235
|
+
_check_kind(kind, conn_type)
|
|
236
|
+
_require_remote(ws) # unconfigured -> 409 before any DB/entity lookup
|
|
237
|
+
entity = await _load_entity(ws, kind, name, conn_type)
|
|
238
|
+
async with _lock(ws):
|
|
239
|
+
wd = await _ensure_repo(ws)
|
|
240
|
+
head = await _origin_head(wd, ws)
|
|
241
|
+
if head:
|
|
242
|
+
await _git("reset", "--hard", head, cwd=wd)
|
|
243
|
+
relpath = entity_relpath(kind, name, conn_type)
|
|
244
|
+
if kind == "query":
|
|
245
|
+
path = wd / relpath
|
|
246
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
path.write_text(query_to_yaml(entity), encoding="utf-8")
|
|
248
|
+
else:
|
|
249
|
+
ddir = wd / relpath
|
|
250
|
+
if ddir.exists():
|
|
251
|
+
shutil.rmtree(ddir)
|
|
252
|
+
ddir.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
for fname, content in dashboard_to_files(entity).items():
|
|
254
|
+
(ddir / fname).write_text(content, encoding="utf-8")
|
|
255
|
+
await _git("add", "-A", "--", relpath, cwd=wd)
|
|
256
|
+
if not (await _git("status", "--porcelain", "--", relpath, cwd=wd)).strip():
|
|
257
|
+
return {"committed": False, "sha": None, "message": "no changes"}
|
|
258
|
+
label = f"{conn_type}/{name}" if kind == "query" else name
|
|
259
|
+
await _git("commit", "-m", message or f"store {kind} {label}", cwd=wd)
|
|
260
|
+
await _git("push", "origin", ws.branch, cwd=wd)
|
|
261
|
+
sha = (await _git("rev-parse", "HEAD", cwd=wd)).strip()
|
|
262
|
+
return {"committed": True, "sha": sha, "message": "stored"}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def history(
|
|
266
|
+
ws: WorkspaceRec,
|
|
267
|
+
kind: str,
|
|
268
|
+
name: str,
|
|
269
|
+
conn_type: str | None = None,
|
|
270
|
+
before: str | None = None,
|
|
271
|
+
limit: int = 10,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Commits touching the entity's path, newest first. `before=<sha>` pages
|
|
274
|
+
strictly older commits. Reads objects only — never touches the working tree."""
|
|
275
|
+
_check_kind(kind, conn_type)
|
|
276
|
+
_require_remote(ws)
|
|
277
|
+
relpath = entity_relpath(kind, name, conn_type)
|
|
278
|
+
async with _lock(ws):
|
|
279
|
+
wd = await _ensure_repo(ws)
|
|
280
|
+
head = await _origin_head(wd, ws)
|
|
281
|
+
if head is None:
|
|
282
|
+
return {"revisions": [], "has_more": False}
|
|
283
|
+
start = head
|
|
284
|
+
if before:
|
|
285
|
+
try:
|
|
286
|
+
await _git("rev-parse", "--verify", "--quiet", f"{before}^{{commit}}", cwd=wd)
|
|
287
|
+
except GitSyncError as e:
|
|
288
|
+
raise GitSyncError(f"unknown revision {before!r}", status=404) from e
|
|
289
|
+
try:
|
|
290
|
+
await _git("rev-parse", "--verify", "--quiet", f"{before}^", cwd=wd)
|
|
291
|
+
except GitSyncError:
|
|
292
|
+
# `before` is the oldest commit: <sha>^ doesn't resolve.
|
|
293
|
+
return {"revisions": [], "has_more": False}
|
|
294
|
+
start = f"{before}^"
|
|
295
|
+
out = await _git(
|
|
296
|
+
"log",
|
|
297
|
+
f"--max-count={limit + 1}",
|
|
298
|
+
"--format=%H%x1f%ct%x1f%s",
|
|
299
|
+
start,
|
|
300
|
+
"--",
|
|
301
|
+
relpath,
|
|
302
|
+
cwd=wd,
|
|
303
|
+
)
|
|
304
|
+
revisions = []
|
|
305
|
+
for line in out.splitlines():
|
|
306
|
+
sha, ct, subject = line.split("\x1f", 2)
|
|
307
|
+
revisions.append({"sha": sha, "date": int(ct) * 1000, "message": subject})
|
|
308
|
+
return {"revisions": revisions[:limit], "has_more": len(revisions) > limit}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def restore(
|
|
312
|
+
ws: WorkspaceRec,
|
|
313
|
+
kind: str,
|
|
314
|
+
name: str,
|
|
315
|
+
conn_type: str | None = None,
|
|
316
|
+
ref: str | None = None,
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
"""Overwrite the local DB row with the entity's content at `ref` (default:
|
|
319
|
+
the remote branch head). Reads via `git show` — HEAD never moves, history
|
|
320
|
+
is never rewritten. Parses fully before writing, so the DB row is either
|
|
321
|
+
untouched or fully replaced."""
|
|
322
|
+
_check_kind(kind, conn_type)
|
|
323
|
+
_require_remote(ws)
|
|
324
|
+
relpath = entity_relpath(kind, name, conn_type)
|
|
325
|
+
async with _lock(ws):
|
|
326
|
+
wd = await _ensure_repo(ws)
|
|
327
|
+
head = await _origin_head(wd, ws)
|
|
328
|
+
resolved = ref if ref and ref != "HEAD" else head
|
|
329
|
+
if resolved is None:
|
|
330
|
+
raise GitSyncError(f"{kind} {name!r} not found in git", status=404)
|
|
331
|
+
|
|
332
|
+
async def _show(path: str) -> str:
|
|
333
|
+
return await _git("show", f"{resolved}:{path}", cwd=wd)
|
|
334
|
+
|
|
335
|
+
if kind == "query":
|
|
336
|
+
try:
|
|
337
|
+
text = await _show(relpath)
|
|
338
|
+
except GitSyncError:
|
|
339
|
+
raise GitSyncError(f"query {name!r} not found at {resolved}", status=404) from None
|
|
340
|
+
data = query_from_yaml(text)
|
|
341
|
+
else:
|
|
342
|
+
files: dict[str, str] = {}
|
|
343
|
+
for fname in ("meta.yaml", "dashboard.html", "queries.yaml"):
|
|
344
|
+
try:
|
|
345
|
+
files[fname] = await _show(f"{relpath}/{fname}")
|
|
346
|
+
except GitSyncError:
|
|
347
|
+
if fname == "meta.yaml":
|
|
348
|
+
raise GitSyncError(f"dashboard {name!r} not found at {resolved}", status=404) from None
|
|
349
|
+
data = dashboard_from_files(files)
|
|
350
|
+
|
|
351
|
+
# DB upsert happens outside the git lock — it doesn't touch the workdir.
|
|
352
|
+
if kind == "query":
|
|
353
|
+
from .queries import save_predefined_query
|
|
354
|
+
|
|
355
|
+
await save_predefined_query(
|
|
356
|
+
data["query_name"],
|
|
357
|
+
conn_type or "",
|
|
358
|
+
data["query"],
|
|
359
|
+
data["cell_view"],
|
|
360
|
+
data["order_by"],
|
|
361
|
+
data["fields"],
|
|
362
|
+
workspace_id=ws.id,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
from .dashboards import upsert_dashboard
|
|
366
|
+
|
|
367
|
+
await upsert_dashboard(
|
|
368
|
+
data["name"],
|
|
369
|
+
data["connection"],
|
|
370
|
+
data["html"],
|
|
371
|
+
data["queries"],
|
|
372
|
+
workspace_id=ws.id,
|
|
373
|
+
)
|
|
374
|
+
return {"restored": True, "sha": resolved}
|