cognexdb 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cognex/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Cognex: local-first file database for humans, apps, and AI."""
2
+
3
+ __version__ = "0.4.1"
4
+
5
+ from .sdk import CognexClient, SDKResponse, create_client
6
+
7
+ __all__ = ["CognexClient", "SDKResponse", "create_client"]
cognex/ai.py ADDED
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .database import FileDatabase
11
+
12
+ SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
13
+
14
+
15
+ @dataclass
16
+ class AiPlan:
17
+ action: str
18
+ confidence: float
19
+ explanation: str
20
+ collection: str | None = None
21
+ query: str | None = None
22
+ sql: str | None = None
23
+ params: list[Any] | None = None
24
+ updates: dict[str, Any] | None = None
25
+ filters: dict[str, Any] | None = None
26
+
27
+ def as_dict(self) -> dict[str, Any]:
28
+ return {k: v for k, v in self.__dict__.items() if v is not None}
29
+
30
+
31
+ class AiNativeEngine:
32
+ """Deterministic AI-native command layer for local file-backed data.
33
+
34
+ This is intentionally provider-free: it turns common natural-language data
35
+ tasks into safe plans that can be inspected before execution, then runs them
36
+ against Cognex files/SQLite. LLMs can call it directly through REST, CLI,
37
+ SDK, or MCP without needing custom database glue code.
38
+ """
39
+
40
+ def __init__(self, root: str | Path):
41
+ self.root = Path(root).resolve()
42
+ self.db = FileDatabase(self.root)
43
+ self.db.init()
44
+
45
+ def plan(self, prompt: str, *, collection: str | None = None, csv_text: str | None = None) -> dict[str, Any]:
46
+ prompt = prompt.strip()
47
+ if not prompt:
48
+ raise ValueError("Prompt is required")
49
+ if csv_text or re.search(r"\b(create|import)\b.*\bcsv\b", prompt, re.I):
50
+ target = collection or self._collection_after_words(prompt, ["crm", "collection", "table"]) or "records"
51
+ return AiPlan("import_csv", 0.82, f"Import CSV rows into the {target} collection.", collection=target).as_dict()
52
+ update = self._plan_update(prompt, collection)
53
+ if update:
54
+ return update.as_dict()
55
+ query = self._plan_query(prompt, collection)
56
+ if query:
57
+ return query.as_dict()
58
+ term = self._quoted(prompt) or prompt
59
+ return AiPlan("search", 0.55, "Search across indexed records because no safer structured intent matched.", collection=collection, query=term).as_dict()
60
+
61
+ def run(self, prompt: str, *, collection: str | None = None, csv_text: str | None = None, dry_run: bool = False) -> dict[str, Any]:
62
+ plan = self.plan(prompt, collection=collection, csv_text=csv_text)
63
+ action = plan["action"]
64
+ if dry_run:
65
+ return {"plan": plan, "dry_run": True, "result": None}
66
+ if action == "search":
67
+ return {"plan": plan, "result": self._search(plan.get("query") or prompt, plan.get("collection"))}
68
+ if action == "query":
69
+ return {"plan": plan, "result": self.db.query(plan["sql"], plan.get("params") or [])}
70
+ if action == "update":
71
+ return {"plan": plan, "result": self._update(plan["collection"], plan.get("filters") or {}, plan.get("updates") or {})}
72
+ if action == "import_csv":
73
+ if not csv_text:
74
+ raise ValueError("csv_text is required for CSV imports")
75
+ return {"plan": plan, "result": self._import_csv(plan["collection"], csv_text)}
76
+ raise ValueError(f"Unsupported AI action: {action}")
77
+
78
+ def _plan_query(self, prompt: str, collection: str | None) -> AiPlan | None:
79
+ low = prompt.lower()
80
+ if not re.search(r"\b(find|show|list|get|search)\b", low):
81
+ return None
82
+ target = collection or self._collection_after_words(prompt, ["every", "all", "from", "in"]) or self._first_collection()
83
+ if not target or not SAFE_NAME_RE.match(target):
84
+ term = self._quoted(prompt) or self._term_after_words(prompt, ["bought", "contains", "about", "for"])
85
+ return AiPlan("search", 0.62, "Search all records for the requested term.", query=term or prompt)
86
+ field_filter = self._field_equals(prompt)
87
+ if field_filter:
88
+ field, value = field_filter
89
+ sql = f'SELECT * FROM "{target}" WHERE lower("{field}") = lower(?)'
90
+ return AiPlan("query", 0.86, f"Query {target} where {field} equals {value}.", collection=target, sql=sql, params=[value], filters={field: value})
91
+ term = self._quoted(prompt) or self._term_after_words(prompt, ["bought", "contains", "about", "for"])
92
+ if term:
93
+ return AiPlan("search", 0.74, f"Search {target} records for {term}.", collection=target, query=term)
94
+ return AiPlan("query", 0.7, f"List records from {target}.", collection=target, sql=f'SELECT * FROM "{target}"', params=[])
95
+
96
+ def _plan_update(self, prompt: str, collection: str | None) -> AiPlan | None:
97
+ # Supported safe shape: update customers where status = lead set status = active
98
+ match = re.search(
99
+ r"update\s+(?P<collection>[A-Za-z0-9_.-]+)?\s*where\s+(?P<filter>[A-Za-z0-9_.-]+)\s*=\s*(?P<value>[^\s]+)\s+set\s+(?P<field>[A-Za-z0-9_.-]+)\s*=\s*(?P<new>[^\s]+)",
100
+ prompt,
101
+ re.I,
102
+ )
103
+ if not match:
104
+ return None
105
+ target = collection or match.group("collection")
106
+ if not target or not SAFE_NAME_RE.match(target):
107
+ raise ValueError("Safe update requires a valid collection")
108
+ filters = {match.group("filter"): self._clean_value(match.group("value"))}
109
+ updates = {match.group("field"): self._clean_value(match.group("new"))}
110
+ return AiPlan("update", 0.9, f"Update {target} records matching {filters} with {updates}.", collection=target, filters=filters, updates=updates)
111
+
112
+ def _search(self, query: str, collection: str | None) -> list[dict[str, Any]]:
113
+ records = self.db.search(query, 50)
114
+ if collection:
115
+ records = [record for record in records if record["collection"] == collection]
116
+ return records
117
+
118
+ def _update(self, collection: str, filters: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
119
+ changed = []
120
+ for record in self.db.list_records(collection, 10000, 0):
121
+ data = record.get("data") or {}
122
+ if all(str(data.get(key)) == str(value) for key, value in filters.items()):
123
+ merged = dict(data)
124
+ merged.update(updates)
125
+ changed.append(self.db.upsert_record(collection, record["id"], merged, record.get("content", ""), record.get("format", "json"), record.get("revision")))
126
+ return {"updated": len(changed), "records": changed}
127
+
128
+ def _import_csv(self, collection: str, csv_text: str) -> dict[str, Any]:
129
+ if not SAFE_NAME_RE.match(collection):
130
+ raise ValueError("Invalid collection name")
131
+ rows = list(csv.DictReader(io.StringIO(csv_text)))
132
+ written = []
133
+ for index, row in enumerate(rows):
134
+ rid = str(row.get("id") or row.get("email") or row.get("name") or index).strip().replace(" ", "_")
135
+ rid = re.sub(r"[^A-Za-z0-9_.-]", "_", rid) or str(index)
136
+ written.append(self.db.upsert_record(collection, rid, dict(row), fmt="json"))
137
+ return {"imported": len(written), "records": written}
138
+
139
+ def _field_equals(self, prompt: str) -> tuple[str, str] | None:
140
+ match = re.search(r"\b([A-Za-z0-9_.-]+)\s*(?:=|is)\s*['\"]?([^'\".?!]+)['\"]?", prompt, re.I)
141
+ if match and SAFE_NAME_RE.match(match.group(1)):
142
+ return match.group(1), self._clean_value(match.group(2))
143
+ return None
144
+
145
+ def _collection_after_words(self, prompt: str, words: list[str]) -> str | None:
146
+ for word in words:
147
+ match = re.search(rf"\b{re.escape(word)}\s+([A-Za-z0-9_.-]+)", prompt, re.I)
148
+ if match:
149
+ candidate = match.group(1).lower().strip(".,?!")
150
+ if candidate not in {"that", "with", "where", "from", "the", "a", "an"} and SAFE_NAME_RE.match(candidate):
151
+ return candidate
152
+ return None
153
+
154
+ def _term_after_words(self, prompt: str, words: list[str]) -> str | None:
155
+ for word in words:
156
+ match = re.search(rf"\b{re.escape(word)}\s+['\"]?([^'\".?!]+)['\"]?", prompt, re.I)
157
+ if match:
158
+ return self._clean_value(match.group(1))
159
+ return None
160
+
161
+ def _quoted(self, prompt: str) -> str | None:
162
+ match = re.search(r"['\"]([^'\"]+)['\"]", prompt)
163
+ return match.group(1).strip() if match else None
164
+
165
+ def _first_collection(self) -> str | None:
166
+ collections = self.db.collections()
167
+ return collections[0]["collection"] if collections else None
168
+
169
+ def _clean_value(self, value: str) -> str:
170
+ return value.strip().strip("'\".,?!")
cognex/auth.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import secrets
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class AuthError(RuntimeError):
15
+ """Raised when local Cognex authentication fails."""
16
+
17
+
18
+ class LocalAuth:
19
+ """File-backed local auth for single-node/local-first deployments.
20
+
21
+ Users and sessions are stored under `.cognex/auth.json`. Passwords are
22
+ salted PBKDF2 hashes and session tokens are stored hashed at rest.
23
+ """
24
+
25
+ def __init__(self, root: str | Path, session_ttl_seconds: int = 60 * 60 * 24 * 7):
26
+ self.root = Path(root).resolve()
27
+ self.path = self.root / ".cognex" / "auth.json"
28
+ self.session_ttl_seconds = session_ttl_seconds
29
+
30
+ def signup(self, email: str, password: str, role: str = "user") -> dict[str, Any]:
31
+ email = self._normalize_email(email)
32
+ if len(password) < 8:
33
+ raise AuthError("Password must be at least 8 characters")
34
+ state = self._load()
35
+ if email in state["users"]:
36
+ raise AuthError("User already exists")
37
+ now = time.time()
38
+ state["users"][email] = {
39
+ "email": email,
40
+ "role": role,
41
+ "password": self._hash_password(password),
42
+ "created_at": now,
43
+ }
44
+ token, session = self._new_session(email, role)
45
+ state["sessions"][session["token_hash"]] = session
46
+ self._save(state)
47
+ return {"user": self._public_user(state["users"][email]), "session": {"access_token": token, "expires_at": session["expires_at"]}}
48
+
49
+ def login(self, email: str, password: str) -> dict[str, Any]:
50
+ email = self._normalize_email(email)
51
+ state = self._load()
52
+ user = state["users"].get(email)
53
+ if not user or not self._verify_password(password, user["password"]):
54
+ raise AuthError("Invalid email or password")
55
+ token, session = self._new_session(email, user.get("role", "user"))
56
+ state["sessions"][session["token_hash"]] = session
57
+ self._save(state)
58
+ return {"user": self._public_user(user), "session": {"access_token": token, "expires_at": session["expires_at"]}}
59
+
60
+ def validate_token(self, token: str) -> dict[str, Any] | None:
61
+ if not token:
62
+ return None
63
+ state = self._load()
64
+ session = state["sessions"].get(self._token_hash(token))
65
+ if not session or session.get("expires_at", 0) < time.time():
66
+ return None
67
+ user = state["users"].get(session["email"])
68
+ return self._public_user(user) if user else None
69
+
70
+ def logout(self, token: str) -> bool:
71
+ state = self._load()
72
+ removed = state["sessions"].pop(self._token_hash(token), None) is not None
73
+ if removed:
74
+ self._save(state)
75
+ return removed
76
+
77
+ def _new_session(self, email: str, role: str) -> tuple[str, dict[str, Any]]:
78
+ token = secrets.token_urlsafe(32)
79
+ now = time.time()
80
+ return token, {"token_hash": self._token_hash(token), "email": email, "role": role, "created_at": now, "expires_at": now + self.session_ttl_seconds}
81
+
82
+ def _load(self) -> dict[str, Any]:
83
+ if not self.path.exists():
84
+ return {"users": {}, "sessions": {}}
85
+ return json.loads(self.path.read_text(encoding="utf-8"))
86
+
87
+ def _save(self, state: dict[str, Any]) -> None:
88
+ self.path.parent.mkdir(parents=True, exist_ok=True)
89
+ tmp = self.path.with_suffix(".tmp")
90
+ tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
91
+ os.replace(tmp, self.path)
92
+
93
+ def _hash_password(self, password: str) -> str:
94
+ salt = secrets.token_bytes(16)
95
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 210_000)
96
+ return "pbkdf2_sha256$210000$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()
97
+
98
+ def _verify_password(self, password: str, encoded: str) -> bool:
99
+ try:
100
+ scheme, iterations, salt_b64, digest_b64 = encoded.split("$", 3)
101
+ if scheme != "pbkdf2_sha256":
102
+ return False
103
+ salt = base64.b64decode(salt_b64)
104
+ expected = base64.b64decode(digest_b64)
105
+ actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iterations))
106
+ return hmac.compare_digest(actual, expected)
107
+ except Exception:
108
+ return False
109
+
110
+ def _token_hash(self, token: str) -> str:
111
+ return hashlib.sha256(token.encode()).hexdigest()
112
+
113
+ def _normalize_email(self, email: str) -> str:
114
+ email = (email or "").strip().lower()
115
+ if "@" not in email:
116
+ raise AuthError("Valid email is required")
117
+ return email
118
+
119
+ def _public_user(self, user: dict[str, Any]) -> dict[str, Any]:
120
+ return {"email": user["email"], "role": user.get("role", "user"), "created_at": user.get("created_at")}
cognex/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .ai import AiNativeEngine
8
+ from .crdt import CrdtEngine
9
+ from .database import FileDatabase
10
+ from .functions import FunctionRunner
11
+ from .importers import Importer
12
+ from .engine import ProjectEngine
13
+ from .mcp import McpServer
14
+ from .server import serve
15
+ from .sync import SyncEngine
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(prog="cognex", description="Local project intelligence engine for AI coding assistants.")
20
+ parser.add_argument("--root", default=".", help="Project root to index. Defaults to current directory.")
21
+ sub = parser.add_subparsers(dest="cmd", required=True)
22
+ sub.add_parser("init", help="Create local Cognex state and ignore rules.")
23
+ sub.add_parser("index", help="Index the project.")
24
+ s = sub.add_parser("search", help="Search the project index."); s.add_argument("query"); s.add_argument("--limit", type=int, default=10)
25
+ c = sub.add_parser("context", help="Build AI-ready context for a prompt."); c.add_argument("prompt"); c.add_argument("--limit", type=int, default=8); c.add_argument("--max-chars", type=int, default=14000)
26
+ sub.add_parser("graph", help="Print the project graph as JSON.")
27
+ q = sub.add_parser("sql", help="Run read-only SQL against file collections."); q.add_argument("sql")
28
+ ai = sub.add_parser("ai", help="Plan or run an AI-native natural-language data task."); ai.add_argument("prompt"); ai.add_argument("--collection", default=None); ai.add_argument("--csv-file", default=None); ai.add_argument("--dry-run", action="store_true")
29
+ sub.add_parser("collections", help="List file database collections.")
30
+ u = sub.add_parser("upsert", help="Write a JSON/Markdown record to the file database."); u.add_argument("collection"); u.add_argument("id"); u.add_argument("--data", default="{}"); u.add_argument("--content", default=""); u.add_argument("--format", default="json", choices=["json", "md"]); u.add_argument("--expected-revision", default=None)
31
+ sub.add_parser("transactions", help="List recent local write transactions.")
32
+ sp = sub.add_parser("sync-push", help="Push local data files to a filesystem/Git sync remote."); sp.add_argument("remote"); sp.add_argument("--force", action="store_true"); sp.add_argument("--git", action="store_true"); sp.add_argument("--message", default="Cognex sync push"); sp.add_argument("--no-crdt", action="store_true")
33
+ sl = sub.add_parser("sync-pull", help="Pull data files from a filesystem/Git sync remote."); sl.add_argument("remote"); sl.add_argument("--force", action="store_true"); sl.add_argument("--git", action="store_true"); sl.add_argument("--no-crdt", action="store_true")
34
+ ss = sub.add_parser("sync-status", help="Show pending push/pull/conflict status for a sync remote."); ss.add_argument("remote")
35
+ cu = sub.add_parser("crdt-update", help="Apply CRDT field updates to a JSON record."); cu.add_argument("collection"); cu.add_argument("id"); cu.add_argument("--fields", default="{}"); cu.add_argument("--deletes", default="[]"); cu.add_argument("--actor", default=None)
36
+ ce = sub.add_parser("crdt-export", help="Export CRDT operations to a JSON file."); ce.add_argument("target"); ce.add_argument("--since", type=float, default=0.0)
37
+ ci = sub.add_parser("crdt-import", help="Import CRDT operations from a JSON file."); ci.add_argument("source")
38
+ imp = sub.add_parser("import", help="Import Supabase/Firebase exports."); imp.add_argument("provider", choices=["supabase", "firebase"]); imp.add_argument("source"); imp.add_argument("--collection", default=None)
39
+ fn = sub.add_parser("function", help="Run a local Cognex function from .cognex/functions."); fn.add_argument("name"); fn.add_argument("--data", default="{}")
40
+ api = sub.add_parser("serve", help="Run the production REST backend."); api.add_argument("--host", default="0.0.0.0"); api.add_argument("--port", type=int, default=8080); api.add_argument("--api-key", default=""); api.add_argument("--no-watch", action="store_true")
41
+ sub.add_parser("mcp", help="Run an MCP stdio server.")
42
+ args = parser.parse_args(argv)
43
+ root = Path(args.root)
44
+ engine = ProjectEngine(root)
45
+
46
+ if args.cmd == "init":
47
+ engine.init(); print(f"Initialized Cognex at {engine.state_dir}")
48
+ elif args.cmd == "index":
49
+ engine.load(); print(json.dumps(engine.index(), indent=2))
50
+ elif args.cmd == "search":
51
+ engine.load(); print(json.dumps([h.__dict__ for h in engine.search(args.query, args.limit)], indent=2))
52
+ elif args.cmd == "context":
53
+ engine.load(); print(engine.context(args.prompt, args.limit, args.max_chars))
54
+ elif args.cmd == "graph":
55
+ engine.load(); print(json.dumps(engine.graph(), indent=2))
56
+ elif args.cmd == "sql":
57
+ db = FileDatabase(root); print(json.dumps(db.query(args.sql), indent=2))
58
+ elif args.cmd == "ai":
59
+ csv_text = Path(args.csv_file).read_text(encoding="utf-8") if args.csv_file else None
60
+ print(json.dumps(AiNativeEngine(root).run(args.prompt, collection=args.collection, csv_text=csv_text, dry_run=args.dry_run), indent=2))
61
+ elif args.cmd == "collections":
62
+ db = FileDatabase(root); print(json.dumps(db.collections(), indent=2))
63
+ elif args.cmd == "import":
64
+ importer = Importer(root)
65
+ result = importer.import_supabase(args.source, args.collection) if args.provider == "supabase" else importer.import_firebase(args.source, args.collection)
66
+ print(json.dumps(result, indent=2))
67
+ elif args.cmd == "function":
68
+ print(json.dumps(FunctionRunner(root).run(args.name, json.loads(args.data)), indent=2))
69
+ elif args.cmd == "upsert":
70
+ db = FileDatabase(root); print(json.dumps(db.upsert_record(args.collection, args.id, json.loads(args.data), args.content, args.format, args.expected_revision), indent=2))
71
+ elif args.cmd == "transactions":
72
+ db = FileDatabase(root); print(json.dumps(db.transaction_log(), indent=2))
73
+ elif args.cmd == "sync-push":
74
+ print(json.dumps(SyncEngine(root).push(args.remote, args.force, args.git, args.message, not args.no_crdt).__dict__, indent=2))
75
+ elif args.cmd == "sync-pull":
76
+ print(json.dumps(SyncEngine(root).pull(args.remote, args.force, args.git, not args.no_crdt).__dict__, indent=2))
77
+ elif args.cmd == "sync-status":
78
+ print(json.dumps(SyncEngine(root).status(args.remote), indent=2))
79
+ elif args.cmd == "crdt-update":
80
+ print(json.dumps(CrdtEngine(root, args.actor).apply_update(args.collection, args.id, json.loads(args.fields), json.loads(args.deletes)), indent=2))
81
+ elif args.cmd == "crdt-export":
82
+ print(json.dumps(CrdtEngine(root).write_export(args.target, args.since), indent=2))
83
+ elif args.cmd == "crdt-import":
84
+ print(json.dumps(CrdtEngine(root).merge_files(args.source), indent=2))
85
+ elif args.cmd == "serve":
86
+ serve(root, args.host, args.port, args.api_key, not args.no_watch)
87
+ elif args.cmd == "mcp":
88
+ McpServer(root).serve()
89
+ return 0
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
cognex/crdt.py ADDED
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import uuid
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .database import FileDatabase
12
+
13
+ DELETE = "__cognex_deleted__"
14
+
15
+ @dataclass(frozen=True)
16
+ class CrdtOp:
17
+ op_id: str
18
+ actor: str
19
+ seq: int
20
+ collection: str
21
+ id: str
22
+ field: str
23
+ value: Any
24
+ deleted: bool
25
+ ts: float
26
+
27
+ class CrdtEngine:
28
+ """Operation-based LWW-register CRDT for JSON-style Cognex records.
29
+
30
+ It stores idempotent field operations, imports/exports operation logs, and
31
+ materializes the deterministic merged state back into human-readable files.
32
+ Different fields merge automatically. Same-field concurrent edits resolve by
33
+ a deterministic last-writer-wins tuple: (ts, actor, op_id).
34
+ """
35
+
36
+ def __init__(self, root: str | Path, actor: str | None = None):
37
+ self.root = Path(root).resolve()
38
+ self.db = FileDatabase(self.root)
39
+ self.db.init()
40
+ self.actor = actor or os.getenv("COGNEX_ACTOR") or self._load_actor()
41
+ self._create_schema()
42
+
43
+ def _create_schema(self) -> None:
44
+ db = self.db.connect()
45
+ db.executescript(
46
+ """
47
+ CREATE TABLE IF NOT EXISTS crdt_ops (
48
+ op_id TEXT PRIMARY KEY,
49
+ actor TEXT NOT NULL,
50
+ seq INTEGER NOT NULL,
51
+ collection TEXT NOT NULL,
52
+ id TEXT NOT NULL,
53
+ field TEXT NOT NULL,
54
+ value_json TEXT NOT NULL,
55
+ deleted INTEGER NOT NULL,
56
+ ts REAL NOT NULL,
57
+ received_at REAL NOT NULL
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_crdt_record ON crdt_ops(collection, id);
60
+ CREATE TABLE IF NOT EXISTS crdt_actor_state (
61
+ actor TEXT PRIMARY KEY,
62
+ seq INTEGER NOT NULL
63
+ );
64
+ """
65
+ )
66
+ db.commit()
67
+
68
+ def _load_actor(self) -> str:
69
+ path = self.root / ".cognex" / "actor_id"
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ if path.exists():
72
+ return path.read_text(encoding="utf-8").strip()
73
+ actor = f"actor-{uuid.uuid4()}"
74
+ path.write_text(actor, encoding="utf-8")
75
+ return actor
76
+
77
+ def _next_seq(self) -> int:
78
+ db = self.db.connect()
79
+ row = db.execute("SELECT seq FROM crdt_actor_state WHERE actor=?", (self.actor,)).fetchone()
80
+ seq = int(row["seq"]) + 1 if row else 1
81
+ db.execute("INSERT INTO crdt_actor_state(actor,seq) VALUES(?,?) ON CONFLICT(actor) DO UPDATE SET seq=excluded.seq", (self.actor, seq))
82
+ db.commit()
83
+ return seq
84
+
85
+ def apply_update(self, collection: str, rid: str, fields: dict[str, Any], deletes: list[str] | None = None, actor: str | None = None) -> dict[str, Any]:
86
+ ops = []
87
+ actor_id = actor or self.actor
88
+ for field, value in fields.items():
89
+ ops.append(self._new_op(collection, rid, field, value, False, actor_id))
90
+ for field in deletes or []:
91
+ ops.append(self._new_op(collection, rid, field, None, True, actor_id))
92
+ imported = self.import_ops([asdict(op) for op in ops], materialize=False)
93
+ record = self.materialize_record(collection, rid)
94
+ return {"actor": actor_id, "applied": imported["imported"], "ops": [asdict(op) for op in ops], "record": record}
95
+
96
+ def delete_record(self, collection: str, rid: str, actor: str | None = None) -> dict[str, Any]:
97
+ return self.apply_update(collection, rid, {DELETE: True}, actor=actor)
98
+
99
+ def _new_op(self, collection: str, rid: str, field: str, value: Any, deleted: bool, actor: str) -> CrdtOp:
100
+ seq = self._next_seq() if actor == self.actor else int(time.time() * 1_000_000)
101
+ ts = time.time()
102
+ op_id = f"{actor}:{seq}:{uuid.uuid4().hex}"
103
+ return CrdtOp(op_id, actor, seq, collection, rid, field, value, deleted, ts)
104
+
105
+ def import_ops(self, ops: list[dict[str, Any]], materialize: bool = True) -> dict[str, Any]:
106
+ db = self.db.connect()
107
+ imported = 0
108
+ touched: set[tuple[str, str]] = set()
109
+ for raw in ops:
110
+ op = self._normalize(raw)
111
+ try:
112
+ db.execute(
113
+ "INSERT INTO crdt_ops(op_id,actor,seq,collection,id,field,value_json,deleted,ts,received_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
114
+ (op.op_id, op.actor, op.seq, op.collection, op.id, op.field, json.dumps(op.value, sort_keys=True), int(op.deleted), op.ts, time.time()),
115
+ )
116
+ imported += 1
117
+ touched.add((op.collection, op.id))
118
+ except Exception as exc:
119
+ if "UNIQUE" not in str(exc).upper():
120
+ raise
121
+ db.commit()
122
+ records = []
123
+ if materialize:
124
+ for collection, rid in sorted(touched):
125
+ records.append(self.materialize_record(collection, rid))
126
+ return {"imported": imported, "records": records}
127
+
128
+ def export_ops(self, since: float = 0.0, collection: str | None = None, rid: str | None = None) -> list[dict[str, Any]]:
129
+ sql = "SELECT * FROM crdt_ops WHERE ts >= ?"
130
+ params: list[Any] = [since]
131
+ if collection:
132
+ sql += " AND collection = ?"; params.append(collection)
133
+ if rid:
134
+ sql += " AND id = ?"; params.append(rid)
135
+ sql += " ORDER BY ts, actor, seq, op_id"
136
+ rows = self.db.connect().execute(sql, params).fetchall()
137
+ return [self._row_to_op(row) for row in rows]
138
+
139
+ def materialize_record(self, collection: str, rid: str) -> dict[str, Any]:
140
+ state = self.merged_state(collection, rid)
141
+ if state.get(DELETE) is True:
142
+ self.db.delete_record(collection, rid)
143
+ return {"collection": collection, "id": rid, "deleted": True, "data": {}}
144
+ record = self.db.upsert_record(collection, rid, state)
145
+ return record
146
+
147
+ def merged_state(self, collection: str, rid: str) -> dict[str, Any]:
148
+ rows = self.db.connect().execute("SELECT * FROM crdt_ops WHERE collection=? AND id=?", (collection, rid)).fetchall()
149
+ winners: dict[str, tuple[tuple[float, str, str], Any, bool]] = {}
150
+ for row in rows:
151
+ key = (float(row["ts"]), row["actor"], row["op_id"])
152
+ field = row["field"]
153
+ old = winners.get(field)
154
+ if old is None or key > old[0]:
155
+ winners[field] = (key, json.loads(row["value_json"]), bool(row["deleted"]))
156
+ data: dict[str, Any] = {}
157
+ for field, (_, value, deleted) in winners.items():
158
+ if not deleted:
159
+ data[field] = value
160
+ return data
161
+
162
+ def merge_files(self, source: str | Path) -> dict[str, Any]:
163
+ path = Path(source)
164
+ payload = json.loads(path.read_text(encoding="utf-8"))
165
+ return self.import_ops(payload.get("ops", payload if isinstance(payload, list) else []))
166
+
167
+ def write_export(self, target: str | Path, since: float = 0.0) -> dict[str, Any]:
168
+ path = Path(target)
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ ops = self.export_ops(since)
171
+ payload = {"version": 1, "actor": self.actor, "exported_at": time.time(), "ops": ops}
172
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
173
+ return {"target": str(path), "ops": len(ops)}
174
+
175
+ def _normalize(self, raw: dict[str, Any]) -> CrdtOp:
176
+ return CrdtOp(
177
+ op_id=str(raw["op_id"]), actor=str(raw["actor"]), seq=int(raw["seq"]),
178
+ collection=str(raw["collection"]), id=str(raw["id"]), field=str(raw["field"]),
179
+ value=raw.get("value"), deleted=bool(raw.get("deleted", False)), ts=float(raw["ts"]),
180
+ )
181
+
182
+ def _row_to_op(self, row) -> dict[str, Any]:
183
+ return {
184
+ "op_id": row["op_id"], "actor": row["actor"], "seq": row["seq"],
185
+ "collection": row["collection"], "id": row["id"], "field": row["field"],
186
+ "value": json.loads(row["value_json"]), "deleted": bool(row["deleted"]), "ts": row["ts"],
187
+ }