Fix SQLite disk I/O on bucket mount

#1
Dockerfile CHANGED
@@ -4,7 +4,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
4
  PYTHONUNBUFFERED=1 \
5
  UV_COMPILE_BYTECODE=1 \
6
  UV_LINK_MODE=copy \
7
- WATERLEAF_DATA_DIR=/data
 
8
 
9
  RUN pip install --no-cache-dir uv==0.11.8 \
10
  && useradd --create-home --uid 1000 user \
 
4
  PYTHONUNBUFFERED=1 \
5
  UV_COMPILE_BYTECODE=1 \
6
  UV_LINK_MODE=copy \
7
+ WATERLEAF_DATA_DIR=/data \
8
+ WATERLEAF_DATABASE_DIR=/tmp/waterleaf-db
9
 
10
  RUN pip install --no-cache-dir uv==0.11.8 \
11
  && useradd --create-home --uid 1000 user \
README.md CHANGED
@@ -41,7 +41,8 @@ Hackathon.
41
 
42
  - **UI and web:** Gradio Blocks mounted in FastAPI
43
  - **Authentication:** Hugging Face OAuth
44
- - **Persistence:** SQLite and normalized JPEGs on an attached HF Storage Bucket
 
45
  - **Vision model:** `ggml-org/gemma-4-26B-A4B-it-GGUF`
46
  - **Runtime:** llama.cpp `server-cuda13-b9445` on a Modal L4
47
  - **Taxonomy:** GBIF Species API
@@ -193,8 +194,11 @@ In **Space Settings → Storage Buckets**:
193
  2. Attach it read-write.
194
  3. Set the mount path to `/data`.
195
 
196
- The Docker image already sets `WATERLEAF_DATA_DIR=/data`. Without this mount,
197
- saved gardens and images disappear when the Space restarts.
 
 
 
198
 
199
  ### 4. Configure secrets and variables
200
 
@@ -207,6 +211,7 @@ In **Space Settings → Variables and secrets**, add:
207
  | `MODAL_SECRET` | Secret | Production | Modal proxy token secret |
208
  | `PUBLIC_BASE_URL` | Variable | Optional | Override the derived Space URL |
209
  | `WATERLEAF_DATA_DIR` | Variable | No | Defaults to `/data` in Docker |
 
210
 
211
  Use only the base Modal URL for `MODAL_ENDPOINT`; do not append
212
  `/v1/chat/completions`.
 
41
 
42
  - **UI and web:** Gradio Blocks mounted in FastAPI
43
  - **Authentication:** Hugging Face OAuth
44
+ - **Persistence:** Local SQLite with an HF Storage Bucket snapshot, plus bucket-backed
45
+ JPEGs and calendar exports
46
  - **Vision model:** `ggml-org/gemma-4-26B-A4B-it-GGUF`
47
  - **Runtime:** llama.cpp `server-cuda13-b9445` on a Modal L4
48
  - **Taxonomy:** GBIF Species API
 
194
  2. Attach it read-write.
195
  3. Set the mount path to `/data`.
196
 
197
+ The Docker image sets `WATERLEAF_DATA_DIR=/data` for bucket-backed media,
198
+ exports, and database snapshots. It keeps the live SQLite file under
199
+ `WATERLEAF_DATABASE_DIR=/tmp/waterleaf-db` so SQLite uses local container disk
200
+ instead of the bucket mount for file locking. Without the bucket mount, images,
201
+ calendar exports, and saved-garden snapshots disappear when the Space restarts.
202
 
203
  ### 4. Configure secrets and variables
204
 
 
211
  | `MODAL_SECRET` | Secret | Production | Modal proxy token secret |
212
  | `PUBLIC_BASE_URL` | Variable | Optional | Override the derived Space URL |
213
  | `WATERLEAF_DATA_DIR` | Variable | No | Defaults to `/data` in Docker |
214
+ | `WATERLEAF_DATABASE_DIR` | Variable | No | Defaults to `/tmp/waterleaf-db` in Docker |
215
 
216
  Use only the base Modal URL for `MODAL_ENDPOINT`; do not append
217
  `/v1/chat/completions`.
docs/architecture.md CHANGED
@@ -5,7 +5,7 @@ flowchart LR
5
  U[Gardener] --> G[Gradio Blocks]
6
  G --> A[FastAPI application]
7
  A --> O[Hugging Face OAuth]
8
- A --> S[(SQLite + images\nHF Storage Bucket)]
9
  A --> M[Modal proxy-auth endpoint]
10
  M --> L[llama.cpp\nGemma 4 26B-A4B GGUF]
11
  A --> B[GBIF taxonomy]
 
5
  U[Gardener] --> G[Gradio Blocks]
6
  G --> A[FastAPI application]
7
  A --> O[Hugging Face OAuth]
8
+ A --> S[(Local SQLite\nbucket snapshot + images)]
9
  A --> M[Modal proxy-auth endpoint]
10
  M --> L[llama.cpp\nGemma 4 26B-A4B GGUF]
11
  A --> B[GBIF taxonomy]
docs/field-notes.md CHANGED
@@ -22,7 +22,7 @@ flowchart LR
22
  U[Gardener] --> G[Gradio Blocks]
23
  G --> A[FastAPI application]
24
  A --> O[Hugging Face OAuth]
25
- A --> S[(SQLite + images\nHF Storage Bucket)]
26
  A --> M[Modal proxy-auth endpoint]
27
  M --> L[llama.cpp\nGemma 4 26B-A4B GGUF]
28
  A --> B[GBIF taxonomy]
 
22
  U[Gardener] --> G[Gradio Blocks]
23
  G --> A[FastAPI application]
24
  A --> O[Hugging Face OAuth]
25
+ A --> S[(Local SQLite\nbucket snapshot + images)]
26
  A --> M[Modal proxy-auth endpoint]
27
  M --> L[llama.cpp\nGemma 4 26B-A4B GGUF]
28
  A --> B[GBIF taxonomy]
tests/test_runtime.py CHANGED
@@ -11,6 +11,19 @@ def test_settings_derive_public_url_from_space_host(monkeypatch, tmp_path):
11
 
12
  assert settings.public_base_url == "https://hkaraoguz-waterleaf.hf.space"
13
  assert settings.database_path == tmp_path / "waterleaf.sqlite3"
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  def test_runtime_uses_demo_identification_without_modal_endpoint(tmp_path):
 
11
 
12
  assert settings.public_base_url == "https://hkaraoguz-waterleaf.hf.space"
13
  assert settings.database_path == tmp_path / "waterleaf.sqlite3"
14
+ assert settings.database_snapshot_path is None
15
+
16
+
17
+ def test_settings_can_keep_sqlite_database_off_data_mount(monkeypatch, tmp_path):
18
+ data_dir = tmp_path / "data"
19
+ database_dir = tmp_path / "db"
20
+ monkeypatch.setenv("WATERLEAF_DATA_DIR", str(data_dir))
21
+ monkeypatch.setenv("WATERLEAF_DATABASE_DIR", str(database_dir))
22
+
23
+ settings = Settings.from_env()
24
+
25
+ assert settings.database_path == database_dir / "waterleaf.sqlite3"
26
+ assert settings.database_snapshot_path == data_dir / "waterleaf.sqlite3"
27
 
28
 
29
  def test_runtime_uses_demo_identification_without_modal_endpoint(tmp_path):
tests/test_storage.py CHANGED
@@ -81,3 +81,36 @@ def test_store_replaces_schedule_atomically(tmp_path):
81
  {"date": "2026-06-14", "reason": "Edited", "confidence": "manual"}
82
  ]
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  {"date": "2026-06-14", "reason": "Edited", "confidence": "manual"}
82
  ]
83
 
84
+
85
+ def test_store_uses_local_database_and_syncs_snapshot(tmp_path):
86
+ snapshot_path = tmp_path / "bucket" / "waterleaf.sqlite3"
87
+ seed_store = GardenStore(snapshot_path)
88
+ seed = seed_store.save_plant("alice", _draft())
89
+ seed_store.replace_schedule(
90
+ "alice",
91
+ seed.id,
92
+ [{"date": "2026-06-14", "reason": "Seed", "confidence": "manual"}],
93
+ )
94
+
95
+ local_path = tmp_path / "local" / "waterleaf.sqlite3"
96
+ store = GardenStore(local_path, snapshot_path=snapshot_path)
97
+ saved = store.save_plant("alice", _draft())
98
+ store.replace_schedule(
99
+ "alice",
100
+ saved.id,
101
+ [{"date": "2026-06-15", "reason": "Synced", "confidence": "manual"}],
102
+ )
103
+
104
+ assert local_path.exists()
105
+ assert store.get_schedule("alice", seed.id) == [
106
+ {"date": "2026-06-14", "reason": "Seed", "confidence": "manual"}
107
+ ]
108
+
109
+ reloaded = GardenStore(
110
+ tmp_path / "reloaded" / "waterleaf.sqlite3",
111
+ snapshot_path=snapshot_path,
112
+ )
113
+ assert {plant.id for plant in reloaded.list_plants("alice")} == {seed.id, saved.id}
114
+ assert reloaded.get_schedule("alice", saved.id) == [
115
+ {"date": "2026-06-15", "reason": "Synced", "confidence": "manual"}
116
+ ]
waterleaf/runtime.py CHANGED
@@ -30,7 +30,7 @@ def build_application(settings: Settings | None = None) -> WaterleafApplication:
30
  identification = build_demo_identification()
31
 
32
  return WaterleafApplication(
33
- store=GardenStore(settings.database_path),
34
  media_directory=settings.media_directory,
35
  export_directory=settings.export_directory,
36
  public_base_url=settings.public_base_url,
 
30
  identification = build_demo_identification()
31
 
32
  return WaterleafApplication(
33
+ store=GardenStore(settings.database_path, snapshot_path=settings.database_snapshot_path),
34
  media_directory=settings.media_directory,
35
  export_directory=settings.export_directory,
36
  public_base_url=settings.public_base_url,
waterleaf/settings.py CHANGED
@@ -12,9 +12,16 @@ class Settings:
12
  modal_endpoint: str | None
13
  modal_key: str | None
14
  modal_secret: str | None
 
15
 
16
  @property
17
  def database_path(self) -> Path:
 
 
 
 
 
 
18
  return self.data_directory / "waterleaf.sqlite3"
19
 
20
  @property
@@ -28,6 +35,7 @@ class Settings:
28
  @classmethod
29
  def from_env(cls) -> Settings:
30
  data_directory = Path(os.getenv("WATERLEAF_DATA_DIR", "data"))
 
31
  public_base_url = os.getenv("PUBLIC_BASE_URL")
32
  if not public_base_url:
33
  space_host = os.getenv("SPACE_HOST")
@@ -40,4 +48,5 @@ class Settings:
40
  modal_endpoint=os.getenv("MODAL_ENDPOINT"),
41
  modal_key=os.getenv("MODAL_KEY"),
42
  modal_secret=os.getenv("MODAL_SECRET"),
 
43
  )
 
12
  modal_endpoint: str | None
13
  modal_key: str | None
14
  modal_secret: str | None
15
+ database_directory: Path | None = None
16
 
17
  @property
18
  def database_path(self) -> Path:
19
+ return (self.database_directory or self.data_directory) / "waterleaf.sqlite3"
20
+
21
+ @property
22
+ def database_snapshot_path(self) -> Path | None:
23
+ if self.database_directory is None or self.database_directory == self.data_directory:
24
+ return None
25
  return self.data_directory / "waterleaf.sqlite3"
26
 
27
  @property
 
35
  @classmethod
36
  def from_env(cls) -> Settings:
37
  data_directory = Path(os.getenv("WATERLEAF_DATA_DIR", "data"))
38
+ database_directory = os.getenv("WATERLEAF_DATABASE_DIR")
39
  public_base_url = os.getenv("PUBLIC_BASE_URL")
40
  if not public_base_url:
41
  space_host = os.getenv("SPACE_HOST")
 
48
  modal_endpoint=os.getenv("MODAL_ENDPOINT"),
49
  modal_key=os.getenv("MODAL_KEY"),
50
  modal_secret=os.getenv("MODAL_SECRET"),
51
+ database_directory=Path(database_directory) if database_directory else None,
52
  )
waterleaf/storage.py CHANGED
@@ -2,7 +2,9 @@ from __future__ import annotations
2
 
3
  import json
4
  import secrets
 
5
  import sqlite3
 
6
  import uuid
7
  from datetime import time
8
  from pathlib import Path
@@ -12,10 +14,15 @@ from waterleaf.models import PlantDraft, PublicPlant, SavedPlant
12
 
13
 
14
  class GardenStore:
15
- def __init__(self, database_path: str | Path):
16
  self.database_path = Path(database_path)
 
 
17
  self.database_path.parent.mkdir(parents=True, exist_ok=True)
 
 
18
  self._initialize()
 
19
 
20
  def _connect(self) -> sqlite3.Connection:
21
  connection = sqlite3.connect(self.database_path)
@@ -24,107 +31,119 @@ class GardenStore:
24
  return connection
25
 
26
  def _initialize(self) -> None:
27
- with self._connect() as connection:
28
- connection.executescript(
29
- """
30
- CREATE TABLE IF NOT EXISTS plants (
31
- id TEXT PRIMARY KEY,
32
- owner TEXT NOT NULL,
33
- public_slug TEXT NOT NULL UNIQUE,
34
- nickname TEXT NOT NULL,
35
- common_name TEXT NOT NULL,
36
- scientific_name TEXT NOT NULL,
37
- taxon_key TEXT NOT NULL,
38
- location_name TEXT NOT NULL,
39
- latitude REAL NOT NULL,
40
- longitude REAL NOT NULL,
41
- timezone TEXT NOT NULL,
42
- preferred_time TEXT NOT NULL,
43
- is_container INTEGER NOT NULL,
44
- size_label TEXT NOT NULL,
45
- image_id TEXT NOT NULL,
46
- care_min_days INTEGER,
47
- care_max_days INTEGER,
48
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
49
- );
50
- CREATE INDEX IF NOT EXISTS idx_plants_owner ON plants(owner);
51
-
52
- CREATE TABLE IF NOT EXISTS schedules (
53
- plant_id TEXT PRIMARY KEY,
54
- owner TEXT NOT NULL,
55
- events_json TEXT NOT NULL,
56
- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
57
- FOREIGN KEY (plant_id) REFERENCES plants(id) ON DELETE CASCADE
58
- );
59
- """
60
- )
 
 
 
 
 
 
 
61
 
62
  def save_plant(self, owner: str, draft: PlantDraft) -> SavedPlant:
63
- plant_id = str(uuid.uuid4())
64
- public_slug = secrets.token_urlsafe(18)
65
- values = (
66
- plant_id,
67
- owner,
68
- public_slug,
69
- draft.nickname.strip(),
70
- draft.common_name.strip(),
71
- draft.scientific_name.strip(),
72
- draft.taxon_key,
73
- draft.location_name.strip(),
74
- round(draft.latitude, 2),
75
- round(draft.longitude, 2),
76
- draft.timezone,
77
- draft.preferred_time.isoformat(timespec="minutes"),
78
- int(draft.is_container),
79
- draft.size_label,
80
- draft.image_id,
81
- draft.care_min_days,
82
- draft.care_max_days,
83
- )
84
- with self._connect() as connection:
85
- connection.execute(
86
- """
87
- INSERT INTO plants (
88
- id, owner, public_slug, nickname, common_name, scientific_name,
89
- taxon_key, location_name, latitude, longitude, timezone,
90
- preferred_time, is_container, size_label, image_id,
91
- care_min_days, care_max_days
92
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93
- """,
94
- values,
95
  )
96
- plant = self.get_plant(owner, plant_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  if plant is None:
98
  raise RuntimeError("Saved plant could not be loaded")
99
  return plant
100
 
101
  def list_plants(self, owner: str) -> list[SavedPlant]:
102
- with self._connect() as connection:
103
- rows = connection.execute(
104
- "SELECT * FROM plants WHERE owner = ? ORDER BY created_at, id",
105
- (owner,),
106
- ).fetchall()
 
107
  return [self._plant_from_row(row) for row in rows]
108
 
109
  def get_plant(self, owner: str, plant_id: str) -> SavedPlant | None:
110
- with self._connect() as connection:
111
- row = connection.execute(
112
- "SELECT * FROM plants WHERE owner = ? AND id = ?",
113
- (owner, plant_id),
114
- ).fetchone()
 
115
  return self._plant_from_row(row) if row else None
116
 
117
  def get_public_plant(self, public_slug: str) -> PublicPlant | None:
118
- with self._connect() as connection:
119
- row = connection.execute(
120
- """
121
- SELECT public_slug, nickname, common_name, scientific_name,
122
- image_id, is_container, size_label, care_min_days,
123
- care_max_days
124
- FROM plants WHERE public_slug = ?
125
- """,
126
- (public_slug,),
127
- ).fetchone()
 
128
  if not row:
129
  return None
130
  return PublicPlant(
@@ -140,19 +159,24 @@ class GardenStore:
140
  )
141
 
142
  def delete_plant(self, owner: str, plant_id: str) -> bool:
143
- with self._connect() as connection:
144
- cursor = connection.execute(
145
- "DELETE FROM plants WHERE owner = ? AND id = ?",
146
- (owner, plant_id),
147
- )
148
- return cursor.rowcount == 1
 
 
 
 
149
 
150
  def image_reference_count(self, image_id: str) -> int:
151
- with self._connect() as connection:
152
- row = connection.execute(
153
- "SELECT COUNT(*) AS count FROM plants WHERE image_id = ?",
154
- (image_id,),
155
- ).fetchone()
 
156
  return int(row["count"])
157
 
158
  def replace_schedule(
@@ -161,41 +185,45 @@ class GardenStore:
161
  plant_id: str,
162
  events: list[dict[str, Any]],
163
  ) -> None:
164
- if self.get_plant(owner, plant_id) is None:
165
- raise PermissionError("Plant not found for owner")
166
- payload = json.dumps(events, separators=(",", ":"), sort_keys=True)
167
- with self._connect() as connection:
168
- connection.execute(
169
- """
170
- INSERT INTO schedules (plant_id, owner, events_json)
171
- VALUES (?, ?, ?)
172
- ON CONFLICT(plant_id) DO UPDATE SET
173
- owner = excluded.owner,
174
- events_json = excluded.events_json,
175
- updated_at = CURRENT_TIMESTAMP
176
- """,
177
- (plant_id, owner, payload),
178
- )
 
 
179
 
180
  def get_schedule(self, owner: str, plant_id: str) -> list[dict[str, Any]]:
181
- with self._connect() as connection:
182
- row = connection.execute(
183
- "SELECT events_json FROM schedules WHERE owner = ? AND plant_id = ?",
184
- (owner, plant_id),
185
- ).fetchone()
 
186
  return json.loads(row["events_json"]) if row else []
187
 
188
  def get_public_schedule(self, public_slug: str) -> list[dict[str, Any]]:
189
- with self._connect() as connection:
190
- row = connection.execute(
191
- """
192
- SELECT schedules.events_json
193
- FROM schedules
194
- JOIN plants ON plants.id = schedules.plant_id
195
- WHERE plants.public_slug = ?
196
- """,
197
- (public_slug,),
198
- ).fetchone()
 
199
  return json.loads(row["events_json"]) if row else []
200
 
201
  @staticmethod
 
2
 
3
  import json
4
  import secrets
5
+ import shutil
6
  import sqlite3
7
+ import threading
8
  import uuid
9
  from datetime import time
10
  from pathlib import Path
 
14
 
15
 
16
  class GardenStore:
17
+ def __init__(self, database_path: str | Path, *, snapshot_path: str | Path | None = None):
18
  self.database_path = Path(database_path)
19
+ self.snapshot_path = Path(snapshot_path) if snapshot_path else None
20
+ self._lock = threading.RLock()
21
  self.database_path.parent.mkdir(parents=True, exist_ok=True)
22
+ if self.snapshot_path and self.snapshot_path.exists() and not self.database_path.exists():
23
+ shutil.copy2(self.snapshot_path, self.database_path)
24
  self._initialize()
25
+ self._sync_snapshot()
26
 
27
  def _connect(self) -> sqlite3.Connection:
28
  connection = sqlite3.connect(self.database_path)
 
31
  return connection
32
 
33
  def _initialize(self) -> None:
34
+ with self._lock:
35
+ with self._connect() as connection:
36
+ connection.executescript(
37
+ """
38
+ CREATE TABLE IF NOT EXISTS plants (
39
+ id TEXT PRIMARY KEY,
40
+ owner TEXT NOT NULL,
41
+ public_slug TEXT NOT NULL UNIQUE,
42
+ nickname TEXT NOT NULL,
43
+ common_name TEXT NOT NULL,
44
+ scientific_name TEXT NOT NULL,
45
+ taxon_key TEXT NOT NULL,
46
+ location_name TEXT NOT NULL,
47
+ latitude REAL NOT NULL,
48
+ longitude REAL NOT NULL,
49
+ timezone TEXT NOT NULL,
50
+ preferred_time TEXT NOT NULL,
51
+ is_container INTEGER NOT NULL,
52
+ size_label TEXT NOT NULL,
53
+ image_id TEXT NOT NULL,
54
+ care_min_days INTEGER,
55
+ care_max_days INTEGER,
56
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
57
+ );
58
+ CREATE INDEX IF NOT EXISTS idx_plants_owner ON plants(owner);
59
+
60
+ CREATE TABLE IF NOT EXISTS schedules (
61
+ plant_id TEXT PRIMARY KEY,
62
+ owner TEXT NOT NULL,
63
+ events_json TEXT NOT NULL,
64
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
65
+ FOREIGN KEY (plant_id) REFERENCES plants(id) ON DELETE CASCADE
66
+ );
67
+ """
68
+ )
69
+
70
+ def _sync_snapshot(self) -> None:
71
+ if not self.snapshot_path:
72
+ return
73
+ self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
74
+ shutil.copy2(self.database_path, self.snapshot_path)
75
 
76
  def save_plant(self, owner: str, draft: PlantDraft) -> SavedPlant:
77
+ with self._lock:
78
+ plant_id = str(uuid.uuid4())
79
+ public_slug = secrets.token_urlsafe(18)
80
+ values = (
81
+ plant_id,
82
+ owner,
83
+ public_slug,
84
+ draft.nickname.strip(),
85
+ draft.common_name.strip(),
86
+ draft.scientific_name.strip(),
87
+ draft.taxon_key,
88
+ draft.location_name.strip(),
89
+ round(draft.latitude, 2),
90
+ round(draft.longitude, 2),
91
+ draft.timezone,
92
+ draft.preferred_time.isoformat(timespec="minutes"),
93
+ int(draft.is_container),
94
+ draft.size_label,
95
+ draft.image_id,
96
+ draft.care_min_days,
97
+ draft.care_max_days,
 
 
 
 
 
 
 
 
 
 
 
98
  )
99
+ with self._connect() as connection:
100
+ connection.execute(
101
+ """
102
+ INSERT INTO plants (
103
+ id, owner, public_slug, nickname, common_name, scientific_name,
104
+ taxon_key, location_name, latitude, longitude, timezone,
105
+ preferred_time, is_container, size_label, image_id,
106
+ care_min_days, care_max_days
107
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
108
+ """,
109
+ values,
110
+ )
111
+ self._sync_snapshot()
112
+ plant = self.get_plant(owner, plant_id)
113
  if plant is None:
114
  raise RuntimeError("Saved plant could not be loaded")
115
  return plant
116
 
117
  def list_plants(self, owner: str) -> list[SavedPlant]:
118
+ with self._lock:
119
+ with self._connect() as connection:
120
+ rows = connection.execute(
121
+ "SELECT * FROM plants WHERE owner = ? ORDER BY created_at, id",
122
+ (owner,),
123
+ ).fetchall()
124
  return [self._plant_from_row(row) for row in rows]
125
 
126
  def get_plant(self, owner: str, plant_id: str) -> SavedPlant | None:
127
+ with self._lock:
128
+ with self._connect() as connection:
129
+ row = connection.execute(
130
+ "SELECT * FROM plants WHERE owner = ? AND id = ?",
131
+ (owner, plant_id),
132
+ ).fetchone()
133
  return self._plant_from_row(row) if row else None
134
 
135
  def get_public_plant(self, public_slug: str) -> PublicPlant | None:
136
+ with self._lock:
137
+ with self._connect() as connection:
138
+ row = connection.execute(
139
+ """
140
+ SELECT public_slug, nickname, common_name, scientific_name,
141
+ image_id, is_container, size_label, care_min_days,
142
+ care_max_days
143
+ FROM plants WHERE public_slug = ?
144
+ """,
145
+ (public_slug,),
146
+ ).fetchone()
147
  if not row:
148
  return None
149
  return PublicPlant(
 
159
  )
160
 
161
  def delete_plant(self, owner: str, plant_id: str) -> bool:
162
+ with self._lock:
163
+ with self._connect() as connection:
164
+ cursor = connection.execute(
165
+ "DELETE FROM plants WHERE owner = ? AND id = ?",
166
+ (owner, plant_id),
167
+ )
168
+ deleted = cursor.rowcount == 1
169
+ if deleted:
170
+ self._sync_snapshot()
171
+ return deleted
172
 
173
  def image_reference_count(self, image_id: str) -> int:
174
+ with self._lock:
175
+ with self._connect() as connection:
176
+ row = connection.execute(
177
+ "SELECT COUNT(*) AS count FROM plants WHERE image_id = ?",
178
+ (image_id,),
179
+ ).fetchone()
180
  return int(row["count"])
181
 
182
  def replace_schedule(
 
185
  plant_id: str,
186
  events: list[dict[str, Any]],
187
  ) -> None:
188
+ with self._lock:
189
+ if self.get_plant(owner, plant_id) is None:
190
+ raise PermissionError("Plant not found for owner")
191
+ payload = json.dumps(events, separators=(",", ":"), sort_keys=True)
192
+ with self._connect() as connection:
193
+ connection.execute(
194
+ """
195
+ INSERT INTO schedules (plant_id, owner, events_json)
196
+ VALUES (?, ?, ?)
197
+ ON CONFLICT(plant_id) DO UPDATE SET
198
+ owner = excluded.owner,
199
+ events_json = excluded.events_json,
200
+ updated_at = CURRENT_TIMESTAMP
201
+ """,
202
+ (plant_id, owner, payload),
203
+ )
204
+ self._sync_snapshot()
205
 
206
  def get_schedule(self, owner: str, plant_id: str) -> list[dict[str, Any]]:
207
+ with self._lock:
208
+ with self._connect() as connection:
209
+ row = connection.execute(
210
+ "SELECT events_json FROM schedules WHERE owner = ? AND plant_id = ?",
211
+ (owner, plant_id),
212
+ ).fetchone()
213
  return json.loads(row["events_json"]) if row else []
214
 
215
  def get_public_schedule(self, public_slug: str) -> list[dict[str, Any]]:
216
+ with self._lock:
217
+ with self._connect() as connection:
218
+ row = connection.execute(
219
+ """
220
+ SELECT schedules.events_json
221
+ FROM schedules
222
+ JOIN plants ON plants.id = schedules.plant_id
223
+ WHERE plants.public_slug = ?
224
+ """,
225
+ (public_slug,),
226
+ ).fetchone()
227
  return json.loads(row["events_json"]) if row else []
228
 
229
  @staticmethod