diff --git a/CHANGELOG.md b/CHANGELOG.md index 252ea14..9b452a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Added +- Workspace-scoped Markdown Vault sync HTTP API (`#234`): discover Vaults, pull bounded + snapshots and atomically commit logical-note deltas with whole-Vault CAS. + Independent empty notes, immutable revisions and deletion tombstones preserve + identity and concurrent edits; workspace, read/write and token-path boundaries + reuse canonical authorization. Additive migration 0027 leaves existing files + unchanged. See `docs/VAULT_SYNC.md` for limits, downgrade data loss and the + explicit boundary from current workspace-bundle export/import formats. - Per-agent memory namespace filter on lexical recall (`#227`): `POST /v1/context` and `mem_context` accept `agent_id` plus optional `extra_agent_ids`. When set, structured-memory recall hard-filters `producer_agent` before ranking so @@ -17,8 +24,20 @@ The project publishes 0.x prerelease versions; a stable release line is not yet Design: `docs/agent-memory-namespace.md`. - `mem put --watch` one-way foreground directory watch: poll `--interval` (default 30s), ingest new files after one quiet interval, report changed files without re-ingesting, and never propagate local deletes. Refs #110. +### Fixed + +- Restore Linux/Windows Web dependency audit gates by removing the unpatched + braces build-tool chain with official Tailwind/PostCSS 4 and typescript-eslint + 8, while retaining existing theme utility behavior and browser acceptance. + Apply compatible transitive security updates without relaxing audit thresholds. + Validation and public advisory references: `docs/acceptance/VAULT_SYNC_VALIDATION.md`. +- Build isolated lifecycle-test MinIO/mc images from checksum-pinned official + sources when former public images are no longer anonymously pullable. Keep + production assets and all HTTP/CLI/MCP assertions unchanged. Refs #234. + ### Changed +- The live recall producer (`python3 -m benchmarks.recall produce`) reads engine, profile, provider and embedding dimension from the running memd instead of CLI labels, scores the multilingual v1 set (English and Chinese, including image-description queries), and stays on-demand rather than a CI gate. Refs #175. - Rewrite the root README around a concise product promise, a verified Compose first-run path, source-built MCP setup, current capability and trust boundaries, and links to authoritative reference docs. Remove the unsupported unpublished `@bytefolk/mem-mcp@0.1.2` install path and keep its migration explicitly tracked in #153. Refs #229. - Index generation HTTP create/activate/rollback stay `503 execution_unavailable` with `execution_wired: false` on that body, and the same flag is now on events as well as list/status/cancel/resume/discard. Create still returns `400` for malformed JSON or an empty `profile_id` before the 503. Empty-workspace activate is refused by the same 503. Refs #174. - Ingest cursor locks try non-blocking exclusive locks and give up after 5s so a wedged peer becomes a warning instead of a silent hang. Refs #139. diff --git a/SPEC.md b/SPEC.md index ce49097..79da954 100644 --- a/SPEC.md +++ b/SPEC.md @@ -254,6 +254,28 @@ workspace import 的数据库 commit 若无法确认结果,服务端不得删 --- +### F9A · Workspace Markdown Vault 同步(HTTP v1) + +需求:[mem #234](https://github.com/bytefolk/mem/issues/234)。 + +`GET /v1/vault/list`、`GET /v1/vault/snapshot?vaultId=` 与 +`POST /v1/vault/commit` 提供工作区内的逻辑笔记同步底座。每份笔记使用独立 +`noteId`,相同正文和空笔记不会因 blob 去重而合并。commit 传递 +`{vaultId,baseRevision,title?,entries:[{noteId,path,content,deleted?,properties?}]}` +增量;整个 Vault 的 head 在单个事务内执行 CAS,过期 head 返回 `409` +及 `currentRevision`,不覆盖其他客户端。删除保留 tombstone 与不可原地更新的 +正文 revision;重命名保留逻辑 ID。只读需 `read`,提交需 `read` + `write`, +复用现有 workspace membership 和 `/Vaults/` token path 边界。 +properties 仅为有界数据,不能授予工具或身份权限。图片等原件继续使用 files API。 + +`vault-snapshot.v1` 是独立的当前状态可迁移合同,详见 +[API、限制与错误码](docs/VAULT_SYNC.md) 和 +[JSON Schema](docs/schemas/vault-snapshot.v1.schema.json)。CLI/MCP/Web 本次不新增 +Vault 命令或独立笔记编辑器。现有 workspace bundle v1/v2 不包含 Vault 表和历史; +完整历史仍需数据库备份。服务不运行桌面文件监听或自动合并客户端冲突;客户端必须 +拉取 head、三方合并并保留冲突副本。迁移 0027 不改变 files/auth/memory 表;降级 +到 0026 会丢弃 Vault 数据,操作前需备份。 + ## 4. 非功能需求 | 维度 | 目标 | diff --git a/benchmarks/recall/README.md b/benchmarks/recall/README.md index 69a1e10..6141a17 100644 --- a/benchmarks/recall/README.md +++ b/benchmarks/recall/README.md @@ -2,11 +2,11 @@ > **Evidence boundary: producer checks do not complete the live benchmark.** > -> The producer corrections tracked in #196 do not complete #175's acceptance. -> The original #184 live-run requirement still needs a populated real memd, -> actual embedding-provider output and saved ranking artifacts. Unit or -> loopback HTTP fixtures establish transport behavior only. A real model-free -> lexical run also cannot establish vector/provider quality. See +> The producer can emit `mem.recall-rankings.v1` from a running memd and copies +> engine/profile identity from that process. It still needs a populated real +> memd, actual embedding-provider output, and a saved ranking artifact before +> a real-memd baseline can land in `baselines/`. Unit or loopback HTTP fixtures +> establish transport behavior only. See > [Exact remaining live prerequisites](#exact-remaining-live-prerequisites). This directory provides a small, repeatable retrieval benchmark. It is a @@ -184,21 +184,32 @@ emits a `mem.recall-rankings.v1` file that the existing `run --rankings` path consumes. Latency is measured client-side per request; the `0 ms` sentinel warning above applies only to the offline lexical lane. +Engine, provider, model/profile identity and embedding dimension are read from +the running process (`GET /v1/version` and +`GET /v1/workspaces/current/ai-profile`). They are not CLI flags. The artifact +cannot be labeled `lexical-reference`. ANN identity is not advertised by those +endpoints, so `configuration.index.kind` is recorded as `not-advertised` +rather than guessed. + +The default dataset is the multilingual v1 fixture. Scoring the same run +reports Recall@1/5/10 and nDCG@10 under `metrics.by_language` (`en`, `zh`) +and `metrics.by_source_kind` (`text`, `image_caption`, `structured`). The +Chinese set includes image-description queries (`q-zh-image-paraphrase`). +Structured-memory queries cannot be served by `POST /v1/search`; they are +recorded as `unsupported_source_kind` and do not fail the producer. + ```bash python3 -m benchmarks.recall produce \ --memd-url http://localhost:8080 \ --token "$MEM_TOKEN" \ - --dataset benchmarks/recall/data/profile-text-v1 \ --output /tmp/live-rankings.json \ - --dimension 768 --provider "$MEM_PROVIDER_LABEL" --model "$MEM_MODEL_LABEL" \ --mode vector ``` -Then score the saved rankings (the default v1 baseline uses a different corpus): +Then score the saved rankings: ```bash python3 -m benchmarks.recall run \ - --dataset benchmarks/recall/data/profile-text-v1 \ --rankings /tmp/live-rankings.json \ --output /tmp/live-artifact.json ``` @@ -224,11 +235,9 @@ silently ignored. Vector mode sends `route=text`; it does not claim a hybrid lexical/vector or multimodal experiment. Lexical mode sends `route=lexical` and requires the server capability from #183. It emits null provider/model/dimension as required -by the ranking schema. Provider, model, dimension and index configuration are -operator declarations, not discovered or verified server metadata. The -`hardware.host` value deliberately records only the producer client's -OS/architecture, never its hostname. It is not the server's hardware inventory -and cannot establish comparable performance conditions. +by the ranking schema. The `hardware.host` value deliberately records only the +producer client's OS/architecture, never its hostname. It is not the server's +hardware inventory and cannot establish comparable performance conditions. The full v1 corpus also contains structured-memory queries. `/v1/search` cannot serve these; the producer records `unsupported_source_kind` and exits 2. Any @@ -248,17 +257,20 @@ acceptance. Those remain NOT VERIFIED until a real populated memd run is saved. interpreting the producer's output. Direct database seeding can establish retrieval/transport behavior but does not verify ingestion or Worker indexing. 3. For vector acceptance, select the same actual embedding model for corpus and - queries, record its dimension and profile, and independently inspect the - active generation/index and deployment revision. Producer labels alone do - not verify any of those properties. + queries. The producer now copies provider/model/dimension from the active + workspace AI profile; independently inspect the ANN index and deployment + revision. `configuration.index.kind=not-advertised` means those endpoints + do not expose ANN identity, not that no index exists. 4. Run the documented producer command, retain its output, score the resulting rankings and record errors and measured client latencies. An empty/error run or a fake embedding provider cannot establish vector quality. A lexical run requires the separate #194 server capability and remains a distinct result. -5. The bounded file-only experiment does not cover #175's bilingual image-query - acceptance or the full v1 structured-memory corpus. The original issue and - live quality acceptance must not be described as complete on this evidence. - -The producer remains opt-in; the normal recall CI gate runs deterministic unit -and fixture checks only. No real-model baseline is checked in until its actual -configuration and saved run are available for review. +5. Score the v1 artifact's `metrics.by_language.zh` and + `metrics.by_source_kind.image_caption` for GOAL.md §6. Structured-memory + rows remain `unsupported_source_kind` because `/v1/search` is file search. + +The producer remains **on-demand**. `make test-recall` and CI run deterministic +unit and fixture checks only; they never start memd or call a provider. No +real-memd baseline is checked into `benchmarks/recall/baselines/` until a +populated deployment's saved rankings are reviewed. That remaining #175 +acceptance bullet is still open. diff --git a/benchmarks/recall/__main__.py b/benchmarks/recall/__main__.py index fe15870..0e2ed09 100644 --- a/benchmarks/recall/__main__.py +++ b/benchmarks/recall/__main__.py @@ -9,7 +9,7 @@ from .dataset import load_dataset from .errors import BenchmarkError -from .live_producer import produce_rankings +from .live_producer import discover_live_configuration, produce_rankings from .runner import ( compare_artifacts, comparison_summary, @@ -24,6 +24,15 @@ PACKAGE_ROOT = Path(__file__).resolve().parent DEFAULT_DATASET = PACKAGE_ROOT / "data" / "v1" DEFAULT_BASELINE = PACKAGE_ROOT / "baselines" / "lexical-reference.v1.json" +_PRODUCE_EXPECTED_GAPS = frozenset({"unsupported_source_kind", "unsupported_filter"}) + + +def produce_exit_code(rankings: dict) -> int: + """Exit 2 only for transport/mapping failures, not dataset surface gaps.""" + for query in rankings.get("queries", []): + if query.get("status") == "error" and query.get("error_code") not in _PRODUCE_EXPECTED_GAPS: + return 2 + return 0 def _parser() -> argparse.ArgumentParser: @@ -79,13 +88,9 @@ def _parser() -> argparse.ArgumentParser: produce.add_argument("--output", type=Path, required=True) produce.add_argument("--limit", type=int, default=10) produce.add_argument("--timeout", type=float, default=30.0) - produce.add_argument("--engine", default="live-memd") - produce.add_argument("--dimension", type=int, default=768) produce.add_argument( "--mode", default="vector", choices=["lexical", "vector"] ) - produce.add_argument("--provider", default="operator-unspecified") - produce.add_argument("--model", default="operator-unspecified") return parser @@ -122,24 +127,37 @@ def main(argv: list[str] | None = None) -> int: if args.command == "produce": dataset = load_dataset(args.dataset) + live_config = discover_live_configuration( + args.memd_url, + args.token, + mode=args.mode, + timeout=args.timeout, + ) rankings = produce_rankings( dataset, base_url=args.memd_url, token=args.token, limit=args.limit, timeout=args.timeout, - engine_label=args.engine, - dimension=args.dimension, mode=args.mode, - provider=args.provider, - model=args.model, + live_config=live_config, ) write_json(args.output, rankings) ok_count = sum(1 for q in rankings["queries"] if q["status"] == "ok") - err_count = sum(1 for q in rankings["queries"] if q["status"] == "error") - print(f"produced rankings: {ok_count} ok, {err_count} error") + real_errors = [ + q + for q in rankings["queries"] + if q.get("status") == "error" and q.get("error_code") not in _PRODUCE_EXPECTED_GAPS + ] + gap_count = sum( + 1 + for q in rankings["queries"] + if q.get("error_code") in _PRODUCE_EXPECTED_GAPS + ) + print(f"produced rankings: {ok_count} ok, {len(real_errors)} error, {gap_count} unsupported") + print(f"engine: {rankings['engine']}") print(f"rankings artifact: {args.output}") - return 2 if err_count else 0 + return produce_exit_code(rankings) first = run_benchmark( dataset_dir=args.dataset, diff --git a/benchmarks/recall/live_producer.py b/benchmarks/recall/live_producer.py index 9888b36..2ea2541 100644 --- a/benchmarks/recall/live_producer.py +++ b/benchmarks/recall/live_producer.py @@ -72,6 +72,113 @@ def _coarse_host() -> str: return "unknown" +def _json_get( + base_url: str, + path: str, + token: str, + *, + timeout: float, +) -> dict[str, Any]: + url = base_url.rstrip("/") + path + req = Request(url, method="GET") + req.add_header("Authorization", f"Bearer {token}") + try: + with urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + raise BenchmarkError(f"GET {path} failed with HTTP {exc.code}") from exc + except URLError as exc: + raise BenchmarkError(f"GET {path} failed: {exc.reason}") from exc + except Exception as exc: + raise BenchmarkError(f"GET {path} returned unreadable JSON") from exc + if not isinstance(payload, dict): + raise BenchmarkError(f"GET {path} must return a JSON object") + return payload + + +def discover_live_configuration( + base_url: str, + token: str, + *, + mode: str, + timeout: float = 30.0, +) -> dict[str, Any]: + """Read engine/profile identity from a running memd, not from CLI flags.""" + if mode not in {"lexical", "vector"}: + raise BenchmarkError("memd /v1/search does not expose a hybrid lexical/vector route") + version = _json_get(base_url, "/v1/version", token, timeout=timeout) + version_label = version.get("version") + revision = version.get("revision") + contract = version.get("contract") + if not isinstance(version_label, str) or not version_label.strip(): + raise BenchmarkError("GET /v1/version did not return a version string") + engine = f"memd/{version_label.strip()}" + if engine == "lexical-reference": + raise BenchmarkError("engine must identify live memd, not lexical-reference") + + profile_payload = _json_get( + base_url, "/v1/workspaces/current/ai-profile", token, timeout=timeout + ) + active = profile_payload.get("active") + server: dict[str, Any] = { + "version": version_label.strip(), + "revision": revision if isinstance(revision, str) else None, + "contract": contract if isinstance(contract, str) else None, + } + index = { + "kind": "not-advertised", + "evidence": ( + "GET /v1/version and GET /v1/workspaces/current/ai-profile do not " + "expose ANN identity" + ), + } + evidence = ( + "discovered from GET /v1/version and GET /v1/workspaces/current/ai-profile" + ) + if mode == "lexical": + return { + "engine": engine, + "provider": None, + "model": None, + "dimension": None, + "index": index, + "server": server, + "evidence": evidence, + } + if not isinstance(active, dict): + raise BenchmarkError( + "vector produce requires an active workspace AI profile from the running memd" + ) + embedding = active.get("embedding") + if not isinstance(embedding, dict): + raise BenchmarkError("active AI profile is missing embedding stage") + provider = embedding.get("provider") + dimension = embedding.get("dimensions") + profile_id = active.get("profile_id") + profile_revision = active.get("profile_revision") + if not isinstance(provider, str) or not provider.strip(): + raise BenchmarkError("active AI profile embedding.provider is empty") + if not isinstance(dimension, int) or isinstance(dimension, bool) or dimension <= 0: + raise BenchmarkError("active AI profile embedding.dimensions must be a positive integer") + if not isinstance(profile_id, str) or not profile_id.strip(): + raise BenchmarkError("active AI profile_id is empty") + model = profile_id.strip() + if isinstance(profile_revision, str) and profile_revision.strip(): + model = f"{model}@{profile_revision.strip()}" + server["profile_id"] = profile_id.strip() + if isinstance(profile_revision, str) and profile_revision.strip(): + server["profile_revision"] = profile_revision.strip() + return { + "engine": engine, + "provider": provider.strip(), + "model": model, + "dimension": dimension, + "index": index, + "server": server, + "evidence": evidence, + } + + def _query_memd( base_url: str, token: str, @@ -131,11 +238,23 @@ def produce_rankings( mode: str = "vector", provider: str = "operator-unspecified", model: str = "operator-unspecified", + live_config: dict[str, Any] | None = None, ) -> dict[str, Any]: if mode not in {"lexical", "vector"}: raise BenchmarkError("memd /v1/search does not expose a hybrid lexical/vector route") if not 1 <= limit <= 100 or not math.isfinite(timeout) or timeout <= 0: raise BenchmarkError("limit must be 1..100 and timeout must be positive and finite") + index_config: dict[str, Any] = {"kind": "operator-unspecified"} + evidence = "operator-declared configuration; model and index not verified by producer" + server_identity: dict[str, Any] | None = None + if live_config is not None: + engine_label = live_config["engine"] + provider = live_config["provider"] + model = live_config["model"] + dimension = live_config["dimension"] + index_config = dict(live_config["index"]) + evidence = live_config["evidence"] + server_identity = live_config.get("server") if not isinstance(engine_label, str) or not engine_label.strip() or engine_label == "lexical-reference": raise BenchmarkError("engine must identify live memd, not lexical-reference") if mode == "vector" and ( @@ -236,24 +355,25 @@ def produce_rankings( row["error_code"] = mapping_error query_rows.append(row) + configuration: dict[str, Any] = { + "mode": mode, + "provider": None if mode == "lexical" else provider, + "model": None if mode == "lexical" else model, + "dimension": None if mode == "lexical" else dimension, + "evidence": evidence, + "index": index_config, + "search": { + "top_k": limit, + "route": "lexical" if mode == "lexical" else "text", + "workspace": "bound by the supplied token; not inferred from dataset labels", + }, + } + if isinstance(server_identity, dict) and server_identity: + configuration["server"] = server_identity return { "schema_version": "mem.recall-rankings.v1", "engine": engine_label, - "configuration": { - "mode": mode, - "provider": None if mode == "lexical" else provider, - "model": None if mode == "lexical" else model, - "dimension": None if mode == "lexical" else dimension, - "evidence": "operator-declared configuration; model and index not verified by producer", - "index": { - "kind": "operator-unspecified", - }, - "search": { - "top_k": limit, - "route": "lexical" if mode == "lexical" else "text", - "workspace": "bound by the supplied token; not inferred from dataset labels", - }, - }, + "configuration": configuration, "hardware": { "host": _coarse_host(), }, diff --git a/benchmarks/recall/tests/test_live_producer.py b/benchmarks/recall/tests/test_live_producer.py index 6985e7e..e4ac243 100644 --- a/benchmarks/recall/tests/test_live_producer.py +++ b/benchmarks/recall/tests/test_live_producer.py @@ -8,12 +8,13 @@ import unittest from unittest.mock import patch -from benchmarks.recall.__main__ import main +from benchmarks.recall.__main__ import main, produce_exit_code from benchmarks.recall.adapters import load_external_rankings from benchmarks.recall.errors import BenchmarkError from benchmarks.recall.live_producer import ( _build_path_index, _match_doc_by_path, + discover_live_configuration, produce_rankings, ) from benchmarks.recall.dataset import Document, load_dataset @@ -118,6 +119,35 @@ def test_http_fixture_runs_transport_and_emits_loadable_artifact(self) -> None: requests = [] class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v1/version": + payload = json.dumps({ + "version": "0.1.2", + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "contract": "durable-context.v1", + }).encode() + elif self.path == "/v1/workspaces/current/ai-profile": + payload = json.dumps({ + "active": { + "profile_id": "local-fast", + "profile_revision": "2026-01-01.1", + "embedding": { + "enabled": True, + "provider": "ollama", + "dimensions": 768, + }, + }, + "available": [], + }).encode() + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + def do_POST(self): requests.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"]))))) payload = json.dumps({"results": [{"path": "/research", "name": "saturn.md", "score": 0.9}]}).encode() @@ -137,7 +167,7 @@ def log_message(self, *args): try: code = main(["produce", "--memd-url", f"http://127.0.0.1:{server.server_port}", "--token", "fixture", "--dataset", str(self.dataset), - "--output", str(output), "--provider", "fixture", "--model", "fixture"]) + "--output", str(output)]) finally: server.shutdown() thread.join() @@ -146,6 +176,12 @@ def log_message(self, *args): self.assertEqual(requests[0][0], "/v1/search") self.assertEqual(requests[0][1]["route"], "text") artifact = load_external_rankings(output, load_dataset(self.dataset)) + self.assertEqual(artifact["engine"], "memd/0.1.2") + self.assertNotEqual(artifact["engine"], "lexical-reference") + self.assertEqual(artifact["configuration"]["provider"], "ollama") + self.assertEqual(artifact["configuration"]["model"], "local-fast@2026-01-01.1") + self.assertEqual(artifact["configuration"]["dimension"], 768) + self.assertIn("discovered", artifact["configuration"]["evidence"]) self.assertEqual(artifact["queries"][0]["results"][0]["doc_id"], "file-en-cassini") self.assertGreater(artifact["queries"][0]["latency_ms"], 0) self.assertNotIn("client", artifact["hardware"]) @@ -206,16 +242,36 @@ def test_lexical_artifact_is_loadable_and_route_is_sent(self, mock_query) -> Non load_external_rankings(output, dataset) self.assertEqual(mock_query.call_args.kwargs["route"], "lexical") + @patch("benchmarks.recall.__main__.discover_live_configuration") @patch("benchmarks.recall.live_producer._query_memd") - def test_produce_command_fails_when_requests_fail(self, mock_query) -> None: + def test_produce_command_fails_when_requests_fail(self, mock_query, mock_discover) -> None: + mock_discover.return_value = { + "engine": "memd/0.1.2", + "provider": "ollama", + "model": "local-fast@2026-01-01.1", + "dimension": 768, + "index": {"kind": "not-advertised"}, + "evidence": "discovered from GET /v1/version", + "server": {"version": "0.1.2"}, + } mock_query.return_value = ([], 1, "http_503") code = main(["produce", "--memd-url", "http://localhost", "--token", "test", "--dataset", str(self.dataset), "--output", str(self.root / "failed.json")]) self.assertEqual(code, 2) + @patch("benchmarks.recall.__main__.discover_live_configuration") @patch("benchmarks.recall.live_producer._query_memd") - def test_malformed_hits_retain_error_artifact(self, mock_query) -> None: + def test_malformed_hits_retain_error_artifact(self, mock_query, mock_discover) -> None: + mock_discover.return_value = { + "engine": "memd/0.1.2", + "provider": "ollama", + "model": "local-fast@2026-01-01.1", + "dimension": 768, + "index": {"kind": "not-advertised"}, + "evidence": "discovered from GET /v1/version", + "server": {"version": "0.1.2"}, + } good = {"path": "/research", "name": "saturn.md", "score": 0.9} malformed = [ {"path": None}, {"path": []}, {"path": {}}, {"path": 42}, @@ -292,6 +348,103 @@ def test_equal_same_workspace_candidates_fail_closed(self) -> None: other = replace(document, id="other") self.assertIsNone(_match_doc_by_path(document.path, document.text, [document, other])) + def test_discover_live_configuration_reads_running_memd(self) -> None: + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v1/version": + payload = json.dumps({ + "version": "0.1.2", + "revision": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "contract": "durable-context.v1", + }).encode() + elif self.path == "/v1/workspaces/current/ai-profile": + payload = json.dumps({ + "active": { + "profile_id": "local-fast", + "profile_revision": "r1", + "embedding": {"provider": "ollama", "dimensions": 768}, + }, + }).encode() + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + cfg = discover_live_configuration( + f"http://127.0.0.1:{server.server_port}", + "tok", + mode="vector", + ) + finally: + server.shutdown() + thread.join() + server.server_close() + self.assertEqual(cfg["engine"], "memd/0.1.2") + self.assertEqual(cfg["provider"], "ollama") + self.assertEqual(cfg["model"], "local-fast@r1") + self.assertEqual(cfg["dimension"], 768) + self.assertNotEqual(cfg["engine"], "lexical-reference") + + def test_discover_live_configuration_fails_closed_without_profile(self) -> None: + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v1/version": + payload = json.dumps({"version": "0.1.2"}).encode() + else: + payload = json.dumps({"active": None, "available": []}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with self.assertRaises(BenchmarkError): + discover_live_configuration( + f"http://127.0.0.1:{server.server_port}", + "tok", + mode="vector", + ) + finally: + server.shutdown() + thread.join() + server.server_close() + + def test_unsupported_source_kind_is_not_a_produce_failure(self) -> None: + self.assertEqual( + produce_exit_code({ + "queries": [ + {"status": "ok"}, + {"status": "error", "error_code": "unsupported_source_kind"}, + {"status": "error", "error_code": "unsupported_filter"}, + ] + }), + 0, + ) + self.assertEqual( + produce_exit_code({ + "queries": [{"status": "error", "error_code": "http_503"}] + }), + 2, + ) + if __name__ == "__main__": unittest.main() diff --git a/docs/MIGRATION_SEQUENCE.md b/docs/MIGRATION_SEQUENCE.md index e02332c..8d14a28 100644 --- a/docs/MIGRATION_SEQUENCE.md +++ b/docs/MIGRATION_SEQUENCE.md @@ -6,18 +6,19 @@ These draft changes are cumulative, not independently deployable: | --- | --- | --- | --- | | 1 | #194 / #183 | 0024 file lexical lane | released/main schema 23 (merged) | | 2 | #173 HNSW completion (supersedes #197 HOLD) | 0025 HNSW indexes + text continuation | #194, schema 24 | -| 3 | #195 / #185 | 0027 data-plane hygiene | schema 26 | +| 3 | #195 / #185 | 0028 data-plane hygiene | schema 27 | -Migrations 0024–0026 are now on `main`. PR #195 must integrate that exact -history before adding data-plane hygiene as 0027. Local repair branches retain +Migrations 0024–0027 are now on `main`. PR #195 must integrate that exact +history before adding data-plane hygiene as 0028. Local repair branches retain the original authored commits and add a current-main integration commit rather than rewriting published history. -The hygiene migration was originally reviewed as draft 0026, but main now owns -0026 for `memory_producer_agent`. The unmerged hygiene migration therefore -moves to 0027. An operator who privately applied the old draft under version 26 -must stop and obtain a recovery plan; do not rewrite Goose history to make the -new main sequence appear valid. +The hygiene migration was originally reviewed as draft 0026, then sequenced as +0027 while main owned only 0026. Main has since taken 0027 for `vault_sync` +(#235), so the unmerged hygiene migration moves on to 0028 — the next version +free on main. An operator who privately applied the old draft under version 26 +or 27 must stop and obtain a recovery plan; do not rewrite Goose history to make +the new main sequence appear valid. Migration 0025 creates the three cosine HNSW indexes. The shipping text route no longer uses `DISTINCT ON (f.id) ORDER BY f.id` as its primary plan: it walks @@ -25,12 +26,12 @@ cosine-ordered candidates and falls back to that exact query only when a bounded scan underfills. Visual cosine-order already matched HNSW. Face DDL is not a face-query speedup. #195 still requires predecessor schema 25. Recall and live latency remain `#175`, not this migration. #195 now requires predecessor schema -26. +27. This document does not waive a review gate or authorize deployment. Goose startup remains strict: no `WithAllowMissing` or equivalent option is -enabled. A database that already applied 27 while omitting 24/25/26 will correctly +enabled. A database that already applied 28 while omitting 24/25/26/27 will correctly fail startup against the cumulative schema. Stop and obtain an operator-owned recovery plan for such a database; do not edit its migration history, renumber its SQL, or apply lower versions out of order to manufacture a pass. @@ -56,7 +57,7 @@ an operator-owned recovery; do not mark an unverified partial schema applied. `scripts/verify.sh integration` creates a separate, owned `_test` database and runs `TestMigrationUpgradeSequence`. It applies real Goose migrations to 23, seeds a file with duplicate text chunks, then advances one version at a time -to the branch's declared head (24 through 27). Each step checks full applied +to the branch's declared head (24 through 28). Each step checks full applied history and preserved data; subsequent steps check lexical backfill, valid HNSW DDL, producer-agent indexing, and hygiene deduplication/unique rejection. Finally the ordinary production diff --git a/docs/TESTING.md b/docs/TESTING.md index a3204af..46f2db8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -274,8 +274,10 @@ Required tests: - `TestManagedAISettlementOutboxPostgres` - `TestReleasedFileStageRetryPostgres` - `TestDurableContextPostgres` +- `TestVaultSyncPostgres` (two-client CAS, distinct empty notes, auth/workspace, + immutable history, tombstones, path swap/title and migration 27 down/up) -Expected result: migrations reach the declared current head (currently `20`), +Expected result: migrations reach the declared current head (currently `28`), all rollback-state assertions pass, every named test prints `PASS`, all commands exit `0`, and the race run reports no data race. @@ -298,6 +300,15 @@ Run the isolated, model-free process test: make test-acceptance ``` +The script builds temporary MinIO/mc binaries from checksum-pinned official +source archives because Community Edition is now source-only. It uses the +existing Go toolchain, `tar` and `sha256sum`, then builds a uniquely named test +image on a pinned official Alpine runtime. The temporary Compose override, +image and run directory are removed by its ownership-checked cleanup. This +does not replace images or credentials in a production deployment. Network +access to the fixed source archives and locked Go module dependencies is +required; missing artifacts or checksum mismatches fail the gate. + The script owns a per-run Compose project, random loopback PostgreSQL/MinIO ports, an ephemeral database/store and a new memd process on an atomically locked, twice-preflighted loopback port. It builds the current CLI, MCP adapter @@ -449,8 +460,8 @@ Use this table in pull requests and add implementation-specific scenarios: | V2 | Worker processing regressions remain hermetic | `make test-worker` | Exit `0`; real-model gate explicitly skipped | | V3 | Localization, theme, enrichment, memory, transfer and managed-embedding control surfaces work in a browser | `make test-web` | Typecheck/lint/build, the localization audit, all browser acceptance suites and managed status mapping pass | | V4 | High-risk Go paths are race-free | `make test-race` | Exit `0`; no data-race warning | -| V5 | Fresh schema, rollback and PostgreSQL semantics hold | `make test-integration` | Migration head and eighteen named tests pass, none skipped | -| V6 | DB concurrency paths are race-free | `make test-integration-race` | The same eighteen tests pass under `-race` | +| V5 | Fresh schema, rollback and PostgreSQL semantics hold | `make test-integration` | Migration head and all required named tests pass, none skipped | +| V6 | DB concurrency paths are race-free | `make test-integration-race` | The same required tests pass under `-race` | | V7 | Real service boundaries agree | `make test-acceptance` | HTTP, CLI and MCP share one isolated service; memory citation/provenance, bounded checkpoint listing, full checkpoint get, lifecycle and forget redaction pass | | V8 | Five config shapes and the real adapter preserve the host-neutral MCP contract | `MEM_MCP_CERT_BINARY=... make test-agent-certification` | All fixtures and current-adapter scenarios pass with no skip | | V9 | Multilingual visual quality meets the chosen checkpoint | Opt-in command in section 7 | All fixed ranking assertions pass | diff --git a/docs/VAULT_SYNC.md b/docs/VAULT_SYNC.md new file mode 100644 index 0000000..3f78447 --- /dev/null +++ b/docs/VAULT_SYNC.md @@ -0,0 +1,109 @@ +# Workspace Vault sync v1 + +Implements [mem #234](https://github.com/bytefolk/mem/issues/234). + +This additive text-note service keeps logical identity independent of the +existing `/v1/files` content-deduplication model. It never changes that model, +invokes AI, or treats note properties as permissions. + +All endpoints use existing Bearer authentication and workspace membership. +`X-Workspace-ID`/a bound token selects the workspace. A token must also cover +the canonical path `/Vaults/`. + +* `GET /v1/vault/list` (`read`) returns + `{ "vaults": [{ "vaultId", "title", "revision", "updatedAt" }] }`. + Out-of-path Vault IDs are omitted, without revealing their count. +* `GET /v1/vault/snapshot?vaultId=` (`read`) returns a full snapshot. + A new Vault returns revision `0`, empty title, `updatedAt: null`, and `[]` + without creating a row. +* `POST /v1/vault/commit` (`read` + `write`) accepts + `{ "vaultId", "baseRevision", "title"?, "entries": [...] }`. + Entries are **deltas**, not a replacement of the full Vault. An entry is + `{ "noteId", "path", "content", "deleted"?, "properties"? }`. + Unlisted notes remain unchanged. `deleted: true` requires empty content. + Omitted properties retain existing metadata; a new note defaults to `{}`. + Empty entries are allowed only with a title update. + +Both snapshot and successful commit return: + +```json +{ + "schemaVersion": "vault-snapshot.v1", + "vaultId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "title": "Knowledge", + "revision": 1, + "updatedAt": "2026-10-07T12:00:00Z", + "entries": [ + { + "noteId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "path": "岗位/空白笔记.md", + "content": "", + "revision": 1, + "deleted": false, + "properties": {} + } + ] +} +``` + +IDs are nonzero UUIDs. Vault IDs in requests use canonical lowercase form. +Paths are canonical relative POSIX paths up to 512 UTF-8 bytes, preserving +Chinese and spaces. Absolute paths, traversal, backslashes, control characters, +Windows-forbidden filename characters and trailing dots/spaces are rejected. +Two active notes cannot share a case-folded path. Logical notes with identical +content, including empty content, retain separate IDs. Text is UTF-8; only +newline, carriage return and tab control characters are accepted. Attachments +continue to use the existing mem file API and references rather than binary +data embedded in this text protocol. + +One transaction locks and compares the whole Vault head. Successful commits +advance it exactly once and append content revisions; mutable head pointers +select current versions. Old entry revisions cannot be updated in place. +Delete creates a retained tombstone; it does not physically erase history. +Rename/swap changes logical paths while preserving note IDs. + +* Stale head: `409 { "error": "vault_revision_conflict", "currentRevision": N }`. +* Active path collision: `409`, `error: vault_path_conflict`. +* Invalid body/path/identity: `400`, `error: vault_request_invalid`. +* Capacity/body/history limit: `413`, `error: vault_limit_exceeded`. +* Unavailable storage: `503`, `error: vault_unavailable`; success is not assumed. + +Clients keep their own base snapshot and local manifest. On 409 they pull the +current head, merge nonoverlapping edits, and preserve conflicts as explicit +copies. A lost response is reconciled through snapshot readback, never a blind +overwrite. The service does not modify local files or promise a sync daemon. + +Bounds: 4 MiB HTTP commit body, 1 MiB per note, 16 KiB per properties object, +256 UTF-8 bytes per title, 512 delta entries, 1000 heads **including tombstones**, +16 MiB current serialized content/path/properties, 64 MiB retained entry-history +content/path/properties, 10,000 commits and 64 Vaults per workspace. Limits +refuse atomically; snapshots are never silently truncated. A full snapshot may +require roughly 32 MiB on the wire due to JSON escaping; callers must use a +bounded Vault-specific response budget rather than an 8 MiB file-card budget. + +Validation: unit/API tests are hermetic. `TestVaultSyncPostgres` requires an +explicit `MEM_TEST_DB` whose database name ends in `_test`; it verifies real +two-client races, distinct empty notes, workspace/auth isolation, tombstones, +immutable history, swaps, title CAS and additive migration down/up. + +## Compatibility and portability boundary + +The HTTP API is the canonical implementation. This initial additive surface +adds no CLI, MCP or standalone mem Web Vault editor; existing adapters and file +operations retain their contracts. Clients may synchronize ordinary Markdown +files and upload attachments through the existing file API. + +The current workspace-bundle v1/v2 export/import formats do not contain Vault +heads or revision history. A Vault snapshot is a separate portable current-state +contract (docs/schemas/vault-snapshot.v1.schema.json). It has no auth tokens, +workspace membership or actor audit fields, and importing its properties never +grants permissions. Restoring a snapshot into an authorized target Vault uses +a normal CAS commit with the target head and a client-selected logical Vault +ID. Full history remains in PostgreSQL and requires an operator database backup; +this API does not claim to transport retained history between deployments. + +Migration 0027 only creates new Vault tables and indexes. Downgrading to 0026 +drops all Vault state and history, so preserve a database backup or current +snapshot before an operator downgrade. Existing files, auth and memory tables +are untouched. No login recovery, deployment credentials or model dependency +is introduced. diff --git a/docs/acceptance/VAULT_SYNC_VALIDATION.md b/docs/acceptance/VAULT_SYNC_VALIDATION.md new file mode 100644 index 0000000..f16d0c4 --- /dev/null +++ b/docs/acceptance/VAULT_SYNC_VALIDATION.md @@ -0,0 +1,160 @@ +# Vault sync validation ledger + +Issue: [mem #234](https://github.com/bytefolk/mem/issues/234). +Base: `c5f3fd591973dfaf0e36baecfee8cb8295c1bcb7` (`origin/main`). +Validation date: 2026-10-08. This ledger records implementation-side evidence; +it is not independent review approval or a deployment/release claim. + +Environment: Linux amd64 through WSL, Go 1.25.0, PostgreSQL 16 with the CI-pinned +`pgvector/pgvector@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc` +image. A new loopback-only container used tmpfs PostgreSQL data and the synthetic +control database `mem_vault_publish_test`. Each integration-runner phase created +and removed its own nonce-marked `mem_verify_*_test` database. No deployed +database, credentials, container or user configuration was read or changed. + +| Check | Exact command (repository root unless specified) | Result | +| --- | --- | --- | +| Server/CLI/MCP compilation | `(cd server && env -u MEM_TEST_DB go build ./...)` | PASS | +| Go static analysis | `(cd server && env -u MEM_TEST_DB go vet ./...)` | PASS | +| All hermetic Go regressions | `(cd server && env -u MEM_TEST_DB go test -count=1 ./...)` | PASS; database tests excluded here and verified separately | +| High-risk hermetic race checks, including Vault | `bash scripts/verify.sh race` | PASS; no data race | +| Fresh schema, strict populated 23 → 27 upgrade, historical rollback round trips, HNSW checks and all required PostgreSQL tests | `MEM_TEST_DB= bash scripts/verify.sh integration` | PASS; migration head 27; no required test skipped | +| All required PostgreSQL tests under race detector | `MEM_TEST_DB= bash scripts/verify.sh integration-race` | PASS; no required test skipped or data race | +| Whitespace and schema serialization | `git diff --check`; Python `json.load` on `docs/schemas/vault-snapshot.v1.schema.json` | PASS; JSON parsing is not a claim of full JSON Schema conformance testing | + +The initial Windows worktree had CRLF shell/golden-fixture copies. The first +Linux attempt failed before integration setup (`pipefail\r`) and the Go doctor +golden comparison rejected line endings. Only those validation working copies +were converted to the Git-equivalent LF content; the commands above were rerun +and passed. No unrelated fixture, shell content or file-mode change is included +in this issue's diff. + +## Observable Vault acceptance + +`TestVaultSyncPostgres` passed in both normal and race integration runners: + +- separate UUID identities preserve two independently synchronized empty notes; +- two actual clients writing the same base revision have exactly one winner + and one `409` conflict; the losing edit never overwrites the winner; +- real router authentication, workspace selection and read/write scopes deny + reader writes and keep another workspace's same Vault UUID isolated; +- successful commits append immutable old text, advance the head once, retain + deletion tombstones, support path swaps and use the same CAS for title changes; +- real discovery lists the workspace's Vault, and real HTTP stale writes return + the observed `currentRevision`; +- migration `27 → 26 → 27` is exercised only on disposable data, preserving + existing file/auth schemas while explicitly dropping Vault state on downgrade. + +Hermetic `internal/vaultsync` and `internal/api` tests additionally cover +malformed identity/path/text/properties, distinct note identity, deterministic +delta planning, stale heads, active-path collisions, count/byte bounds, +tombstones, unavailable storage, strict HTTP bodies, anonymous denial and +token-path filtering for both snapshot and discovery. Property values are +untrusted data and do not add workspace membership or tool authority. + +## Compatibility and honest limits + +- API: additive canonical HTTP routes; existing file/blob identity is unchanged. + The new note ID is independent of content SHA-256. +- CLI/MCP/Web: N/A for new Vault UI/commands in this backend change. Their + existing behavior is covered by the complete Go suite where applicable; + no separate Vault adapter or desktop sync daemon is claimed. +- Worker: N/A; no Worker, protobuf, model or queue behavior changed. The Web + tooling follow-up and its complete validation are recorded below. +- Provenance/privacy: public snapshot contains current note data/properties, + with no authentication material, actor audit IDs or new authorization model. +- Retry: a stale or uncertain commit is reconciled through current-head + readback; clients retain their base and preserve divergent edits as copies. + No request-level idempotency key or server-side automatic merge is claimed. +- Recall warnings: N/A; this surface does not change search, recall or ranking. +- Portability: `vault-snapshot.v1` transports current state. Workspace bundle + v1/v2 does not include Vault state; retained history needs database backup. +- Deployment: NOT VERIFIED by this publication worktree; no production + migration, restart, credential issuance, release or hosted-service rollout ran. + +## CI baseline repair (2026-10-09) + +The first PR run passed Go, PostgreSQL, Worker, existing Web browser acceptance, +deployment profiles, CodeQL and dependency review. Three gates failed for +unchanged baseline dependencies or external artifacts, before exercising any +Vault code: + +- [Linux Web audit](https://github.com/bytefolk/mem/actions/runs/37806469015/job/113411946797) + and [Windows audit](https://github.com/bytefolk/mem/actions/runs/37806469015/job/113411946574) + both reported 14 high and 2 moderate findings in the original lockfile. +- [HTTP/CLI/MCP lifecycle](https://github.com/bytefolk/mem/actions/runs/37806468988/job/113411942810) + stopped at an unauthorized pull of the original public MinIO image; no + application service or migration had started. + +This follow-up keeps the audit thresholds, all lifecycle assertions and CI +timeouts. It does not suppress vulnerabilities, ignore findings or disable a +check. + +### Dependency cause and compatibility + +The published [braces stack-exhaustion advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) +affects every available version through 3.0.3 and lists no patched version. +Tailwind 3.4.19 still requires chokidar 3, fast-glob and micromatch, which retain +that chain. typescript-eslint 7 also retains it through globby/fast-glob. These +are active CSS and TypeScript build tools, so removing an unused package or +applying a compatible braces patch cannot remove the finding. + +Use official Tailwind 4.3.3 and its same-version PostCSS plugin, plus +typescript-eslint 8.71.1 (which supports the existing ESLint 8.57.1). Keep the +existing theme/config palette, font, animation and shadow definitions. Use +tailwind-merge 3.7.0 for its [supported Tailwind 4 class semantics](https://github.com/dcastil/tailwind-merge/blob/main/README.md). +Apply compatible lockfile patches for +[brace-expansion](https://github.com/advisories/GHSA-q2hr-2g5m-vwhr), +[source-map-js](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) and the published +undici advisories surfaced by npm audit. No vulnerable package is renamed or +replaced by an unreviewed fork to hide its audit identity. + +Follow the [official compiler upgrade contract](https://tailwindcss.com/docs/upgrade-guide): +the existing JS theme is loaded explicitly with `@config`, and compilation uses +`@tailwindcss/postcss`. Global compatibility declarations retain the existing +2px small radius, 4px backdrop blur, button cursor, placeholder defaults and +keyboard outlines. No React component or product flow is migrated. + +| Verification | Result | +| --- | --- | +| Linux Node 24.11.1 / npm 11.17.0 `npm run audit` | PASS; zero vulnerabilities at original production-moderate and all-dependencies-high thresholds | +| Windows Node 24.19.0 / npm 11.17.0 `npm.cmd run audit` | PASS; both original thresholds and shell-free Windows invocation pass | +| `npm run lint`, `npm run typecheck`, `npm test`, `npm run build` | PASS; 109 unit tests; pinned token bridge checks 334 contrast pairs; production build succeeds | +| Fresh `npm ci` followed by `npm run audit` and `npm run build` | PASS; checked lockfile reproduces the zero-finding audit and same production assets | +| `npm run test:i18n`, `test:theme`, `test:enrichment`, `test:memory`, `test:transfer`, `test:managed-embedding` | PASS; all original assertions retained | +| New theme compatibility browser probes | PASS; dark/light RGB and opacity, radius/blur, pointer and 2px solid keyboard outline also verified under forced colors | + +The token-source and generated-CSS validation copies were normalized to their +Git-equivalent LF bytes for Linux's pinned-byte check. Neither the upstream +token hash nor generated content was changed in the commit. The existing +production bundle-size warning remains visible. + +### Lifecycle artifact repair + +MinIO Community Edition's [official source-only distribution contract](https://github.com/minio/minio#source-only-distribution) +no longer provides the anonymously pullable historical artifacts used by this +test. Use the official +[MinIO source](https://github.com/minio/minio/tree/7aac2a2c5b7c882e68c1ce017d8256be2feea27f) +and [mc source](https://github.com/minio/mc/tree/77f82e18b5401a65958f1619df6ebb994634bd88), +pinning both commits and archive SHA-256 before extraction. Build with existing +Go using `-mod=readonly`, then place the binaries and their upstream licenses +into a fixed-digest official Alpine runtime image. A temporary Compose override +uses only the uniquely named test image, with `pull_policy: never`; the existing +test Compose graph and production deployment assets are unchanged. + +| Verification | Result | +| --- | --- | +| `bash -n scripts/acceptance_agent_memory.sh` | PASS | +| Official ShellCheck 0.11.0 on the final script | PASS | +| Full `scripts/acceptance_agent_memory.sh` with the CI-exact `GOTOOLCHAIN=go1.25.0` | PASS; official-source build, fresh migration 27, healthy MinIO, real HTTP/CLI/MCP and PostgreSQL forgetting/redaction assertions all complete | +| Ownership cleanup | PASS; run-specific containers, networks, volumes, image, directory and port lock removed; production containers unchanged | + +The local Windows-owned worktree used `GOFLAGS=-buildvcs=false` only while +executing its validation binary builds, because Linux Git cannot parse its +Windows `.git` pointer. The script's checksum-pinned upstream tarball builds +explicitly disable VCS stamping, rather than accidentally attributing those +sources to the enclosing checkout. The CI checkout, Go version, timeout and +all existing service and lifecycle assertions remain unchanged. + +Remote CI on the repaired head must still rerun. Local pass is not substituted +for required remote checks, CODEOWNERS approval or resolved review conversations. diff --git a/docs/schemas/vault-snapshot.v1.schema.json b/docs/schemas/vault-snapshot.v1.schema.json new file mode 100644 index 0000000..a66a423 --- /dev/null +++ b/docs/schemas/vault-snapshot.v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/bytefolk/mem/blob/main/docs/schemas/vault-snapshot.v1.schema.json", + "title": "Workspace Vault current-state snapshot v1", + "description": "Canonical GET snapshot and successful commit response. This transports current logical notes and tombstones, not auth state or retained history. UTF-8 byte bounds are additionally enforced by the server.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "vaultId", "title", "revision", "updatedAt", "entries"], + "properties": { + "schemaVersion": { "const": "vault-snapshot.v1" }, + "vaultId": { "$ref": "#/$defs/id" }, + "title": { "type": "string", "maxLength": 256, "$comment": "At most 256 UTF-8 bytes; empty only before the first commit." }, + "revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "updatedAt": { "type": ["string", "null"], "format": "date-time" }, + "entries": { + "type": "array", "maxItems": 1000, + "items": { + "type": "object", "additionalProperties": false, + "required": ["noteId", "path", "content", "revision", "deleted", "properties"], + "properties": { + "noteId": { "$ref": "#/$defs/id" }, + "path": { "type": "string", "minLength": 1, "maxLength": 512, "$comment": "Canonical safe relative POSIX path, at most 512 UTF-8 bytes. Active paths are case-fold unique." }, + "content": { "type": "string", "maxLength": 1048576, "$comment": "At most 1 MiB UTF-8 bytes; only tab, CR and LF control characters are accepted." }, + "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "deleted": { "type": "boolean" }, + "properties": { "type": "object", "$comment": "At most 16 KiB serialized JSON. Untrusted application data, never a permission grant." } + }, + "allOf": [{ "if": { "properties": { "deleted": { "const": true } } }, "then": { "properties": { "content": { "const": "" } } } }] + } + } + }, + "$defs": { + "id": { "type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "not": { "const": "00000000-0000-0000-0000-000000000000" } } + } +} diff --git a/scripts/acceptance_agent_memory.sh b/scripts/acceptance_agent_memory.sh index f913556..99977cb 100755 --- a/scripts/acceptance_agent_memory.sh +++ b/scripts/acceptance_agent_memory.sh @@ -4,7 +4,9 @@ set -euo pipefail REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${REPO_ROOT}/docker-compose.test.yml" +COMPOSE_OVERRIDE="" COMPOSE_PROJECT="" +MINIO_IMAGE="" PG_PORT="${MEM_ACCEPTANCE_PG_PORT:-0}" S3_PORT="${MEM_ACCEPTANCE_S3_PORT:-0}" HTTP_PORT_REQUESTED="${MEM_ACCEPTANCE_HTTP_PORT:-}" @@ -33,11 +35,15 @@ require_command() { } compose() { + local -a compose_files=(-f "$COMPOSE_FILE") + if [[ -n "$COMPOSE_OVERRIDE" ]]; then + compose_files+=(-f "$COMPOSE_OVERRIDE") + fi MEM_TEST_PROJECT="$COMPOSE_PROJECT" \ MEM_TEST_PG_PORT="$PG_PORT" \ MEM_TEST_S3_PORT="$S3_PORT" \ MEM_TEST_DB_NAME="$DB_NAME" \ - docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" --profile e2e "$@" + docker compose -p "$COMPOSE_PROJECT" "${compose_files[@]}" --profile e2e "$@" } cleanup() { @@ -63,6 +69,14 @@ cleanup() { printf 'WARNING: failed to remove Compose project %s\n' "$COMPOSE_PROJECT" >&2 cleanup_status=1 fi + if [[ -n "$MINIO_IMAGE" ]] && + docker image inspect "$MINIO_IMAGE" >/dev/null 2>&1; then + if [[ "$MINIO_IMAGE" != "${COMPOSE_PROJECT}:minio" ]] || + ! docker image rm "$MINIO_IMAGE" >/dev/null 2>&1; then + printf 'WARNING: failed to remove acceptance MinIO image\n' >&2 + cleanup_status=1 + fi + fi if [[ -n "$E2E_DIR" && "$E2E_DIR" == "${E2E_ROOT}/acceptance."* ]]; then if ! rm -rf -- "$E2E_DIR"; then printf 'WARNING: failed to remove acceptance directory\n' >&2 @@ -92,6 +106,8 @@ require_command curl require_command jq require_command od require_command tr +require_command tar +require_command sha256sum curl_safe() { command curl --connect-timeout 2 --max-time 20 "$@" @@ -184,6 +200,64 @@ published_port() { printf '%s\n' "${address##*:}" } +build_test_minio() { + # MinIO CE is source-only; its former public minio and mc images no longer + # pull anonymously. Build the official sources, without third-party mirrors. + # https://github.com/minio/minio#source-only-distribution + local minio_revision="7aac2a2c5b7c882e68c1ce017d8256be2feea27f" + local mc_revision="77f82e18b5401a65958f1619df6ebb994634bd88" + local build_dir="${E2E_DIR}/minio-build" + mkdir -p "${build_dir}/minio" "${build_dir}/mc" "${build_dir}/licenses" + curl --fail --silent --show-error --location --retry 2 \ + --connect-timeout 5 --max-time 180 \ + "https://codeload.github.com/minio/minio/tar.gz/${minio_revision}" \ + --output "${build_dir}/minio.tar.gz" + curl --fail --silent --show-error --location --retry 2 \ + --connect-timeout 5 --max-time 180 \ + "https://codeload.github.com/minio/mc/tar.gz/${mc_revision}" \ + --output "${build_dir}/mc.tar.gz" + printf '%s %s\n' \ + '71794c2df26aad0cc99e8421c58b7aa2dd55969f979b0e7d1e931042e9fabcd6' \ + "${build_dir}/minio.tar.gz" \ + '167415edd21bc29f5360943dac64272aa5cda0a39f3070b15cfeca671c43d975' \ + "${build_dir}/mc.tar.gz" | sha256sum --check --status \ + || die "official MinIO source checksum mismatch" + tar -xzf "${build_dir}/minio.tar.gz" --strip-components=1 -C "${build_dir}/minio" + tar -xzf "${build_dir}/mc.tar.gz" --strip-components=1 -C "${build_dir}/mc" + ( + cd "${build_dir}/minio" + CGO_ENABLED=0 GOOS=linux go build -mod=readonly -trimpath -buildvcs=false \ + -o "${build_dir}/minio-bin" . + ) + ( + cd "${build_dir}/mc" + CGO_ENABLED=0 GOOS=linux go build -mod=readonly -trimpath -buildvcs=false \ + -o "${build_dir}/mc-bin" . + ) + cp "${build_dir}/minio/LICENSE" "${build_dir}/licenses/minio-LICENSE" + cp "${build_dir}/mc/LICENSE" "${build_dir}/licenses/mc-LICENSE" + cat >"${build_dir}/Dockerfile" <<'DOCKERFILE' +FROM alpine:3.22.6@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +COPY minio-bin /usr/local/bin/minio +COPY mc-bin /usr/local/bin/mc +COPY licenses /usr/share/licenses/minio +ENTRYPOINT ["minio"] +DOCKERFILE + printf 'minio/\nmc/\n*.tar.gz\n' >"${build_dir}/.dockerignore" + MINIO_IMAGE="${COMPOSE_PROJECT}:minio" + docker build --tag "$MINIO_IMAGE" "$build_dir" + COMPOSE_OVERRIDE="${E2E_DIR}/minio-compose.yml" + cat >"$COMPOSE_OVERRIDE" <= 27 { + if version >= 28 { wantChunks = 1 } if err := sqldb.QueryRowContext(ctx, "SELECT count(*) FROM embeddings_text WHERE file_id=$1", fileID).Scan(&chunks); err != nil || chunks != wantChunks { @@ -122,9 +122,16 @@ func TestMigrationUpgradeSequence(t *testing.T) { t.Fatalf("producer_agent index=%d, err=%v", producerIdx, err) } } + if version >= 27 { + var tables int + if err := sqldb.QueryRowContext(ctx, `SELECT count(*) FROM pg_tables WHERE schemaname='public' + AND tablename IN ('vaults','vault_commits','vault_entry_revisions','vault_entry_heads')`).Scan(&tables); err != nil || tables != 4 { + t.Fatalf("Vault tables=%d, want=4, err=%v", tables, err) + } + } t.Logf("PASS: strict Goose upgrade to %d; complete history and populated data preserved", version) } - if head >= 27 { + if head >= 28 { _, err := sqldb.ExecContext(ctx, "INSERT INTO embeddings_text(file_id,chunk_index,chunk_text) VALUES($1,0,'duplicate')", fileID) var pgErr *pgconn.PgError if !errors.As(err, &pgErr) || pgErr.Code != "23505" { diff --git a/server/internal/db/migrations/0027_vault_sync.sql b/server/internal/db/migrations/0027_vault_sync.sql new file mode 100644 index 0000000..c69ad2a --- /dev/null +++ b/server/internal/db/migrations/0027_vault_sync.sql @@ -0,0 +1,66 @@ +-- +goose Up +CREATE TABLE vaults ( + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + id uuid NOT NULL, + title text NOT NULL CHECK (octet_length(title) BETWEEN 1 AND 256), + revision bigint NOT NULL DEFAULT 0 CHECK (revision BETWEEN 0 AND 9007199254740991), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, id) +); + +CREATE TABLE vault_commits ( + workspace_id uuid NOT NULL, + vault_id uuid NOT NULL, + revision bigint NOT NULL CHECK (revision BETWEEN 1 AND 9007199254740991), + title text NOT NULL, + actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, vault_id, revision), + FOREIGN KEY (workspace_id, vault_id) REFERENCES vaults(workspace_id, id) ON DELETE CASCADE +); + +CREATE TABLE vault_entry_revisions ( + workspace_id uuid NOT NULL, + vault_id uuid NOT NULL, + note_id uuid NOT NULL, + revision bigint NOT NULL, + path text NOT NULL CHECK (octet_length(path) BETWEEN 1 AND 512), + content text NOT NULL CHECK (octet_length(content) <= 1048576), + properties jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(properties) = 'object' AND octet_length(properties::text) <= 16384), + deleted boolean NOT NULL DEFAULT false, + CHECK (NOT deleted OR content = ''), + PRIMARY KEY (workspace_id, vault_id, note_id, revision), + FOREIGN KEY (workspace_id, vault_id, revision) REFERENCES vault_commits(workspace_id, vault_id, revision) ON DELETE CASCADE +); + +CREATE TABLE vault_entry_heads ( + workspace_id uuid NOT NULL, + vault_id uuid NOT NULL, + note_id uuid NOT NULL, + revision bigint NOT NULL, + path text NOT NULL, + deleted boolean NOT NULL, + PRIMARY KEY (workspace_id, vault_id, note_id), + FOREIGN KEY (workspace_id, vault_id, note_id, revision) + REFERENCES vault_entry_revisions(workspace_id, vault_id, note_id, revision) ON DELETE CASCADE +); +-- Blob identity is never note identity. Separate empty notes keep separate IDs. +CREATE UNIQUE INDEX vault_entry_active_path ON vault_entry_heads(workspace_id, vault_id, lower(path)) WHERE NOT deleted; + +-- Content revisions are append-only; head pointers are the mutable projection. +-- +goose StatementBegin +CREATE FUNCTION reject_vault_entry_revision_update() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Vault entry revisions are immutable' USING ERRCODE = '23514'; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER vault_entry_revisions_immutable BEFORE UPDATE ON vault_entry_revisions + FOR EACH ROW EXECUTE FUNCTION reject_vault_entry_revision_update(); + +-- +goose Down +DROP TABLE vault_entry_heads; +DROP TABLE vault_entry_revisions; +DROP FUNCTION reject_vault_entry_revision_update(); +DROP TABLE vault_commits; +DROP TABLE vaults; diff --git a/server/internal/db/migrations/0027_data_plane_hygiene.sql b/server/internal/db/migrations/0028_data_plane_hygiene.sql similarity index 100% rename from server/internal/db/migrations/0027_data_plane_hygiene.sql rename to server/internal/db/migrations/0028_data_plane_hygiene.sql diff --git a/server/internal/db/migrations_test.go b/server/internal/db/migrations_test.go index a4c1338..6b1f3dc 100644 --- a/server/internal/db/migrations_test.go +++ b/server/internal/db/migrations_test.go @@ -82,7 +82,7 @@ func TestEmbeddingsTextUniqueConstraint(t *testing.T) { VALUES ($1, 0, 'legacy duplicate')`, fileID); err != nil { t.Fatal(err) } - migration, err := migrationsFS.ReadFile("migrations/0027_data_plane_hygiene.sql") + migration, err := migrationsFS.ReadFile("migrations/0028_data_plane_hygiene.sql") if err != nil { t.Fatal(err) } diff --git a/server/internal/vaultsync/README.md b/server/internal/vaultsync/README.md new file mode 100644 index 0000000..5ebeee1 --- /dev/null +++ b/server/internal/vaultsync/README.md @@ -0,0 +1,6 @@ +# Workspace Vault sync + +The canonical wire contract, bounds and compatibility boundaries are documented +in [docs/VAULT_SYNC.md](../../../docs/VAULT_SYNC.md). + +The snapshot schema is [vault-snapshot.v1](../../../docs/schemas/vault-snapshot.v1.schema.json). diff --git a/server/internal/vaultsync/service.go b/server/internal/vaultsync/service.go new file mode 100644 index 0000000..402eb8c --- /dev/null +++ b/server/internal/vaultsync/service.go @@ -0,0 +1,470 @@ +// Package vaultsync stores workspace-scoped text Vaults with append-only +// revisions and an atomic Vault-wide compare-and-swap head. File/blob upload +// deduplication is deliberately outside this logical note identity model. +package vaultsync + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/PeterGuy326/mem/server/internal/workspacelock" +) + +const ( + SchemaVersion = "vault-snapshot.v1" + MaxFileBytes = 1 << 20 + MaxPropertiesBytes = 16 << 10 + MaxCurrentBytes = 16 << 20 + MaxHistoryBytes = 64 << 20 + MaxEntries = 1000 + MaxMutationEntries = 512 + MaxVaults = 64 + MaxCommits = 10000 + MaxSafeRevision int64 = 9007199254740991 +) + +var ( + ErrInvalid = errors.New("invalid Vault request") + ErrLimit = errors.New("Vault limit exceeded") + ErrPathConflict = errors.New("Vault path conflict") + ErrUnavailable = errors.New("Vault sync is unavailable") +) + +type RevisionConflict struct{ CurrentRevision int64 } + +func (e *RevisionConflict) Error() string { return "Vault revision conflict" } + +type Mutation struct { + NoteID uuid.UUID `json:"noteId"` + Path string `json:"path"` + Content string `json:"content"` + Deleted bool `json:"deleted,omitempty"` + Properties json.RawMessage `json:"properties,omitempty"` +} + +type Entry struct { + NoteID uuid.UUID `json:"noteId"` + Path string `json:"path"` + Content string `json:"content"` + Revision int64 `json:"revision"` + Deleted bool `json:"deleted"` + Properties json.RawMessage `json:"properties"` +} + +type Snapshot struct { + SchemaVersion string `json:"schemaVersion"` + VaultID uuid.UUID `json:"vaultId"` + Title string `json:"title"` + Revision int64 `json:"revision"` + UpdatedAt *time.Time `json:"updatedAt"` + Entries []Entry `json:"entries"` +} + +type Summary struct { + VaultID uuid.UUID `json:"vaultId"` + Title string `json:"title"` + Revision int64 `json:"revision"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CommitCommand struct { + WorkspaceID uuid.UUID + ActorUserID uuid.UUID + VaultID uuid.UUID + BaseRevision int64 + Title *string + Entries []Mutation +} + +type Service struct{ pool *pgxpool.Pool } + +func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } +func ScopePath(id uuid.UUID) string { return "/Vaults/" + id.String() } + +func ParseVaultID(raw string) (uuid.UUID, error) { + id, err := uuid.Parse(raw) + if err != nil || id == uuid.Nil || id.String() != raw { + return uuid.Nil, ErrInvalid + } + return id, nil +} + +func ValidatePath(value string) error { + if !utf8.ValidString(value) || len(value) == 0 || len(value) > 512 || path.IsAbs(value) || path.Clean(value) != value || + strings.ContainsAny(value, "\\:*?\"<>|") || strings.IndexFunc(value, unicode.IsControl) >= 0 { + return ErrInvalid + } + for _, part := range strings.Split(value, "/") { + if part == "." || part == ".." || strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") { + return ErrInvalid + } + } + return nil +} + +func normalizeProperties(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return nil, nil + } // Existing metadata is preserved when omitted. + if len(raw) > MaxPropertiesBytes || !utf8.Valid(raw) { + return nil, ErrLimit + } + var value map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil || value == nil { + return nil, ErrInvalid + } + if containsZero(value) { + return nil, ErrInvalid + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, ErrInvalid + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, ErrInvalid + } + // PostgreSQL jsonb may add spaces between keys/values; this bound applies + // to its actual persisted representation as well as the compact wire value. + var indented bytes.Buffer + if err := json.Indent(&indented, encoded, "", ""); err != nil || indented.Len() > MaxPropertiesBytes { + return nil, ErrLimit + } + return encoded, nil +} + +func containsZero(value any) bool { + switch typed := value.(type) { + case string: + return strings.ContainsRune(typed, 0) + case map[string]any: + for key, item := range typed { + if strings.ContainsRune(key, 0) || containsZero(item) { + return true + } + } + case []any: + for _, item := range typed { + if containsZero(item) { + return true + } + } + } + return false +} + +func NormalizeCommand(cmd CommitCommand) (CommitCommand, error) { + if cmd.WorkspaceID == uuid.Nil || cmd.ActorUserID == uuid.Nil || cmd.VaultID == uuid.Nil || cmd.BaseRevision < 0 || + cmd.BaseRevision >= MaxSafeRevision || len(cmd.Entries) > MaxMutationEntries || (len(cmd.Entries) == 0 && cmd.Title == nil) { + return cmd, ErrInvalid + } + if cmd.Title != nil && (!utf8.ValidString(*cmd.Title) || len(*cmd.Title) == 0 || len(*cmd.Title) > 256 || + strings.TrimSpace(*cmd.Title) == "" || strings.IndexFunc(*cmd.Title, unicode.IsControl) >= 0) { + return cmd, ErrInvalid + } + seen := make(map[uuid.UUID]bool) + cmd.Entries = append([]Mutation(nil), cmd.Entries...) + for i, entry := range cmd.Entries { + if entry.NoteID == uuid.Nil || seen[entry.NoteID] || ValidatePath(entry.Path) != nil || !utf8.ValidString(entry.Content) || + strings.IndexFunc(entry.Content, func(r rune) bool { return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' }) >= 0 || (entry.Deleted && entry.Content != "") { + return cmd, ErrInvalid + } + if len(entry.Content) > MaxFileBytes { + return cmd, ErrLimit + } + seen[entry.NoteID] = true + properties, err := normalizeProperties(entry.Properties) + if err != nil { + return cmd, err + } + cmd.Entries[i].Properties = properties + } + return cmd, nil +} + +// plan applies a delta without silently discarding notes, tombstones or a +// conflict. Properties are data and can never grant tools or membership. +func plan(current Snapshot, cmd CommitCommand) (Snapshot, error) { + if current.Revision != cmd.BaseRevision { + return Snapshot{}, &RevisionConflict{current.Revision} + } + entries := make(map[uuid.UUID]Entry, len(current.Entries)+len(cmd.Entries)) + for _, entry := range current.Entries { + entries[entry.NoteID] = entry + } + next := current + next.Revision++ + if cmd.Title != nil { + next.Title = *cmd.Title + } + if next.Title == "" { + next.Title = "Vault " + cmd.VaultID.String()[:8] + } + for _, mutation := range cmd.Entries { + properties := mutation.Properties + if len(properties) == 0 { + properties = entries[mutation.NoteID].Properties + } + if len(properties) == 0 { + properties = json.RawMessage(`{}`) + } + entries[mutation.NoteID] = Entry{NoteID: mutation.NoteID, Path: mutation.Path, Content: mutation.Content, + Deleted: mutation.Deleted, Properties: append(json.RawMessage(nil), properties...), Revision: next.Revision} + } + if len(entries) > MaxEntries { + return Snapshot{}, ErrLimit + } + paths := make(map[string]bool) + total := 0 + next.Entries = make([]Entry, 0, len(entries)) + for _, entry := range entries { + if !entry.Deleted { + key := strings.ToLower(entry.Path) + if paths[key] { + return Snapshot{}, ErrPathConflict + } + paths[key] = true + } + total += len(entry.Content) + len(entry.Properties) + len(entry.Path) + if total > MaxCurrentBytes { + return Snapshot{}, ErrLimit + } + next.Entries = append(next.Entries, entry) + } + sort.Slice(next.Entries, func(i, j int) bool { + if next.Entries[i].Path == next.Entries[j].Path { + return next.Entries[i].NoteID.String() < next.Entries[j].NoteID.String() + } + return next.Entries[i].Path < next.Entries[j].Path + }) + return next, nil +} + +type querier interface { + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row +} + +func readSnapshot(ctx context.Context, q querier, workspaceID, vaultID uuid.UUID) (Snapshot, error) { + snapshot := Snapshot{SchemaVersion: SchemaVersion, VaultID: vaultID, Entries: []Entry{}} + var updated time.Time + err := q.QueryRow(ctx, `SELECT title, revision, updated_at FROM vaults WHERE workspace_id=$1 AND id=$2`, workspaceID, vaultID). + Scan(&snapshot.Title, &snapshot.Revision, &updated) + if errors.Is(err, pgx.ErrNoRows) { + return snapshot, nil + } + if err != nil { + return Snapshot{}, err + } + snapshot.UpdatedAt = &updated + rows, err := q.Query(ctx, `SELECT r.note_id, r.path, r.content, r.revision, r.deleted, r.properties + FROM vault_entry_heads h JOIN vault_entry_revisions r USING(workspace_id,vault_id,note_id,revision) + WHERE h.workspace_id=$1 AND h.vault_id=$2 ORDER BY r.path COLLATE "C", r.note_id LIMIT $3`, workspaceID, vaultID, MaxEntries+1) + if err != nil { + return Snapshot{}, err + } + defer rows.Close() + for rows.Next() { + var entry Entry + if err := rows.Scan(&entry.NoteID, &entry.Path, &entry.Content, &entry.Revision, &entry.Deleted, &entry.Properties); err != nil { + return Snapshot{}, err + } + snapshot.Entries = append(snapshot.Entries, entry) + } + if len(snapshot.Entries) > MaxEntries { + return Snapshot{}, ErrLimit + } + return snapshot, rows.Err() +} + +func (s *Service) Snapshot(ctx context.Context, workspaceID, vaultID uuid.UUID) (Snapshot, error) { + if s == nil || s.pool == nil { + return Snapshot{}, ErrUnavailable + } + if workspaceID == uuid.Nil || vaultID == uuid.Nil { + return Snapshot{}, ErrInvalid + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return Snapshot{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + snapshot, err := readSnapshot(ctx, tx, workspaceID, vaultID) + if err != nil { + return Snapshot{}, err + } + if err := tx.Commit(ctx); err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +func (s *Service) List(ctx context.Context, workspaceID uuid.UUID) ([]Summary, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + if workspaceID == uuid.Nil { + return nil, ErrInvalid + } + rows, err := s.pool.Query(ctx, `SELECT id,title,revision,updated_at FROM vaults WHERE workspace_id=$1 ORDER BY updated_at DESC,id LIMIT $2`, workspaceID, MaxVaults+1) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Summary{} + for rows.Next() { + var item Summary + if err := rows.Scan(&item.VaultID, &item.Title, &item.Revision, &item.UpdatedAt); err != nil { + return nil, err + } + result = append(result, item) + } + if len(result) > MaxVaults { + return nil, ErrLimit + } + return result, rows.Err() +} + +func (s *Service) Commit(ctx context.Context, raw CommitCommand) (Snapshot, error) { + if s == nil || s.pool == nil { + return Snapshot{}, ErrUnavailable + } + cmd, err := NormalizeCommand(raw) + if err != nil { + return Snapshot{}, err + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return Snapshot{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + // Take the existing stronger workspace coordination lock first. Ordinary + // file writers retain KEY SHARE compatibility, while Vault commits and + // creation quotas serialize across distinct Vault IDs in this workspace. + if _, err := workspacelock.ForAIProfileCoordination(ctx, tx, cmd.WorkspaceID); err != nil { + return Snapshot{}, err + } + var count int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM vaults WHERE workspace_id=$1`, cmd.WorkspaceID).Scan(&count); err != nil { + return Snapshot{}, err + } + var exists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM vaults WHERE workspace_id=$1 AND id=$2)`, cmd.WorkspaceID, cmd.VaultID).Scan(&exists); err != nil { + return Snapshot{}, err + } + if !exists && count >= MaxVaults { + return Snapshot{}, ErrLimit + } + defaultTitle := "Vault " + cmd.VaultID.String()[:8] + if _, err := tx.Exec(ctx, `INSERT INTO vaults(workspace_id,id,title) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, cmd.WorkspaceID, cmd.VaultID, defaultTitle); err != nil { + return Snapshot{}, err + } + var revision int64 + if err := tx.QueryRow(ctx, `SELECT revision FROM vaults WHERE workspace_id=$1 AND id=$2 FOR UPDATE`, cmd.WorkspaceID, cmd.VaultID).Scan(&revision); err != nil { + return Snapshot{}, err + } + if revision != cmd.BaseRevision { + return Snapshot{}, &RevisionConflict{revision} + } + current, err := readSnapshot(ctx, tx, cmd.WorkspaceID, cmd.VaultID) + if err != nil { + return Snapshot{}, err + } + next, err := plan(current, cmd) + if err != nil { + return Snapshot{}, err + } + var historyBytes int64 + var commits int + if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(octet_length(content)+octet_length(properties::text)+octet_length(path)),0) + FROM vault_entry_revisions WHERE workspace_id=$1 AND vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&historyBytes); err != nil { + return Snapshot{}, err + } + if err := tx.QueryRow(ctx, `SELECT count(*) FROM vault_commits WHERE workspace_id=$1 AND vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&commits); err != nil { + return Snapshot{}, err + } + changed := make(map[uuid.UUID]Entry) + for _, entry := range next.Entries { + if entry.Revision == next.Revision { + changed[entry.NoteID] = entry + historyBytes += int64(len(entry.Content) + len(entry.Path) + len(entry.Properties)) + } + } + if historyBytes > MaxHistoryBytes || commits >= MaxCommits { + return Snapshot{}, ErrLimit + } + updated := time.Now().UTC() + if _, err := tx.Exec(ctx, `INSERT INTO vault_commits(workspace_id,vault_id,revision,title,actor_user_id,created_at) VALUES($1,$2,$3,$4,$5,$6)`, + cmd.WorkspaceID, cmd.VaultID, next.Revision, next.Title, cmd.ActorUserID, updated); err != nil { + return Snapshot{}, err + } + ids := make([]uuid.UUID, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + // Delete only mutable pointers inside the atomic transaction. This permits + // swaps/renames without a transient uniqueness conflict; history remains. + if _, err := tx.Exec(ctx, `DELETE FROM vault_entry_heads WHERE workspace_id=$1 AND vault_id=$2 AND note_id=ANY($3::uuid[])`, cmd.WorkspaceID, cmd.VaultID, ids); err != nil { + return Snapshot{}, err + } + for _, entry := range changed { + if _, err := tx.Exec(ctx, `INSERT INTO vault_entry_revisions(workspace_id,vault_id,note_id,revision,path,content,deleted,properties) + VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)`, cmd.WorkspaceID, cmd.VaultID, entry.NoteID, entry.Revision, entry.Path, entry.Content, entry.Deleted, string(entry.Properties)); err != nil { + return Snapshot{}, classifyWriteError(err) + } + if _, err := tx.Exec(ctx, `INSERT INTO vault_entry_heads(workspace_id,vault_id,note_id,revision,path,deleted) VALUES($1,$2,$3,$4,$5,$6)`, + cmd.WorkspaceID, cmd.VaultID, entry.NoteID, entry.Revision, entry.Path, entry.Deleted); err != nil { + return Snapshot{}, classifyWriteError(err) + } + } + // Enforce quotas against PostgreSQL's actual jsonb representation, including + // its spacing, rather than estimating persisted bytes from compact JSON. + var persistedCurrent, persistedHistory int64 + if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(octet_length(r.content)+octet_length(r.properties::text)+octet_length(r.path)),0) + FROM vault_entry_heads h JOIN vault_entry_revisions r USING(workspace_id,vault_id,note_id,revision) + WHERE h.workspace_id=$1 AND h.vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&persistedCurrent); err != nil { + return Snapshot{}, err + } + if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(octet_length(content)+octet_length(properties::text)+octet_length(path)),0) + FROM vault_entry_revisions WHERE workspace_id=$1 AND vault_id=$2`, cmd.WorkspaceID, cmd.VaultID).Scan(&persistedHistory); err != nil { + return Snapshot{}, err + } + if persistedCurrent > MaxCurrentBytes || persistedHistory > MaxHistoryBytes { + return Snapshot{}, ErrLimit + } + if _, err := tx.Exec(ctx, `UPDATE vaults SET title=$3,revision=$4,updated_at=$5 WHERE workspace_id=$1 AND id=$2`, cmd.WorkspaceID, cmd.VaultID, next.Title, next.Revision, updated); err != nil { + return Snapshot{}, err + } + if err := tx.Commit(ctx); err != nil { + return Snapshot{}, err + } + next.UpdatedAt = &updated + return next, nil +} + +func classifyWriteError(err error) error { + var pgError *pgconn.PgError + if errors.As(err, &pgError) && pgError.Code == "23505" { + return ErrPathConflict + } + if errors.As(err, &pgError) && pgError.Code == "23514" { + return ErrLimit + } + return fmt.Errorf("Vault write failed: %w", err) +} diff --git a/server/internal/vaultsync/service_integration_test.go b/server/internal/vaultsync/service_integration_test.go new file mode 100644 index 0000000..c56086a --- /dev/null +++ b/server/internal/vaultsync/service_integration_test.go @@ -0,0 +1,235 @@ +package vaultsync_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" + + "github.com/PeterGuy326/mem/server/internal/api" + "github.com/PeterGuy326/mem/server/internal/auth" + memdb "github.com/PeterGuy326/mem/server/internal/db" + "github.com/PeterGuy326/mem/server/internal/vaultsync" + "github.com/PeterGuy326/mem/server/internal/workspace" +) + +// Uses only an explicitly selected isolated _test database. No production +// migration, credential recovery, or container restart occurs in this test. +func TestVaultSyncPostgres(t *testing.T) { + dsn := os.Getenv("MEM_TEST_DB") + if dsn == "" { + t.Skip("MEM_TEST_DB not set; real PostgreSQL Vault verification was not executed") + } + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal("invalid isolated test database configuration") + } + if !strings.HasSuffix(config.ConnConfig.Database, "_test") { + t.Fatal("refusing to modify a database whose name does not end in _test") + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + database, err := memdb.Open(ctx, dsn) + if err != nil { + t.Fatal("could not connect to isolated test database") + } + t.Cleanup(database.Close) + if err := database.Migrate(ctx); err != nil { + t.Fatalf("isolated migrations: %v", err) + } + createTenant := func() (uuid.UUID, uuid.UUID) { + user, ws := uuid.New(), uuid.New() + if _, err := database.Pool.Exec(ctx, `INSERT INTO users(id,email,password_hash) VALUES($1,$2,'test-hash')`, user, user.String()+"@vault.test"); err != nil { + t.Fatal(err) + } + if _, err := database.Pool.Exec(ctx, `INSERT INTO workspaces(id,name,resource_owner_user_id) VALUES($1,'Vault test',$2)`, ws, user); err != nil { + t.Fatal(err) + } + if _, err := database.Pool.Exec(ctx, `INSERT INTO workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`, ws, user); err != nil { + t.Fatal(err) + } + return user, ws + } + userA, wsA := createTenant() + userB, wsB := createTenant() + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = database.Pool.Exec(cleanupCtx, `DELETE FROM users WHERE id=$1 OR id=$2`, userA, userB) + }) + service := vaultsync.New(database.Pool) + vault, noteA, noteB := uuid.New(), uuid.New(), uuid.New() + command := vaultsync.CommitCommand{WorkspaceID: wsA, ActorUserID: userA, VaultID: vault, Entries: []vaultsync.Mutation{ + {NoteID: noteA, Path: "岗位/第一份 空笔记.md", Content: ""}, {NoteID: noteB, Path: "共享/第二份 空笔记.md", Content: ""}}} + empty, err := service.Snapshot(ctx, wsA, vault) + if err != nil || empty.Revision != 0 || len(empty.Entries) != 0 { + t.Fatal("new Vault snapshot", err) + } + first, err := service.Commit(ctx, command) + if err != nil || first.Revision != 1 || len(first.Entries) != 2 { + t.Fatalf("two independent empty notes: %+v %v", first, err) + } + // Two clients read the same head, then race a different edit. Exactly one + // succeeds; the other gets a conflict without overwriting the winner. + var wg sync.WaitGroup + var lock sync.Mutex + successes, conflicts := 0, 0 + for _, text := range []string{"客户端 A 内容", "客户端 B 内容"} { + wg.Add(1) + go func(text string) { + defer wg.Done() + cmd := command + cmd.BaseRevision = 1 + cmd.Entries = []vaultsync.Mutation{{NoteID: noteA, Path: command.Entries[0].Path, Content: text}} + _, err := service.Commit(ctx, cmd) + lock.Lock() + defer lock.Unlock() + var conflict *vaultsync.RevisionConflict + if err == nil { + successes++ + } else if errors.As(err, &conflict) && conflict.CurrentRevision == 2 { + conflicts++ + } else { + t.Errorf("unexpected concurrent result: %v", err) + } + }(text) + } + wg.Wait() + if successes != 1 || conflicts != 1 { + t.Fatalf("CAS race successes=%d conflicts=%d", successes, conflicts) + } + second, err := service.Snapshot(ctx, wsA, vault) + if err != nil || second.Revision != 2 || len(second.Entries) != 2 { + t.Fatal("consistent snapshot after race", err) + } + other, err := service.Snapshot(ctx, wsB, vault) + if err != nil || other.Revision != 0 || len(other.Entries) != 0 { + t.Fatal("another workspace saw the Vault", err) + } + // Old text revisions remain unchanged and cannot be updated in place. + var original string + if err := database.Pool.QueryRow(ctx, `SELECT content FROM vault_entry_revisions WHERE workspace_id=$1 AND vault_id=$2 AND note_id=$3 AND revision=1`, wsA, vault, noteA).Scan(&original); err != nil || original != "" { + t.Fatal("original revision changed", err) + } + if _, err := database.Pool.Exec(ctx, `UPDATE vault_entry_revisions SET content='rewrite' WHERE workspace_id=$1 AND vault_id=$2 AND note_id=$3 AND revision=1`, wsA, vault, noteA); err == nil { + t.Fatal("immutable revision accepted UPDATE") + } + command.BaseRevision = 2 + command.Entries = []vaultsync.Mutation{{NoteID: noteA, Path: "岗位/第一份 空笔记.md", Deleted: true}} + third, err := service.Commit(ctx, command) + if err != nil || third.Revision != 3 || len(third.Entries) != 2 { + t.Fatal("delete tombstone commit", err) + } + for _, entry := range third.Entries { + if entry.NoteID == noteA && (!entry.Deleted || entry.Content != "") { + t.Fatal("missing tombstone") + } + } + // Test swap/rename under the actual unique index, then title-only commit. + command.BaseRevision = 3 + command.Entries = []vaultsync.Mutation{{NoteID: noteA, Path: "共享/第二份 空笔记.md", Content: "恢复"}, {NoteID: noteB, Path: "岗位/第一份 空笔记.md", Content: "交换"}} + fourth, err := service.Commit(ctx, command) + if err != nil || fourth.Revision != 4 { + t.Fatal("atomic path swap", err) + } + title := "组织知识库" + command.BaseRevision = 4 + command.Entries = nil + command.Title = &title + fifth, err := service.Commit(ctx, command) + if err != nil || fifth.Revision != 5 || fifth.Title != title { + t.Fatal("title CAS", err) + } + listed, err := service.List(ctx, wsA) + if err != nil || len(listed) != 1 || listed[0].VaultID != vault { + t.Fatal("Vault discovery", err) + } + // Real HTTP + canonical auth + workspace middleware use sandbox-only tokens. + authService := auth.New(database.Pool) + ttl := time.Now().Add(time.Minute) + writer, _, err := authService.CreateToken(ctx, userA, &wsA, "vault-test-writer", []string{auth.ScopeRead, auth.ScopeWrite}, nil, &ttl, false) + if err != nil { + t.Fatal(err) + } + reader, _, err := authService.CreateToken(ctx, userA, &wsA, "vault-test-reader", []string{auth.ScopeRead}, nil, &ttl, false) + if err != nil { + t.Fatal(err) + } + foreign, _, err := authService.CreateToken(ctx, userB, &wsB, "vault-test-foreign", []string{auth.ScopeRead}, nil, &ttl, false) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer((&api.Server{Auth: authService, Workspace: workspace.New(database.Pool), VaultSync: service, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}).Router()) + defer server.Close() + request := func(method, route, token string, body any) (int, []byte) { + t.Helper() + var encoded []byte + if body != nil { + encoded, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + req, err := http.NewRequestWithContext(ctx, method, server.URL+route, bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, payload + } + status, body := request("GET", "/v1/vault/snapshot?vaultId="+vault.String(), reader, nil) + if status != 200 || !bytes.Contains(body, []byte("组织知识库")) { + t.Fatal("authenticated snapshot HTTP", status) + } + status, _ = request("POST", "/v1/vault/commit", reader, map[string]any{"vaultId": vault, "baseRevision": 5, "title": "拒绝只读写入", "entries": []any{}}) + if status != 403 { + t.Fatal("read-only credential could commit", status) + } + status, body = request("GET", "/v1/vault/snapshot?vaultId="+vault.String(), foreign, nil) + if status != 200 || !bytes.Contains(body, []byte(`"revision":0`)) || bytes.Contains(body, []byte("组织知识库")) { + t.Fatal("HTTP cross-workspace isolation", status) + } + status, body = request("POST", "/v1/vault/commit", writer, map[string]any{"vaultId": vault, "baseRevision": 1, "title": "stale", "entries": []any{}}) + if status != 409 || !bytes.Contains(body, []byte(`"currentRevision":5`)) { + t.Fatalf("HTTP CAS conflict: %d %s", status, body) + } + // The additive migration is reversible on a disposable test deployment; + // dropping Vault tables does not change existing files or auth tables. + server.Close() + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal("could not open isolated migration rehearsal") + } + defer sqlDB.Close() + goose.SetBaseFS(os.DirFS("../db")) + if err := goose.DownToContext(ctx, sqlDB, "migrations", 26); err != nil { + t.Fatalf("isolated Vault down migration: %v", err) + } + if err := database.Migrate(ctx); err != nil { + t.Fatalf("isolated Vault up migration: %v", err) + } +} diff --git a/server/internal/vaultsync/service_test.go b/server/internal/vaultsync/service_test.go new file mode 100644 index 0000000..cd81ccb --- /dev/null +++ b/server/internal/vaultsync/service_test.go @@ -0,0 +1,150 @@ +package vaultsync + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func validCommand() CommitCommand { + return CommitCommand{WorkspaceID: uuid.New(), ActorUserID: uuid.New(), VaultID: uuid.New(), + Entries: []Mutation{{NoteID: uuid.New(), Path: "知识/空白笔记.md", Content: ""}}} +} + +func TestLogicalIdentityEmptyNotesAndCAS(t *testing.T) { + cmd := validCommand() + cmd.Entries = append(cmd.Entries, Mutation{NoteID: uuid.New(), Path: "知识/另一个空笔记.md", Content: ""}) + empty := Snapshot{SchemaVersion: SchemaVersion, VaultID: cmd.VaultID, Entries: []Entry{}} + first, err := plan(empty, cmd) + if err != nil || first.Revision != 1 || len(first.Entries) != 2 || first.Entries[0].NoteID == first.Entries[1].NoteID { + t.Fatalf("separate empty note identities: %+v, %v", first, err) + } + _, err = plan(first, cmd) + var conflict *RevisionConflict + if !errors.As(err, &conflict) || conflict.CurrentRevision != 1 { + t.Fatalf("stale client must conflict: %v", err) + } + if len(first.Entries) != 2 || first.Entries[0].Content != "" { + t.Fatal("conflict modified original snapshot") + } +} + +func TestDeltaPreservesUntouchedNotesMetadataAndTombstones(t *testing.T) { + cmd := validCommand() + cmd.Entries[0].Properties = json.RawMessage(`{"positionIds":["writer"],"source":"user"}`) + first, _ := plan(Snapshot{VaultID: cmd.VaultID}, cmd) + noteID := cmd.Entries[0].NoteID + cmd.BaseRevision = 1 + cmd.Entries = []Mutation{{NoteID: noteID, Path: "知识/改名.md", Content: "第二版"}, + {NoteID: uuid.New(), Path: "共享 SOP.md", Content: "流程"}} + second, err := plan(first, cmd) + if err != nil || len(second.Entries) != 2 { + t.Fatal(err) + } + for _, entry := range second.Entries { + if entry.NoteID == noteID && !strings.Contains(string(entry.Properties), "writer") { + t.Fatal("omitted properties erased metadata") + } + } + cmd.BaseRevision = 2 + cmd.Entries = []Mutation{{NoteID: noteID, Path: "知识/改名.md", Deleted: true}} + third, err := plan(second, cmd) + if err != nil || len(third.Entries) != 2 { + t.Fatal("deletion must retain tombstone and unrelated note", err) + } + for _, entry := range third.Entries { + if entry.NoteID == noteID && (!entry.Deleted || entry.Content != "" || entry.Revision != 3) { + t.Fatal("invalid tombstone") + } + } + if first.Entries[0].Path != "知识/空白笔记.md" || first.Revision != 1 { + t.Fatal("later revisions mutated previous snapshot") + } +} + +func TestPathsDuplicatesAndUntrustedPropertiesAreValidated(t *testing.T) { + for _, name := range []string{"../secret.md", "/absolute.md", "a\\b.md", "a/../b.md", "a//b.md", "a\x00.md", "C:notes.md", "dir/a ", "dir/a.", "a?.md"} { + if ValidatePath(name) == nil { + t.Fatalf("unsafe path accepted: %q", name) + } + } + if err := ValidatePath("客户 ACME/项目 決策.md"); err != nil { + t.Fatal(err) + } + for _, properties := range []string{`null`, `[]`, `{"source":"\u0000"}`, `{"source":true} trailing`} { + cmd := validCommand() + cmd.Entries[0].Properties = json.RawMessage(properties) + if _, err := NormalizeCommand(cmd); err == nil { + t.Fatalf("invalid metadata accepted: %s", properties) + } + } + cmd := validCommand() + cmd.Entries[0].Properties = json.RawMessage(`{"tools":["write"],"authority":"admin"}`) + normalized, err := NormalizeCommand(cmd) + if err != nil || normalized.ActorUserID != cmd.ActorUserID || normalized.WorkspaceID != cmd.WorkspaceID { + t.Fatal("metadata changed authorization", err) + } + cmd.Entries = append(cmd.Entries, cmd.Entries[0]) + if _, err := NormalizeCommand(cmd); !errors.Is(err, ErrInvalid) { + t.Fatal("duplicate note ID accepted") + } + cmd = validCommand() + cmd.Entries[0].Content = "secret" + cmd.Entries[0].Deleted = true + if _, err := NormalizeCommand(cmd); !errors.Is(err, ErrInvalid) { + t.Fatal("tombstone retained content") + } +} + +func TestActivePathsConflictCaseInsensitivelyAndSwapsAreValid(t *testing.T) { + cmd := validCommand() + cmd.Entries = []Mutation{{NoteID: uuid.New(), Path: "A.md"}, {NoteID: uuid.New(), Path: "a.md"}} + if _, err := plan(Snapshot{VaultID: cmd.VaultID}, cmd); !errors.Is(err, ErrPathConflict) { + t.Fatal("Windows path collision accepted") + } + cmd.Entries[1].Path = "B.md" + first, err := plan(Snapshot{VaultID: cmd.VaultID}, cmd) + if err != nil { + t.Fatal(err) + } + cmd.BaseRevision = 1 + cmd.Entries[0].Path = "B.md" + cmd.Entries[1].Path = "A.md" + if _, err := plan(first, cmd); err != nil { + t.Fatal("atomic path swap rejected", err) + } +} + +func TestBoundsAndTitleOnlyCAS(t *testing.T) { + cmd := validCommand() + cmd.Entries[0].Content = strings.Repeat("x", MaxFileBytes+1) + if _, err := NormalizeCommand(cmd); !errors.Is(err, ErrLimit) { + t.Fatal("oversize note accepted") + } + cmd = validCommand() + cmd.Entries = nil + if _, err := NormalizeCommand(cmd); err == nil { + t.Fatal("empty mutation accepted") + } + title := "岗位知识库" + cmd.Title = &title + cmd, err := NormalizeCommand(cmd) + if err != nil { + t.Fatal(err) + } + result, err := plan(Snapshot{VaultID: cmd.VaultID}, cmd) + if err != nil || result.Title != title || result.Revision != 1 || len(result.Entries) != 0 { + t.Fatal("title-only commit failed", err) + } + large := Snapshot{VaultID: cmd.VaultID} + for i := 0; i < MaxEntries; i++ { + large.Entries = append(large.Entries, Entry{NoteID: uuid.New(), Path: "old.md", Deleted: true, Properties: json.RawMessage(`{}`)}) + } + cmd.Entries = []Mutation{{NoteID: uuid.New(), Path: "new.md"}} + if _, err := plan(large, cmd); !errors.Is(err, ErrLimit) { + t.Fatal("tombstones were excluded from note-count bound") + } +} diff --git a/web/package-lock.json b/web/package-lock.json index ce35cc0..5eec4fe 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,16 +18,17 @@ "react-dom": "^19.2.8", "react-router": "^8.3.0", "sonner": "^1.7.0", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@vitejs/plugin-react": "^6.0.4", "autoprefixer": "^10.4.20", "eslint": "^8.57.1", @@ -38,7 +39,7 @@ "playwright": "^1.60.0", "postcss": "^8.5.23", "prettier": "^3.3.3", - "tailwindcss": "^3.4.14", + "tailwindcss": "^4.3.3", "typescript": "^5.6.3", "vite": "^8.1.5", "vitest": "^4.1.10" @@ -312,7 +313,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" @@ -325,7 +325,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -337,7 +336,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -396,9 +394,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -502,9 +500,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -645,6 +643,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1724,159 +1733,691 @@ "dev": true, "license": "MIT" }, - "node_modules/@tanstack/query-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", - "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" } }, - "node_modules/@tanstack/react-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", - "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", - "license": "MIT", + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "@tanstack/query-core": "5.100.10" + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "peerDependencies": { - "react": "^18 || ^19" + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=22", - "npm": ">=6", - "yarn": ">=1" + "node": ">= 12.0.0" }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@testing-library/user-event": { - "version": "14.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", - "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12", - "npm": ">=6" + "node": ">= 12.0.0" }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "peer": true + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, @@ -1935,122 +2476,159 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2058,75 +2636,88 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -2339,34 +2930,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2396,16 +2959,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2483,40 +3036,27 @@ "require-from-string": "^2.0.2" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, "node_modules/browserslist": { @@ -2563,16 +3103,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -2621,44 +3151,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -2713,16 +3205,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2793,19 +3275,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2885,33 +3354,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2947,6 +3389,20 @@ "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2960,16 +3416,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -3111,9 +3557,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3225,36 +3671,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3319,19 +3735,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3407,16 +3810,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3472,9 +3865,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3511,26 +3904,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", @@ -3559,19 +3938,6 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -3662,35 +4028,6 @@ "dev": true, "license": "ISC" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3731,16 +4068,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -3766,13 +4093,13 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -4153,26 +4480,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4233,40 +4540,16 @@ "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4278,16 +4561,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4371,18 +4654,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -4419,36 +4690,6 @@ "node": ">=18" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4586,13 +4827,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -4600,16 +4834,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4624,39 +4848,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -4733,133 +4924,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -5073,29 +5137,6 @@ } } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5130,28 +5171,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5275,9 +5294,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5337,16 +5356,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -5358,9 +5367,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5452,29 +5461,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5488,19 +5474,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5522,9 +5495,9 @@ } }, "node_modules/tailwind-merge": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", - "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "license": "MIT", "funding": { "type": "github", @@ -5532,41 +5505,24 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, "engines": { - "node": ">=14.0.0" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/text-table": { @@ -5576,29 +5532,6 @@ "dev": true, "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5694,19 +5627,6 @@ "dev": true, "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -5734,25 +5654,18 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5800,9 +5713,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { @@ -5910,13 +5823,6 @@ } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", diff --git a/web/package.json b/web/package.json index 2988971..177f4d8 100644 --- a/web/package.json +++ b/web/package.json @@ -37,16 +37,17 @@ "react-dom": "^19.2.8", "react-router": "^8.3.0", "sonner": "^1.7.0", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@vitejs/plugin-react": "^6.0.4", "autoprefixer": "^10.4.20", "eslint": "^8.57.1", @@ -57,7 +58,7 @@ "playwright": "^1.60.0", "postcss": "^8.5.23", "prettier": "^3.3.3", - "tailwindcss": "^3.4.14", + "tailwindcss": "^4.3.3", "typescript": "^5.6.3", "vite": "^8.1.5", "vitest": "^4.1.10" diff --git a/web/postcss.config.js b/web/postcss.config.js index 2aa7205..51a6e4e 100644 --- a/web/postcss.config.js +++ b/web/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, }; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 41ef4dd..3e10e37 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1,8 +1,14 @@ @import './design-tokens.generated.css'; +@import 'tailwindcss'; -@tailwind base; -@tailwind components; -@tailwind utilities; +@config '../../tailwind.config.js'; + +/* Preserve the existing v3 utility scale while removing its unpatched + * dependency chain. The product palette remains in the pinned JS config. */ +@theme { + --radius-sm: 0.125rem; + --blur-sm: 4px; +} @layer base { html, @@ -11,6 +17,16 @@ height: 100%; } + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } + + input::placeholder, + textarea::placeholder { + color: rgb(156 163 175); + } + body { @apply bg-bg text-fg font-sans antialiased; font-size: 14px; @@ -48,7 +64,7 @@ /* Focus ring — keyboard only, geometric not glow-y */ :focus-visible { - @apply outline-none ring-2 ring-accent/60 ring-offset-2 ring-offset-bg; + @apply outline-hidden ring-2 ring-accent/60 ring-offset-2 ring-offset-bg; } /* Heading defaults */ @@ -108,3 +124,13 @@ background: linear-gradient(90deg, transparent, rgb(var(--border-strong) / 0.25), transparent); animation: shimmer 1.6s infinite; } + +/* Keep existing class consumers' keyboard outline visible in forced colors. + * These declarations follow the v4 utility layer's outline-style: none. */ +@layer utilities { + .outline-none, + .focus-visible\:outline-none:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + } +} diff --git a/web/theme-acceptance.mjs b/web/theme-acceptance.mjs index a347a50..e2f0bfe 100644 --- a/web/theme-acceptance.mjs +++ b/web/theme-acceptance.mjs @@ -116,6 +116,38 @@ async function assertTheme(page, theme) { assert.equal(await page.locator('[data-toast-theme]').getAttribute('data-toast-theme'), theme); } +async function assertUtilityCompatibility(page) { + await page.keyboard.press('Tab'); + const values = await page.evaluate(() => { + const probe = document.createElement('button'); + probe.className = 'rounded-sm backdrop-blur-sm text-fg bg-accent/10 outline-none'; + const focusProbe = document.createElement('button'); + focusProbe.className = 'focus-visible:outline-none'; + const reference = document.createElement('span'); + reference.style.color = 'rgb(var(--fg) / calc(var(--fg-opacity, 1) * 1))'; + reference.style.backgroundColor = 'rgb(var(--accent) / calc(var(--accent-opacity, 1) * 0.1))'; + document.body.append(probe, focusProbe, reference); focusProbe.focus(); + const rgba = (value) => { + const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; + const context = canvas.getContext('2d'); context.fillStyle = value; context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data]; + }; + const style = getComputedStyle(probe); const focused = getComputedStyle(focusProbe); const expected = getComputedStyle(reference); + const result = { radius: style.borderRadius, blur: style.backdropFilter, cursor: style.cursor, + outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, + focusVisible: focusProbe.matches(':focus-visible'), focusOutlineStyle: focused.outlineStyle, focusOutlineWidth: focused.outlineWidth, + foreground: rgba(style.color), expectedForeground: rgba(expected.color), + accent: rgba(style.backgroundColor), expectedAccent: rgba(expected.backgroundColor) }; + probe.remove(); focusProbe.remove(); reference.remove(); return result; + }); + assert.equal(values.radius, '2px'); assert.equal(values.blur, 'blur(4px)'); assert.equal(values.cursor, 'pointer'); + assert.equal(values.outlineStyle, 'solid'); assert.equal(values.outlineWidth, '2px'); + assert.equal(values.focusVisible, true); assert.equal(values.focusOutlineStyle, 'solid'); assert.equal(values.focusOutlineWidth, '2px'); + for (const [actual, expected] of [[values.foreground, values.expectedForeground], [values.accent, values.expectedAccent]]) { + assert.ok(actual.every((value, index) => Math.abs(value - expected[index]) <= 1), 'theme color/opacity utility changed'); + } +} + const vite = startVite(); let browser; let failure; @@ -135,6 +167,7 @@ try { const lightToggle = defaultPage.getByRole('button', { name: 'Switch to light theme' }); await lightToggle.waitFor(); await assertTheme(defaultPage, 'dark'); + await assertUtilityCompatibility(defaultPage); assert.equal( await defaultPage.evaluate(() => localStorage.getItem('mem.theme')), null, @@ -148,6 +181,10 @@ try { await lightToggle.click(); await assertTheme(defaultPage, 'light'); + await assertUtilityCompatibility(defaultPage); + await defaultPage.emulateMedia({ forcedColors: 'active' }); + await assertUtilityCompatibility(defaultPage); + await defaultPage.emulateMedia({ forcedColors: 'none' }); assert.equal(await defaultPage.evaluate(() => localStorage.getItem('mem.theme')), 'light'); assert.equal( (await defaultPage.evaluate(() => getComputedStyle(document.body).backgroundColor)).replaceAll( diff --git a/worker/pyproject.toml b/worker/pyproject.toml index b965758..76fe1f4 100644 --- a/worker/pyproject.toml +++ b/worker/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # Imaging "pillow>=10.2", # PDF (placeholder — W2 actually uses) - "pypdf>=6.18.0", + "pypdf>=6.19.0", # HTTP "requests>=2.31", "httpx>=0.27", diff --git a/worker/uv.lock b/worker/uv.lock index 482ddc8..82e7d3f 100644 --- a/worker/uv.lock +++ b/worker/uv.lock @@ -3,12 +3,12 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'darwin'", - "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", - "python_full_version == '3.12.*' and sys_platform != 'darwin'", - "python_full_version < '3.12' and sys_platform != 'darwin'", "python_full_version >= '3.15' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version < '3.12' and sys_platform != 'darwin'", "python_full_version < '3.12' and sys_platform == 'darwin'", ] @@ -986,7 +986,7 @@ requires-dist = [ { name = "protobuf", specifier = ">=7.35.1" }, { name = "pydantic", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.2" }, - { name = "pypdf", specifier = ">=6.18.0" }, + { name = "pypdf", specifier = ">=6.19.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.0" }, @@ -1210,10 +1210,10 @@ version = "2.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'darwin'", - "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", - "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version >= '3.15' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", ] sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } @@ -1644,11 +1644,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.18.0" +version = "6.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/c3/9fa0666f280552bd3833562d985b785fce1ddb3804937edd2fd6a3f2bdb3/pypdf-6.18.0.tar.gz", hash = "sha256:ae58b7d93c22c169ffb02c3b06321c45c4f223b4916536568adb57d789d95d01", size = 7024871, upload-time = "2026-09-07T16:48:16.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ac/63d71aaedb59acbcdef491e6ca6469165e3771c9c74358204818fd9bc5a6/pypdf-6.19.0.tar.gz", hash = "sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155", size = 7033266, upload-time = "2026-09-16T09:32:05.946Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/a5/d5922a078c9a612327681d4793404f82998f4d66213add6fdc0659245856/pypdf-6.18.0-py3-none-any.whl", hash = "sha256:05b762b77bcb9dcb4a7c91fcf5dded585b25bee7269ab3d3001d7c55fa1b324b", size = 393848, upload-time = "2026-09-07T16:48:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl", hash = "sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14", size = 395480, upload-time = "2026-09-16T09:32:04.087Z" }, ] [[package]] @@ -2040,7 +2040,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -2112,14 +2112,14 @@ version = "1.18.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'darwin'", - "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", - "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version >= '3.15' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -2213,7 +2213,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -2226,14 +2226,14 @@ version = "2026.7.31" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'darwin'", - "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", - "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version >= '3.15' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/62/083288b8d6b9ecb2968e7573e60aa0694089e960e203934bc217169acf64/tifffile-2026.7.31.tar.gz", hash = "sha256:79b1f4b1aba3ef3e6b6f1691a32abb62f5d7383faa52a12771c695232ba40bee", size = 442177, upload-time = "2026-08-01T02:28:32.523Z" } wheels = [ @@ -2349,13 +2349,13 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "fsspec", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "jinja2", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "networkx", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "setuptools", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "sympy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", upload-time = "2026-07-08T12:26:13Z" }, @@ -2371,19 +2371,19 @@ version = "2.13.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'darwin'", + "python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version < '3.12' and sys_platform != 'darwin'", - "python_full_version >= '3.15' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "fsspec", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "jinja2", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "networkx", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "setuptools", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "sympy", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" }, @@ -2425,10 +2425,10 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "pillow", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, - { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.15'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.15'" }, + { name = "pillow" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" } }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:83fe6c020866a85acd7d97deccc45ff11d66daf42916d04396a4309c66c0ccb8", upload-time = "2026-07-08T12:26:40Z" }, @@ -2444,16 +2444,16 @@ version = "0.28.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'darwin'", + "python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version < '3.12' and sys_platform != 'darwin'", - "python_full_version >= '3.15' and sys_platform == 'darwin'", ] dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'darwin'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform != 'darwin') or (python_full_version >= '3.15' and sys_platform == 'darwin')" }, - { name = "pillow", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, - { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or sys_platform == 'darwin'" }, + { name = "pillow" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" } }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:22958193d72444ed7cbcc665ba4821a31e5279f9c4d1ad08520918b30896b78a", upload-time = "2026-07-08T12:26:39Z" }, @@ -2520,11 +2520,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]]