浏览代码

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 4 月之前
父节点
当前提交
29a4d0456b

+ 4 - 4
room_cal/_health.py

@@ -5,7 +5,7 @@ import random
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 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 import ServiceAPI
 from room_cal.service_api.types import ServiceAuthError
 from room_cal.service_api.types import ServiceAuthError
 from room_cal._utils import services
 from room_cal._utils import services
@@ -38,14 +38,14 @@ def get_cached() -> tuple[dict | None, bool]:
   return _cached, age > _CACHE_TTL
   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."""
   """Open one CalDAV connection and run both the connection and calendar-exists checks."""
   from room_cal._utils import get_config
   from room_cal._utils import get_config
   cfg = get_config()
   cfg = get_config()
   if cfg.store.active != 'caldav':
   if cfg.store.active != 'caldav':
     return HealthResult(ok=True), HealthResult(ok=True)
     return HealthResult(ok=True), HealthResult(ok=True)
   cal_cfg = cfg.store.caldav
   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:
   try:
     async with await cal._client() as client:
     async with await cal._client() as client:
       conn = HealthResult(ok=True)
       conn = HealthResult(ok=True)
@@ -98,7 +98,7 @@ async def probe_once() -> None:
   periodic_disabled = bool(os.getenv('DISABLE_PERIODIC'))
   periodic_disabled = bool(os.getenv('DISABLE_PERIODIC'))
   try:
   try:
     api = ServiceAPI()
     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)
     creds = {} if periodic_disabled else await credentials_checks(api)
     caldav_ok = conn.ok and cal_result.ok
     caldav_ok = conn.ok and cal_result.ok
     creds_ok = all(v.ok for v in creds.values())
     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 datetime import date
 from fastapi import APIRouter, Body
 from fastapi import APIRouter, Body
 from room_cal.closed import Closed, ClosedData
 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._utils import safe_json_loads, iter_dates
 from room_cal.api._routes import API_SOURCE
 from room_cal.api._routes import API_SOURCE
 
 
@@ -11,13 +10,13 @@ _SOURCE = API_SOURCE
 
 
 @router.get('/closed/{target_date}')
 @router.get('/closed/{target_date}')
 async def get_closed(target_date: 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 []
   return data.services if data else []
 
 
 
 
 @router.get('/closed/{start_date}/{end_date}')
 @router.get('/closed/{start_date}/{end_date}')
 async def get_closed_range(start_date: date, end_date: 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)}
   by_date: dict[str, list] = {str(d): [] for d in iter_dates(start_date, end_date)}
   for ev in events:
   for ev in events:
     d = ev.start.date()
     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)
 @router.post('/closed/{target_date}', status_code=204)
 async def set_closed(target_date: date, body: dict = Body(...)):
 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)
 @router.delete('/closed/{target_date}', status_code=204)
 async def delete_closed(target_date: date):
 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 datetime import date
 from fastapi import APIRouter, HTTPException, Body
 from fastapi import APIRouter, HTTPException, Body
 from room_cal.discount import Discount, DiscountData, DiscountType
 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._utils import safe_json_loads, iter_dates
 from room_cal.api._routes import API_SOURCE
 from room_cal.api._routes import API_SOURCE
 
 
@@ -11,7 +10,7 @@ _SOURCE = API_SOURCE
 
 
 @router.get('/discount/{target_date}')
 @router.get('/discount/{target_date}')
 async def get_discount(target_date: 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:
   if data is None:
     return None
     return None
   return {'type': data.type, 'value': data.value}
   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}')
 @router.get('/discount/{start_date}/{end_date}')
 async def get_discount_range(start_date: date, end_date: 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)}
   by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
   for ev in events:
   for ev in events:
     d = ev.start.date()
     d = ev.start.date()
@@ -36,9 +35,9 @@ async def set_discount(target_date: date, body: dict = Body(...)):
   value = body.get('value')
   value = body.get('value')
   if discount_type not in ('percent', 'flat') or value is None:
   if discount_type not in ('percent', 'flat') or value is None:
     raise HTTPException(422, 'body must include "type" ("percent" or "flat") and "value"')
     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)
 @router.delete('/discount/{target_date}', status_code=204)
 async def delete_discount(target_date: date):
 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 datetime import date
 from fastapi import APIRouter, Body, HTTPException
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.pricing import CalPrice
 from room_cal.pricing import CalPrice
-from room_cal.store import DEFAULT_CALENDAR_NAME
 
 
 router = APIRouter()
 router = APIRouter()
 
 
 
 
 @router.get('/price/{start_date}/{end_date}')
 @router.get('/price/{start_date}/{end_date}')
 async def get_price_range(start_date: date, end_date: 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()}
   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')
   value = body.get('value')
   if value is None:
   if value is None:
     raise HTTPException(422, 'body must include "value"')
     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 datetime import date
 from fastapi import APIRouter, Body, HTTPException
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.reservations import Reservation
 from room_cal.reservations import Reservation
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal._utils import safe_json_loads, iter_dates
 
 
 router = APIRouter()
 router = APIRouter()
@@ -20,7 +19,7 @@ def _enrich(ev) -> dict:
 
 
 @router.get('/reservation/{target_date}')
 @router.get('/reservation/{target_date}')
 async def get_reservation(target_date: 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:
   for ev in events:
     if ev.start.date() <= target_date < ev.end.date():
     if ev.start.date() <= target_date < ev.end.date():
       return _enrich(ev)
       return _enrich(ev)
@@ -30,7 +29,7 @@ async def get_reservation(target_date: date):
 @router.patch('/reservation/{uid}')
 @router.patch('/reservation/{uid}')
 async def patch_reservation(uid: str, body: dict = Body(...)):
 async def patch_reservation(uid: str, body: dict = Body(...)):
   checkin_date = date.fromisoformat(body['checkin_date'])
   checkin_date = date.fromisoformat(body['checkin_date'])
-  result = await Reservation(DEFAULT_CALENDAR_NAME).patch(
+  result = await Reservation().patch(
     uid, checkin_date,
     uid, checkin_date,
     wifi_ssid=body.get('wifi_ssid'),
     wifi_ssid=body.get('wifi_ssid'),
     wifi_key=body.get('wifi_key'),
     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}')
 @router.get('/reservation/{start_date}/{end_date}')
 async def get_reservation_range(start_date: date, end_date: 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)}
   result: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
   for ev in events:
   for ev in events:
     checkin = ev.start.date()
     checkin = ev.start.date()

+ 3 - 3
room_cal/closed/__init__.py

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

+ 13 - 14
room_cal/mcp/__init__.py

@@ -2,7 +2,6 @@ import asyncio
 from datetime import date
 from datetime import date
 from fastmcp import FastMCP
 from fastmcp import FastMCP
 from room_cal._utils import services as _services, safe_json_loads, iter_dates
 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._health import caldav_checks, credentials_checks
 from room_cal.pricing import CalPrice
 from room_cal.pricing import CalPrice
 from room_cal.pricing.calc import (
 from room_cal.pricing.calc import (
@@ -27,7 +26,7 @@ _API_SOURCE = {'domain': 'mcp', 'id': ''}
 async def health_check() -> dict:
 async def health_check() -> dict:
   """Check CalDAV connection, room calendar reachability, and service credential validity. Returns {caldav_connection, calendar, credentials}."""
   """Check CalDAV connection, room calendar reachability, and service credential validity. Returns {caldav_connection, calendar, credentials}."""
   (conn, cal_result), creds = await asyncio.gather(
   (conn, cal_result), creds = await asyncio.gather(
-    caldav_checks(DEFAULT_CALENDAR_NAME),
+    caldav_checks(),
     credentials_checks(),
     credentials_checks(),
   )
   )
   return {
   return {
@@ -58,14 +57,14 @@ async def convert_price(transformation: str, service: str, amount: int) -> dict:
 @mcp.tool()
 @mcp.tool()
 async def get_prices(start_date: str, end_date: str) -> dict:
 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."""
   """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()}
   return {k: v.to_dict() if v else None for k, v in price_range.items()}
 
 
 
 
 @mcp.tool()
 @mcp.tool()
 async def set_price(target_date: str, value: float) -> None:
 async def set_price(target_date: str, value: float) -> None:
   """Set the net price for a date (YYYY-MM-DD)."""
   """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:
 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."""
   """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
   from dataclasses import asdict
-  result = await Reservation(DEFAULT_CALENDAR_NAME).patch(
+  result = await Reservation().patch(
     uid, date.fromisoformat(checkin_date),
     uid, date.fromisoformat(checkin_date),
     wifi_ssid=wifi_ssid, wifi_key=wifi_key, door_code=door_code,
     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:
 async def get_reservation(target_date: str) -> dict | None:
   """Get the reservation covering a date (checkin ≤ date < checkout), or null."""
   """Get the reservation covering a date (checkin ≤ date < checkout), or null."""
   d = date.fromisoformat(target_date)
   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():
     if ev['start'].date() <= d < ev['end'].date():
       xd = safe_json_loads(ev['x_data']) or {}
       xd = safe_json_loads(ev['x_data']) or {}
       xd['checkin_date'] = ev['start'].date().isoformat()
       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}."""
   """Get reservations for each date in a range. Returns {date: dict or null}."""
   start = date.fromisoformat(start_date)
   start = date.fromisoformat(start_date)
   end = date.fromisoformat(end_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)}
   result: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
   for ev in events:
   for ev in events:
     checkin = ev['start'].date()
     checkin = ev['start'].date()
@@ -118,7 +117,7 @@ async def get_reservations_range(start_date: str, end_date: str) -> dict:
 @mcp.tool()
 @mcp.tool()
 async def get_closed(target_date: str) -> list[str]:
 async def get_closed(target_date: str) -> list[str]:
   """Get closed service keys for a date. Empty list means open."""
   """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()
 @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, ...]}."""
   """Get closed services for each date in a range. Returns {date: [service, ...]}."""
   start = date.fromisoformat(start_date)
   start = date.fromisoformat(start_date)
   end = date.fromisoformat(end_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)}
   by_date: dict[str, list] = {str(d): [] for d in iter_dates(start, end)}
   for ev in events:
   for ev in events:
     d = ev['start'].date()
     d = ev['start'].date()
@@ -139,13 +138,13 @@ async def get_closed_range(start_date: str, end_date: str) -> dict:
 @mcp.tool()
 @mcp.tool()
 async def set_closed(target_date: str, services: list[str]) -> None:
 async def set_closed(target_date: str, services: list[str]) -> None:
   """Block a date for the given services. Empty list unblocks the date."""
   """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()
 @mcp.tool()
 async def get_discount(target_date: str) -> dict | None:
 async def get_discount(target_date: str) -> dict | None:
   """Get discount for a date, or null if none set."""
   """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()
 @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}."""
   """Get discounts for each date in a range. Returns {date: {type, value} or null}."""
   start = date.fromisoformat(start_date)
   start = date.fromisoformat(start_date)
   end = date.fromisoformat(end_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)}
   by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
   for ev in events:
   for ev in events:
     d = ev['start'].date()
     d = ev['start'].date()
@@ -167,7 +166,7 @@ async def get_discounts_range(start_date: str, end_date: str) -> dict:
 @mcp.tool()
 @mcp.tool()
 async def set_discount(target_date: str, discount_type: str, value: int) -> None:
 async def set_discount(target_date: str, discount_type: str, value: int) -> None:
   """Set a discount. discount_type: 'percent' or 'flat'."""
   """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
     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()
 @mcp.tool()
 async def delete_discount(target_date: str) -> None:
 async def delete_discount(target_date: str) -> None:
   """Remove the discount on a date."""
   """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()
 @mcp.tool()

+ 4 - 4
room_cal/pricing/price_calendar.py

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

+ 3 - 3
room_cal/reservations/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import date, datetime, timedelta
 from datetime import date, datetime, timedelta
 from zoneinfo import ZoneInfo
 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._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
 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):
 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')
     EventSource.__init__(self, 'reservations')
 
 
   async def set(self, data: dict) -> None:
   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._caldav import Calendar, DEFAULT_CALENDAR_NAME, get_calendar
 from room_cal.store import _caldav, _sqlite  # trigger self-registration
 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
   The backend type is determined by store.active in config.json
   ('caldav' by default, 'sqlite' for local SQLite storage).
   ('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
   from room_cal.store._backend import _backend_factories
   cfg = get_config()
   cfg = get_config()
   active = cfg.store.active
   active = cfg.store.active
-  key = (active, name)
-  if key not in _backend_instances:
+  if active not in _backend_instances:
     factory = _backend_factories.get(active)
     factory = _backend_factories.get(active)
     if factory is None:
     if factory is None:
       raise ValueError(f'Unknown store backend: {active!r}')
       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:
 def clear_caches() -> int:
@@ -46,10 +45,8 @@ def clear_caches() -> int:
 
 
 class StoreBacked:
 class StoreBacked:
   """Base for objects backed by a single named event store."""
   """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]:
   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)
     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
     self._cal = cal
 
 
   @classmethod
   @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
     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):
   def clear_cache(self):
     self._cal._event_cache.clear()
     self._cal._event_cache.clear()

+ 47 - 39
room_cal/store/_sqlite.py

@@ -10,37 +10,60 @@ _log = logging.getLogger(__name__)
 
 
 _CREATE_TABLE = '''
 _CREATE_TABLE = '''
 CREATE TABLE IF NOT EXISTS events (
 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
 # 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
 # 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
 # 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')
 @register_backend('sqlite')
 class SQLiteBackend:
 class SQLiteBackend:
-  def __init__(self, db_path: Path, calendar_name: str):
+  def __init__(self, db_path: Path):
     self._db_path = db_path
     self._db_path = db_path
-    self._calendar_name = calendar_name
 
 
   @classmethod
   @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
     from room_cal._utils import data_home
     db_path = Path(store_cfg.sqlite.db_path)
     db_path = Path(store_cfg.sqlite.db_path)
     if not db_path.is_absolute():
     if not db_path.is_absolute():
       db_path = data_home() / db_path
       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
   @asynccontextmanager
   async def _connect(self):
   async def _connect(self):
@@ -52,7 +75,7 @@ class SQLiteBackend:
       raise sqlite3.OperationalError(f"SQLite error for {self._db_path}: {e}") from None
       raise sqlite3.OperationalError(f"SQLite error for {self._db_path}: {e}") from None
 
 
   def _to_record(self, row) -> EventRecord:
   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(
     return EventRecord(
       uid=uid,
       uid=uid,
       start=datetime.fromisoformat(start),
       start=datetime.fromisoformat(start),
@@ -67,8 +90,8 @@ class SQLiteBackend:
     async with self._connect() as db:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       await db.execute(_CREATE_TABLE)
       cursor = await db.execute(
       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()
       rows = await cursor.fetchall()
     return [self._to_record(r) for r in rows]
     return [self._to_record(r) for r in rows]
@@ -77,8 +100,8 @@ class SQLiteBackend:
     async with self._connect() as db:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       await db.execute(_CREATE_TABLE)
       await db.execute(
       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),
          int(record.all_day), record.title, record.description, record.x_data),
       )
       )
       await db.commit()
       await db.commit()
@@ -90,25 +113,13 @@ class SQLiteBackend:
     async with self._connect() as db:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       await db.execute(_CREATE_TABLE)
       if title is not None:
       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:
       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:
       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()
       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()
       row = await cursor.fetchone()
     _log.info('update uid=%s', uid)
     _log.info('update uid=%s', uid)
     return self._to_record(row)
     return self._to_record(row)
@@ -116,9 +127,6 @@ class SQLiteBackend:
   async def delete(self, uid: str) -> None:
   async def delete(self, uid: str) -> None:
     async with self._connect() as db:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       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()
       await db.commit()
     _log.info('delete uid=%s', uid)
     _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):
   def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
     if backend is None:
     if backend is None:
       backend = CalDAVBackend(get_calendar(calendar_name))
       backend = CalDAVBackend(get_calendar(calendar_name))
-    StoreBacked.__init__(self, calendar_name, backend=backend)
+    StoreBacked.__init__(self, backend=backend)
     EventSource.__init__(self, 'user_cal')
     EventSource.__init__(self, 'user_cal')
 
 
   async def find_closed(self, start_date: date, end_date: date) -> list:
   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 fastapi import APIRouter, BackgroundTasks
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal.closed import Closed, ClosedData
 from room_cal.closed import Closed, ClosedData
 from room_cal.actions.fetch_closed import fetch_closed
 from room_cal.actions.fetch_closed import fetch_closed
 from room_cal.actions.push_closed import push_closed
 from room_cal.actions.push_closed import push_closed
@@ -31,7 +30,7 @@ async def set_closed(
   source: dict = _SOURCE,
   source: dict = _SOURCE,
   schedule: Callable | None = None,
   schedule: Callable | None = 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)
   await _push(push_closed(target_date, target_date), schedule)
 
 
 
 
@@ -41,7 +40,7 @@ async def delete_closed(
   source: dict = _SOURCE,
   source: dict = _SOURCE,
   schedule: Callable | None = None,
   schedule: Callable | None = 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)
   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 fastapi import APIRouter, BackgroundTasks, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
-from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal.pricing import CalPrice
 from room_cal.pricing import CalPrice
 from room_cal.discount import Discount, DiscountData, DiscountType
 from room_cal.discount import Discount, DiscountData, DiscountType
 from room_cal.actions.apply_discounts import apply_discounts
 from room_cal.actions.apply_discounts import apply_discounts
@@ -37,7 +36,7 @@ async def set_price(
   *,
   *,
   schedule: Callable | None = None,
   schedule: Callable | None = 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 apply_discounts(target_date, target_date)
   await _push(push_prices(target_date, target_date), schedule)
   await _push(push_prices(target_date, target_date), schedule)
 
 
@@ -50,7 +49,7 @@ async def set_discount(
   source: dict = _SOURCE,
   source: dict = _SOURCE,
   schedule: Callable | None = None,
   schedule: Callable | None = 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 apply_discounts(target_date, target_date)
   await _push(push_prices(target_date, target_date), schedule)
   await _push(push_prices(target_date, target_date), schedule)
 
 
@@ -60,7 +59,7 @@ async def delete_discount(
   *,
   *,
   schedule: Callable | None = None,
   schedule: Callable | None = 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 apply_discounts(target_date, target_date)
   await _push(push_prices(target_date, target_date), schedule)
   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
 @pytest.fixture
 def sqlite_backend(tmp_path):
 def sqlite_backend(tmp_path):
-  return SQLiteBackend(tmp_path / 'test.db', 'Room')
+  return SQLiteBackend(tmp_path / 'test.db')
 
 
 
 
 @pytest.fixture
 @pytest.fixture
 def house_backend(tmp_path):
 def house_backend(tmp_path):
-  return SQLiteBackend(tmp_path / 'test.db', 'House')
+  return SQLiteBackend(tmp_path / 'test.db')
 
 
 
 
 # ── SQLiteBackend direct ───────────────────────────────────────────────────────
 # ── SQLiteBackend direct ───────────────────────────────────────────────────────
@@ -103,25 +103,23 @@ async def test_backend_fetch_range_boundary(sqlite_backend):
 
 
 
 
 @pytest.mark.asyncio
 @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 room_cal.store._backend import EventRecord
   from datetime import datetime
   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(
   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 ─────────────────────────────────────────────────────────────────────
 # ── Closed ─────────────────────────────────────────────────────────────────────