Explorar el Código

Remove calendar_name from store layer; SQLite schema migration

StoreBacked and get_backend() no longer accept a calendar_name — there is
only ever one store target. CalDAV reads its calendar name from
store.caldav.calendar_name in config. SQLite drops the column entirely:
uid is now the sole primary key, and a (start, end) index is added for
fetch_range performance.

Existing SQLite databases are migrated automatically on first startup via
a table-recreate-and-copy. The calendar_name column is dropped and the
index is created.
ZiB hace 4 meses
padre
commit
29a4d0456b

+ 4 - 4
room_cal/_health.py

@@ -5,7 +5,7 @@ import random
 from dataclasses import dataclass
 from datetime import datetime, timezone
 
-from room_cal.store import Calendar, DEFAULT_CALENDAR_NAME
+from room_cal.store import Calendar
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api.types import ServiceAuthError
 from room_cal._utils import services
@@ -38,14 +38,14 @@ def get_cached() -> tuple[dict | None, bool]:
   return _cached, age > _CACHE_TTL
 
 
-async def caldav_checks(calendar_name: str = DEFAULT_CALENDAR_NAME) -> tuple[HealthResult, HealthResult]:
+async def caldav_checks() -> tuple[HealthResult, HealthResult]:
   """Open one CalDAV connection and run both the connection and calendar-exists checks."""
   from room_cal._utils import get_config
   cfg = get_config()
   if cfg.store.active != 'caldav':
     return HealthResult(ok=True), HealthResult(ok=True)
   cal_cfg = cfg.store.caldav
-  cal = Calendar(calendar_name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password)
+  cal = Calendar(cal_cfg.calendar_name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password)
   try:
     async with await cal._client() as client:
       conn = HealthResult(ok=True)
@@ -98,7 +98,7 @@ async def probe_once() -> None:
   periodic_disabled = bool(os.getenv('DISABLE_PERIODIC'))
   try:
     api = ServiceAPI()
-    conn, cal_result = await caldav_checks(DEFAULT_CALENDAR_NAME)
+    conn, cal_result = await caldav_checks()
     creds = {} if periodic_disabled else await credentials_checks(api)
     caldav_ok = conn.ok and cal_result.ok
     creds_ok = all(v.ok for v in creds.values())

+ 4 - 5
room_cal/api/_routes/closed.py

@@ -1,7 +1,6 @@
 from datetime import date
 from fastapi import APIRouter, Body
 from room_cal.closed import Closed, ClosedData
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal.api._routes import API_SOURCE
 
@@ -11,13 +10,13 @@ _SOURCE = API_SOURCE
 
 @router.get('/closed/{target_date}')
 async def get_closed(target_date: date):
-  data = await Closed(DEFAULT_CALENDAR_NAME).get(target_date)
+  data = await Closed().get(target_date)
   return data.services if data else []
 
 
 @router.get('/closed/{start_date}/{end_date}')
 async def get_closed_range(start_date: date, end_date: date):
-  events = await Closed(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
+  events = await Closed().list(start_date, end_date)
   by_date: dict[str, list] = {str(d): [] for d in iter_dates(start_date, end_date)}
   for ev in events:
     d = ev.start.date()
@@ -29,9 +28,9 @@ async def get_closed_range(start_date: date, end_date: date):
 
 @router.post('/closed/{target_date}', status_code=204)
 async def set_closed(target_date: date, body: dict = Body(...)):
-  await Closed(DEFAULT_CALENDAR_NAME).set(target_date, ClosedData(services=body.get('services', []), source=_SOURCE))
+  await Closed().set(target_date, ClosedData(services=body.get('services', []), source=_SOURCE))
 
 
 @router.delete('/closed/{target_date}', status_code=204)
 async def delete_closed(target_date: date):
-  await Closed(DEFAULT_CALENDAR_NAME).set(target_date, ClosedData(services=[]))
+  await Closed().set(target_date, ClosedData(services=[]))

+ 4 - 5
room_cal/api/_routes/discounts.py

@@ -1,7 +1,6 @@
 from datetime import date
 from fastapi import APIRouter, HTTPException, Body
 from room_cal.discount import Discount, DiscountData, DiscountType
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal.api._routes import API_SOURCE
 
@@ -11,7 +10,7 @@ _SOURCE = API_SOURCE
 
 @router.get('/discount/{target_date}')
 async def get_discount(target_date: date):
-  data = await Discount(DEFAULT_CALENDAR_NAME).get(target_date)
+  data = await Discount().get(target_date)
   if data is None:
     return None
   return {'type': data.type, 'value': data.value}
@@ -19,7 +18,7 @@ async def get_discount(target_date: date):
 
 @router.get('/discount/{start_date}/{end_date}')
 async def get_discount_range(start_date: date, end_date: date):
-  events = await Discount(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
+  events = await Discount().list(start_date, end_date)
   by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
   for ev in events:
     d = ev.start.date()
@@ -36,9 +35,9 @@ async def set_discount(target_date: date, body: dict = Body(...)):
   value = body.get('value')
   if discount_type not in ('percent', 'flat') or value is None:
     raise HTTPException(422, 'body must include "type" ("percent" or "flat") and "value"')
-  await Discount(DEFAULT_CALENDAR_NAME).set(target_date, DiscountData(type=DiscountType(discount_type), value=round(value), source=_SOURCE))
+  await Discount().set(target_date, DiscountData(type=DiscountType(discount_type), value=round(value), source=_SOURCE))
 
 
 @router.delete('/discount/{target_date}', status_code=204)
 async def delete_discount(target_date: date):
-  await Discount(DEFAULT_CALENDAR_NAME).delete_on(target_date)
+  await Discount().delete_on(target_date)

+ 2 - 3
room_cal/api/_routes/prices.py

@@ -1,14 +1,13 @@
 from datetime import date
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.pricing import CalPrice
-from room_cal.store import DEFAULT_CALENDAR_NAME
 
 router = APIRouter()
 
 
 @router.get('/price/{start_date}/{end_date}')
 async def get_price_range(start_date: date, end_date: date):
-  price_range = await CalPrice(DEFAULT_CALENDAR_NAME).get(start_date, end_date)
+  price_range = await CalPrice().get(start_date, end_date)
   return {k: v.to_dict() if v else None for k, v in price_range.items()}
 
 
@@ -17,4 +16,4 @@ async def set_price(target_date: date, body: dict = Body(...)):
   value = body.get('value')
   if value is None:
     raise HTTPException(422, 'body must include "value"')
-  await CalPrice(DEFAULT_CALENDAR_NAME).set(target_date, float(value))
+  await CalPrice().set(target_date, float(value))

+ 3 - 4
room_cal/api/_routes/reservations.py

@@ -2,7 +2,6 @@ from dataclasses import asdict
 from datetime import date
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.reservations import Reservation
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 
 router = APIRouter()
@@ -20,7 +19,7 @@ def _enrich(ev) -> dict:
 
 @router.get('/reservation/{target_date}')
 async def get_reservation(target_date: date):
-  events = await Reservation(DEFAULT_CALENDAR_NAME).list(target_date, target_date)
+  events = await Reservation().list(target_date, target_date)
   for ev in events:
     if ev.start.date() <= target_date < ev.end.date():
       return _enrich(ev)
@@ -30,7 +29,7 @@ async def get_reservation(target_date: date):
 @router.patch('/reservation/{uid}')
 async def patch_reservation(uid: str, body: dict = Body(...)):
   checkin_date = date.fromisoformat(body['checkin_date'])
-  result = await Reservation(DEFAULT_CALENDAR_NAME).patch(
+  result = await Reservation().patch(
     uid, checkin_date,
     wifi_ssid=body.get('wifi_ssid'),
     wifi_key=body.get('wifi_key'),
@@ -43,7 +42,7 @@ async def patch_reservation(uid: str, body: dict = Body(...)):
 
 @router.get('/reservation/{start_date}/{end_date}')
 async def get_reservation_range(start_date: date, end_date: date):
-  events = await Reservation(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
+  events = await Reservation().list(start_date, end_date)
   result: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
   for ev in events:
     checkin = ev.start.date()

+ 3 - 3
room_cal/closed/__init__.py

@@ -1,7 +1,7 @@
 import json
 from dataclasses import dataclass, field
 from datetime import date
-from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked
 from room_cal._utils import EventSource, safe_json_loads, filter_events, services as _services, service_name, source_line
 
 _PREFIX = 'Closed: '
@@ -33,8 +33,8 @@ def _build_description(closed_services: list[str], source: dict) -> str:
 
 
 class Closed(StoreBacked, EventSource):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
-    StoreBacked.__init__(self, calendar_name, backend=backend)
+  def __init__(self, *, backend=None):
+    StoreBacked.__init__(self, backend=backend)
     EventSource.__init__(self, 'closed')
 
   async def set(self, target_date: date, data: 'ClosedData', *, existing=_UNSET) -> None:

+ 1 - 1
room_cal/discount/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from datetime import date
 from enum import StrEnum
-from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked
 from room_cal._utils import safe_json_loads, filter_events, source_line
 
 _PREFIX = 'Discount: '

+ 13 - 14
room_cal/mcp/__init__.py

@@ -2,7 +2,6 @@ import asyncio
 from datetime import date
 from fastmcp import FastMCP
 from room_cal._utils import services as _services, safe_json_loads, iter_dates
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._health import caldav_checks, credentials_checks
 from room_cal.pricing import CalPrice
 from room_cal.pricing.calc import (
@@ -27,7 +26,7 @@ _API_SOURCE = {'domain': 'mcp', 'id': ''}
 async def health_check() -> dict:
   """Check CalDAV connection, room calendar reachability, and service credential validity. Returns {caldav_connection, calendar, credentials}."""
   (conn, cal_result), creds = await asyncio.gather(
-    caldav_checks(DEFAULT_CALENDAR_NAME),
+    caldav_checks(),
     credentials_checks(),
   )
   return {
@@ -58,14 +57,14 @@ async def convert_price(transformation: str, service: str, amount: int) -> dict:
 @mcp.tool()
 async def get_prices(start_date: str, end_date: str) -> dict:
   """Get price dicts for a date range (YYYY-MM-DD). Returns a dict keyed by date string; None for dates with no price set."""
-  price_range = await CalPrice(DEFAULT_CALENDAR_NAME).get(date.fromisoformat(start_date), date.fromisoformat(end_date))
+  price_range = await CalPrice().get(date.fromisoformat(start_date), date.fromisoformat(end_date))
   return {k: v.to_dict() if v else None for k, v in price_range.items()}
 
 
 @mcp.tool()
 async def set_price(target_date: str, value: float) -> None:
   """Set the net price for a date (YYYY-MM-DD)."""
-  await CalPrice(DEFAULT_CALENDAR_NAME).set(date.fromisoformat(target_date), value)
+  await CalPrice().set(date.fromisoformat(target_date), value)
 
 
 
@@ -74,7 +73,7 @@ async def set_price(target_date: str, value: float) -> None:
 async def patch_reservation(uid: str, checkin_date: str, wifi_ssid: str | None = None, wifi_key: str | None = None, door_code: str | None = None) -> dict:
   """Update guest access fields (wifi_ssid, wifi_key, door_code) on a stored reservation. checkin_date (YYYY-MM-DD) is required to locate the event. Only supplied fields are written. Returns updated ReservationData as a dict."""
   from dataclasses import asdict
-  result = await Reservation(DEFAULT_CALENDAR_NAME).patch(
+  result = await Reservation().patch(
     uid, date.fromisoformat(checkin_date),
     wifi_ssid=wifi_ssid, wifi_key=wifi_key, door_code=door_code,
   )
@@ -87,7 +86,7 @@ async def patch_reservation(uid: str, checkin_date: str, wifi_ssid: str | None =
 async def get_reservation(target_date: str) -> dict | None:
   """Get the reservation covering a date (checkin ≤ date < checkout), or null."""
   d = date.fromisoformat(target_date)
-  for ev in await Reservation(DEFAULT_CALENDAR_NAME).list(d, d):
+  for ev in await Reservation().list(d, d):
     if ev['start'].date() <= d < ev['end'].date():
       xd = safe_json_loads(ev['x_data']) or {}
       xd['checkin_date'] = ev['start'].date().isoformat()
@@ -101,7 +100,7 @@ async def get_reservations_range(start_date: str, end_date: str) -> dict:
   """Get reservations for each date in a range. Returns {date: dict or null}."""
   start = date.fromisoformat(start_date)
   end = date.fromisoformat(end_date)
-  events = await Reservation(DEFAULT_CALENDAR_NAME).list(start, end)
+  events = await Reservation().list(start, end)
   result: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
   for ev in events:
     checkin = ev['start'].date()
@@ -118,7 +117,7 @@ async def get_reservations_range(start_date: str, end_date: str) -> dict:
 @mcp.tool()
 async def get_closed(target_date: str) -> list[str]:
   """Get closed service keys for a date. Empty list means open."""
-  return await Closed(DEFAULT_CALENDAR_NAME).get(date.fromisoformat(target_date)) or []
+  return await Closed().get(date.fromisoformat(target_date)) or []
 
 
 @mcp.tool()
@@ -126,7 +125,7 @@ async def get_closed_range(start_date: str, end_date: str) -> dict:
   """Get closed services for each date in a range. Returns {date: [service, ...]}."""
   start = date.fromisoformat(start_date)
   end = date.fromisoformat(end_date)
-  events = await Closed(DEFAULT_CALENDAR_NAME).list(start, end)
+  events = await Closed().list(start, end)
   by_date: dict[str, list] = {str(d): [] for d in iter_dates(start, end)}
   for ev in events:
     d = ev['start'].date()
@@ -139,13 +138,13 @@ async def get_closed_range(start_date: str, end_date: str) -> dict:
 @mcp.tool()
 async def set_closed(target_date: str, services: list[str]) -> None:
   """Block a date for the given services. Empty list unblocks the date."""
-  await Closed(DEFAULT_CALENDAR_NAME).set(date.fromisoformat(target_date), services, source=_API_SOURCE)
+  await Closed().set(date.fromisoformat(target_date), services, source=_API_SOURCE)
 
 
 @mcp.tool()
 async def get_discount(target_date: str) -> dict | None:
   """Get discount for a date, or null if none set."""
-  return await Discount(DEFAULT_CALENDAR_NAME).get(date.fromisoformat(target_date))
+  return await Discount().get(date.fromisoformat(target_date))
 
 
 @mcp.tool()
@@ -153,7 +152,7 @@ async def get_discounts_range(start_date: str, end_date: str) -> dict:
   """Get discounts for each date in a range. Returns {date: {type, value} or null}."""
   start = date.fromisoformat(start_date)
   end = date.fromisoformat(end_date)
-  events = await Discount(DEFAULT_CALENDAR_NAME).list(start, end)
+  events = await Discount().list(start, end)
   by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
   for ev in events:
     d = ev['start'].date()
@@ -167,7 +166,7 @@ async def get_discounts_range(start_date: str, end_date: str) -> dict:
 @mcp.tool()
 async def set_discount(target_date: str, discount_type: str, value: int) -> None:
   """Set a discount. discount_type: 'percent' or 'flat'."""
-  await Discount(DEFAULT_CALENDAR_NAME).set(
+  await Discount().set(
     date.fromisoformat(target_date), discount_type, value, source=_API_SOURCE
   )
 
@@ -175,7 +174,7 @@ async def set_discount(target_date: str, discount_type: str, value: int) -> None
 @mcp.tool()
 async def delete_discount(target_date: str) -> None:
   """Remove the discount on a date."""
-  await Discount(DEFAULT_CALENDAR_NAME).delete_on(date.fromisoformat(target_date))
+  await Discount().delete_on(date.fromisoformat(target_date))
 
 
 @mcp.tool()

+ 4 - 4
room_cal/pricing/price_calendar.py

@@ -1,7 +1,7 @@
 import json
 from dataclasses import dataclass, field
 from datetime import date
-from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked
 from room_cal._utils import safe_json_loads, filter_events, services as _services, get_config
 from room_cal.discount import DiscountType
 from . import calc
@@ -50,8 +50,8 @@ PriceList = dict[str, 'PriceData | None']
 
 
 class CalPrice(StoreBacked):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
-    super().__init__(calendar_name, backend=backend)
+  def __init__(self, *, backend=None):
+    super().__init__(backend=backend)
     self._services = [s.key for s in _services()]
 
   async def _get_prices(self, target_date: date) -> 'PriceData | None':
@@ -85,7 +85,7 @@ class CalPrice(StoreBacked):
     from room_cal.discount import Discount
     base = round(base)
     if discount_rec is _UNSET:
-      discount_rec = await Discount(self._calendar_name).get(target_date)
+      discount_rec = await Discount().get(target_date)
 
     discount = 0
     if discount_rec:

+ 3 - 3
room_cal/reservations/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from datetime import date, datetime, timedelta
 from zoneinfo import ZoneInfo
-from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked
 from room_cal._utils import EventSource, safe_json_loads, filter_events, service_name, get_config
 from room_cal._mnemonic import wifi_key as _gen_wifi_key, door_code as _gen_door_code
 
@@ -30,8 +30,8 @@ def _reservation_defaults():
 
 
 class Reservation(StoreBacked, EventSource):
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
-    StoreBacked.__init__(self, calendar_name, backend=backend)
+  def __init__(self, *, backend=None):
+    StoreBacked.__init__(self, backend=backend)
     EventSource.__init__(self, 'reservations')
 
   async def set(self, data: dict) -> None:

+ 8 - 11
room_cal/store/__init__.py

@@ -5,11 +5,11 @@ from room_cal.store._backend import EventBackend, EventRecord
 from room_cal.store._caldav import Calendar, DEFAULT_CALENDAR_NAME, get_calendar
 from room_cal.store import _caldav, _sqlite  # trigger self-registration
 
-_backend_instances: dict[tuple[str, str], EventBackend] = {}
+_backend_instances: dict[str, EventBackend] = {}
 
 
-def get_backend(name: str = DEFAULT_CALENDAR_NAME) -> EventBackend:
-  """Return the shared EventBackend for the given store name.
+def get_backend() -> EventBackend:
+  """Return the shared EventBackend.
 
   The backend type is determined by store.active in config.json
   ('caldav' by default, 'sqlite' for local SQLite storage).
@@ -18,13 +18,12 @@ def get_backend(name: str = DEFAULT_CALENDAR_NAME) -> EventBackend:
   from room_cal.store._backend import _backend_factories
   cfg = get_config()
   active = cfg.store.active
-  key = (active, name)
-  if key not in _backend_instances:
+  if active not in _backend_instances:
     factory = _backend_factories.get(active)
     if factory is None:
       raise ValueError(f'Unknown store backend: {active!r}')
-    _backend_instances[key] = factory.from_store_config(cfg.store, name)
-  return _backend_instances[key]
+    _backend_instances[active] = factory.from_store_config(cfg.store)
+  return _backend_instances[active]
 
 
 def clear_caches() -> int:
@@ -46,10 +45,8 @@ def clear_caches() -> int:
 
 class StoreBacked:
   """Base for objects backed by a single named event store."""
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *,
-               backend: 'EventBackend | None' = None):
-    self._calendar_name = calendar_name
-    self._backend: EventBackend = backend if backend is not None else get_backend(calendar_name)
+  def __init__(self, *, backend: 'EventBackend | None' = None):
+    self._backend: EventBackend = backend if backend is not None else get_backend()
 
   async def _fetch_date_range(self, start_date: date, end_date: date) -> list[EventRecord]:
     start = datetime(start_date.year, start_date.month, start_date.day)

+ 2 - 2
room_cal/store/_caldav.py

@@ -234,9 +234,9 @@ class CalDAVBackend:
     self._cal = cal
 
   @classmethod
-  def from_store_config(cls, store_cfg, calendar_name: str) -> 'CalDAVBackend':
+  def from_store_config(cls, store_cfg) -> 'CalDAVBackend':
     cal_cfg = store_cfg.caldav
-    return cls(Calendar(calendar_name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password))
+    return cls(Calendar(cal_cfg.calendar_name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password))
 
   def clear_cache(self):
     self._cal._event_cache.clear()

+ 47 - 39
room_cal/store/_sqlite.py

@@ -10,37 +10,60 @@ _log = logging.getLogger(__name__)
 
 _CREATE_TABLE = '''
 CREATE TABLE IF NOT EXISTS events (
-  uid           TEXT NOT NULL,
-  calendar_name TEXT NOT NULL,
-  start         TEXT NOT NULL,
-  end           TEXT NOT NULL,
-  all_day       INTEGER NOT NULL,
-  title         TEXT NOT NULL,
-  description   TEXT NOT NULL DEFAULT '',
-  x_data        TEXT,
-  PRIMARY KEY (uid, calendar_name)
+  uid         TEXT NOT NULL PRIMARY KEY,
+  start       TEXT NOT NULL,
+  end         TEXT NOT NULL,
+  all_day     INTEGER NOT NULL,
+  title       TEXT NOT NULL,
+  description TEXT NOT NULL DEFAULT '',
+  x_data      TEXT
 )
 '''
 
+_CREATE_INDEX = 'CREATE INDEX IF NOT EXISTS idx_events_start_end ON events(start, end)'
+
 # This is not a carefully optimized SQL implementation. It shoves JSON blobs into a column
 # But it is very easy to map to CalDAV, which is where the room_cal module was born, and
 # it's so much faster than CalDAV that it's hard to care about the simplistic design
-_COLS = 'uid, calendar_name, start, end, all_day, title, description, x_data'
+_COLS = 'uid, start, end, all_day, title, description, x_data'
+
+_MIGRATE_DROP_CALENDAR_NAME = '''
+CREATE TABLE events_new (
+  uid         TEXT NOT NULL PRIMARY KEY,
+  start       TEXT NOT NULL,
+  end         TEXT NOT NULL,
+  all_day     INTEGER NOT NULL,
+  title       TEXT NOT NULL,
+  description TEXT NOT NULL DEFAULT '',
+  x_data      TEXT
+);
+INSERT INTO events_new SELECT uid, start, end, all_day, title, description, x_data FROM events;
+DROP TABLE events;
+ALTER TABLE events_new RENAME TO events;
+CREATE INDEX IF NOT EXISTS idx_events_start_end ON events(start, end);
+'''
 
 
 @register_backend('sqlite')
 class SQLiteBackend:
-  def __init__(self, db_path: Path, calendar_name: str):
+  def __init__(self, db_path: Path):
     self._db_path = db_path
-    self._calendar_name = calendar_name
 
   @classmethod
-  def from_store_config(cls, store_cfg, calendar_name: str) -> 'SQLiteBackend':
+  def from_store_config(cls, store_cfg) -> 'SQLiteBackend':
+    import sqlite3
     from room_cal._utils import data_home
     db_path = Path(store_cfg.sqlite.db_path)
     if not db_path.is_absolute():
       db_path = data_home() / db_path
-    return cls(db_path, calendar_name)
+    with sqlite3.connect(db_path) as conn:
+      cols = {row[1] for row in conn.execute('PRAGMA table_info(events)')}
+      if 'calendar_name' in cols:
+        conn.executescript(_MIGRATE_DROP_CALENDAR_NAME)
+      else:
+        conn.execute(_CREATE_TABLE)
+        conn.execute(_CREATE_INDEX)
+    return cls(db_path)
 
   @asynccontextmanager
   async def _connect(self):
@@ -52,7 +75,7 @@ class SQLiteBackend:
       raise sqlite3.OperationalError(f"SQLite error for {self._db_path}: {e}") from None
 
   def _to_record(self, row) -> EventRecord:
-    uid, _, start, end, all_day, title, description, x_data = row
+    uid, start, end, all_day, title, description, x_data = row
     return EventRecord(
       uid=uid,
       start=datetime.fromisoformat(start),
@@ -67,8 +90,8 @@ class SQLiteBackend:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       cursor = await db.execute(
-        f'SELECT {_COLS} FROM events WHERE calendar_name = ? AND start < ? AND end > ? ORDER BY start',
-        (self._calendar_name, end.isoformat(), start.isoformat()),
+        f'SELECT {_COLS} FROM events WHERE start < ? AND end > ? ORDER BY start',
+        (end.isoformat(), start.isoformat()),
       )
       rows = await cursor.fetchall()
     return [self._to_record(r) for r in rows]
@@ -77,8 +100,8 @@ class SQLiteBackend:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       await db.execute(
-        f'INSERT INTO events ({_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
-        (record.uid, self._calendar_name, record.start.isoformat(), record.end.isoformat(),
+        f'INSERT INTO events ({_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?)',
+        (record.uid, record.start.isoformat(), record.end.isoformat(),
          int(record.all_day), record.title, record.description, record.x_data),
       )
       await db.commit()
@@ -90,25 +113,13 @@ class SQLiteBackend:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       if title is not None:
-        await db.execute(
-          'UPDATE events SET title = ? WHERE uid = ? AND calendar_name = ?',
-          (title, uid, self._calendar_name),
-        )
+        await db.execute('UPDATE events SET title = ? WHERE uid = ?', (title, uid))
       if description is not None:
-        await db.execute(
-          'UPDATE events SET description = ? WHERE uid = ? AND calendar_name = ?',
-          (description, uid, self._calendar_name),
-        )
+        await db.execute('UPDATE events SET description = ? WHERE uid = ?', (description, uid))
       if x_data is not None:
-        await db.execute(
-          'UPDATE events SET x_data = ? WHERE uid = ? AND calendar_name = ?',
-          (x_data, uid, self._calendar_name),
-        )
+        await db.execute('UPDATE events SET x_data = ? WHERE uid = ?', (x_data, uid))
       await db.commit()
-      cursor = await db.execute(
-        f'SELECT {_COLS} FROM events WHERE uid = ? AND calendar_name = ?',
-        (uid, self._calendar_name),
-      )
+      cursor = await db.execute(f'SELECT {_COLS} FROM events WHERE uid = ?', (uid,))
       row = await cursor.fetchone()
     _log.info('update uid=%s', uid)
     return self._to_record(row)
@@ -116,9 +127,6 @@ class SQLiteBackend:
   async def delete(self, uid: str) -> None:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
-      await db.execute(
-        'DELETE FROM events WHERE uid = ? AND calendar_name = ?',
-        (uid, self._calendar_name),
-      )
+      await db.execute('DELETE FROM events WHERE uid = ?', (uid,))
       await db.commit()
     _log.info('delete uid=%s', uid)

+ 1 - 1
room_cal/user_cal/__init__.py

@@ -12,7 +12,7 @@ class UserCal(StoreBacked, EventSource):
   def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
     if backend is None:
       backend = CalDAVBackend(get_calendar(calendar_name))
-    StoreBacked.__init__(self, calendar_name, backend=backend)
+    StoreBacked.__init__(self, backend=backend)
     EventSource.__init__(self, 'user_cal')
 
   async def find_closed(self, start_date: date, end_date: date) -> list:

+ 2 - 3
room_cal/workflows/closed.py

@@ -3,7 +3,6 @@ from typing import Callable, TYPE_CHECKING
 from fastapi import APIRouter, BackgroundTasks
 from pydantic import BaseModel
 
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal.closed import Closed, ClosedData
 from room_cal.actions.fetch_closed import fetch_closed
 from room_cal.actions.push_closed import push_closed
@@ -31,7 +30,7 @@ async def set_closed(
   source: dict = _SOURCE,
   schedule: Callable | None = None,
 ) -> None:
-  await Closed(DEFAULT_CALENDAR_NAME).set(target_date, ClosedData(services=services, source=source))
+  await Closed().set(target_date, ClosedData(services=services, source=source))
   await _push(push_closed(target_date, target_date), schedule)
 
 
@@ -41,7 +40,7 @@ async def delete_closed(
   source: dict = _SOURCE,
   schedule: Callable | None = None,
 ) -> None:
-  await Closed(DEFAULT_CALENDAR_NAME).set(target_date, ClosedData(services=[], source=source))
+  await Closed().set(target_date, ClosedData(services=[], source=source))
   await _push(push_closed(target_date, target_date), schedule)
 
 

+ 3 - 4
room_cal/workflows/prices.py

@@ -3,7 +3,6 @@ from typing import Callable, TYPE_CHECKING
 from fastapi import APIRouter, BackgroundTasks, HTTPException
 from pydantic import BaseModel
 
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal.pricing import CalPrice
 from room_cal.discount import Discount, DiscountData, DiscountType
 from room_cal.actions.apply_discounts import apply_discounts
@@ -37,7 +36,7 @@ async def set_price(
   *,
   schedule: Callable | None = None,
 ) -> None:
-  await CalPrice(DEFAULT_CALENDAR_NAME).set(target_date, value)
+  await CalPrice().set(target_date, value)
   await apply_discounts(target_date, target_date)
   await _push(push_prices(target_date, target_date), schedule)
 
@@ -50,7 +49,7 @@ async def set_discount(
   source: dict = _SOURCE,
   schedule: Callable | None = None,
 ) -> None:
-  await Discount(DEFAULT_CALENDAR_NAME).set(target_date, DiscountData(type=DiscountType(discount_type), value=round(value), source=source))
+  await Discount().set(target_date, DiscountData(type=DiscountType(discount_type), value=round(value), source=source))
   await apply_discounts(target_date, target_date)
   await _push(push_prices(target_date, target_date), schedule)
 
@@ -60,7 +59,7 @@ async def delete_discount(
   *,
   schedule: Callable | None = None,
 ) -> None:
-  await Discount(DEFAULT_CALENDAR_NAME).delete_on(target_date)
+  await Discount().delete_on(target_date)
   await apply_discounts(target_date, target_date)
   await _push(push_prices(target_date, target_date), schedule)
 

+ 14 - 16
tests/test_store_sqlite.py

@@ -21,12 +21,12 @@ from room_cal.pricing.price_calendar import CalPrice
 
 @pytest.fixture
 def sqlite_backend(tmp_path):
-  return SQLiteBackend(tmp_path / 'test.db', 'Room')
+  return SQLiteBackend(tmp_path / 'test.db')
 
 
 @pytest.fixture
 def house_backend(tmp_path):
-  return SQLiteBackend(tmp_path / 'test.db', 'House')
+  return SQLiteBackend(tmp_path / 'test.db')
 
 
 # ── SQLiteBackend direct ───────────────────────────────────────────────────────
@@ -103,25 +103,23 @@ async def test_backend_fetch_range_boundary(sqlite_backend):
 
 
 @pytest.mark.asyncio
-async def test_backend_multiple_calendars_isolated(tmp_path):
-  """Two backends sharing the same DB file but different calendar_names don't see each other's events."""
+async def test_backend_uid_is_unique_primary_key(tmp_path):
+  """uid is the sole primary key; inserting a duplicate uid raises."""
+  import sqlite3
   from room_cal.store._backend import EventRecord
   from datetime import datetime
 
-  db = tmp_path / 'shared.db'
-  room = SQLiteBackend(db, 'Room')
-  house = SQLiteBackend(db, 'House')
-
+  backend = SQLiteBackend(tmp_path / 'test.db')
   rec = EventRecord(
-    uid='r1', start=datetime(2024, 9, 1), end=datetime(2024, 9, 2),
-    all_day=True, title='Room Event', description='', x_data=None,
+    uid='dup', start=datetime(2024, 9, 1), end=datetime(2024, 9, 2),
+    all_day=True, title='First', description='', x_data=None,
   )
-  await room.create(rec)
-
-  room_results = await room.fetch_range(datetime(2024, 8, 31), datetime(2024, 9, 3))
-  house_results = await house.fetch_range(datetime(2024, 8, 31), datetime(2024, 9, 3))
-  assert len(room_results) == 1
-  assert house_results == []
+  await backend.create(rec)
+  with pytest.raises(Exception):
+    await backend.create(EventRecord(
+      uid='dup', start=datetime(2024, 9, 3), end=datetime(2024, 9, 4),
+      all_day=True, title='Second', description='', x_data=None,
+    ))
 
 
 # ── Closed ─────────────────────────────────────────────────────────────────────