paskia 0.8.1__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. paskia/_version.py +2 -2
  2. paskia/aaguid/__init__.py +5 -4
  3. paskia/authsession.py +15 -43
  4. paskia/bootstrap.py +31 -103
  5. paskia/db/__init__.py +27 -55
  6. paskia/db/background.py +20 -40
  7. paskia/db/jsonl.py +196 -46
  8. paskia/db/logging.py +233 -0
  9. paskia/db/migrations.py +33 -0
  10. paskia/db/operations.py +409 -825
  11. paskia/db/structs.py +408 -94
  12. paskia/fastapi/__main__.py +25 -28
  13. paskia/fastapi/admin.py +147 -329
  14. paskia/fastapi/api.py +68 -110
  15. paskia/fastapi/logging.py +218 -0
  16. paskia/fastapi/mainapp.py +25 -8
  17. paskia/fastapi/remote.py +16 -39
  18. paskia/fastapi/reset.py +27 -19
  19. paskia/fastapi/response.py +22 -0
  20. paskia/fastapi/session.py +2 -2
  21. paskia/fastapi/user.py +24 -30
  22. paskia/fastapi/ws.py +25 -60
  23. paskia/fastapi/wschat.py +62 -0
  24. paskia/fastapi/wsutil.py +15 -2
  25. paskia/frontend-build/auth/admin/index.html +5 -5
  26. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  27. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  28. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  29. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  30. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  31. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  32. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  33. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  34. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  35. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  36. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  37. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  38. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  39. paskia/frontend-build/auth/index.html +5 -5
  40. paskia/frontend-build/auth/restricted/index.html +4 -4
  41. paskia/frontend-build/int/forward/index.html +4 -4
  42. paskia/frontend-build/int/reset/index.html +3 -3
  43. paskia/globals.py +2 -2
  44. paskia/migrate/__init__.py +67 -60
  45. paskia/migrate/sql.py +94 -37
  46. paskia/remoteauth.py +7 -8
  47. paskia/sansio.py +6 -12
  48. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
  49. paskia-0.9.1.dist-info/RECORD +60 -0
  50. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  51. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  52. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  53. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  54. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  55. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  56. paskia-0.8.1.dist-info/RECORD +0 -55
  57. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
  58. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
paskia/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.8.1'
32
- __version_tuple__ = version_tuple = (0, 8, 1)
31
+ __version__ = version = '0.9.1'
32
+ __version_tuple__ = version_tuple = (0, 9, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
paskia/aaguid/__init__.py CHANGED
@@ -10,6 +10,7 @@ This module provides functionality to:
10
10
  import json
11
11
  from collections.abc import Iterable
12
12
  from importlib.resources import files
13
+ from uuid import UUID
13
14
 
14
15
  __ALL__ = ["AAGUID", "filter"]
15
16
 
@@ -18,15 +19,15 @@ AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
18
19
  AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
19
20
 
20
21
 
21
- def filter(aaguids: Iterable[str]) -> dict[str, dict]:
22
+ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
22
23
  """
23
24
  Get AAGUID information only for the provided set of AAGUIDs.
24
25
 
25
26
  Args:
26
- aaguids: Set of AAGUID strings that the user has credentials for
27
+ aaguids: Iterable of AAGUIDs (UUIDs) that the user has credentials for
27
28
 
28
29
  Returns:
29
- Dictionary mapping AAGUID to authenticator information for only
30
+ Dictionary mapping AAGUID string to authenticator information for only
30
31
  the AAGUIDs that the user has and that we have data for
31
32
  """
32
- return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}
33
+ return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
paskia/authsession.py CHANGED
@@ -8,68 +8,40 @@ independent of any web framework:
8
8
  - Credential management
9
9
  """
10
10
 
11
- from datetime import datetime, timezone
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
12
13
  from uuid import UUID
13
14
 
14
15
  from paskia import db
15
- from paskia.config import SESSION_LIFETIME
16
- from paskia.db import ResetToken, Session
16
+ from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
17
17
  from paskia.util import hostutil
18
18
 
19
+ if TYPE_CHECKING:
20
+ from paskia.db import ResetToken
21
+
19
22
  EXPIRES = SESSION_LIFETIME
20
23
 
21
24
 
22
25
  def expires() -> datetime:
23
- return datetime.now(timezone.utc) + EXPIRES
26
+ return datetime.now(UTC) + EXPIRES
24
27
 
25
28
 
26
29
  def reset_expires() -> datetime:
27
- from .config import RESET_LIFETIME
28
-
29
- return datetime.now(timezone.utc) + RESET_LIFETIME
30
+ return datetime.now(UTC) + RESET_LIFETIME
30
31
 
31
32
 
32
- async def get_reset(token: str) -> ResetToken:
33
+ def get_reset(token: str) -> "ResetToken":
33
34
  """Validate a credential reset token."""
35
+
34
36
  record = db.get_reset_token(token)
35
37
  if record:
36
38
  return record
37
39
  raise ValueError("This authentication link is no longer valid.")
38
40
 
39
41
 
40
- async def get_session(token: str, host: str | None = None) -> Session:
41
- """Validate a session token and return session data if valid."""
42
- host = hostutil.normalize_host(host)
43
- if not host:
44
- raise ValueError("Invalid host")
45
- session = db.get_session(token)
46
- if session:
47
- if session.host is None:
48
- # First time binding: store exact host:port (or IPv6 form) now.
49
- db.set_session_host(session.key, host)
50
- session.host = host
51
- elif session.host != host:
52
- raise ValueError("Session host mismatch")
53
- return session
54
- raise ValueError("Your session has expired. Please sign in again!")
55
-
56
-
57
- async def refresh_session_token(token: str, *, ip: str, user_agent: str):
58
- """Refresh a session extending its expiry."""
59
- session_record = db.get_session(token)
60
- if not session_record:
61
- raise ValueError("Session not found or expired")
62
- updated = db.update_session(
63
- token,
64
- ip=ip,
65
- user_agent=user_agent,
66
- expiry=expires(),
67
- )
68
- if not updated:
69
- raise ValueError("Session not found or expired")
70
-
71
-
72
- async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
42
+ def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
73
43
  """Delete a specific credential for the current user."""
74
- s = await get_session(auth, host=host)
75
- db.delete_credential(credential_uuid, s.user_uuid)
44
+ ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
45
+ if not ctx:
46
+ raise ValueError("Session expired")
47
+ db.delete_credential(credential_uuid, ctx.user.uuid)
paskia/bootstrap.py CHANGED
@@ -8,26 +8,11 @@ generating a reset link for initial admin setup.
8
8
 
9
9
  import asyncio
10
10
  import logging
11
- from datetime import datetime, timezone
12
11
 
13
- import uuid7
14
-
15
- from paskia import authsession, db
16
- from paskia.db import Org, Permission, Role, User
12
+ from paskia import authsession, db, globals
17
13
  from paskia.util import hostutil, passphrase
18
14
 
19
-
20
- def _init_logger() -> logging.Logger:
21
- logger = logging.getLogger(__name__)
22
- if not logger.handlers and not logging.getLogger().handlers:
23
- h = logging.StreamHandler()
24
- h.setFormatter(logging.Formatter("%(message)s"))
25
- logger.addHandler(h)
26
- logger.setLevel(logging.INFO)
27
- return logger
28
-
29
-
30
- logger = _init_logger()
15
+ logger = logging.getLogger(__name__)
31
16
 
32
17
  # Shared log message template for admin reset links
33
18
  ADMIN_RESET_MESSAGE = """\
@@ -38,84 +23,25 @@ ADMIN_RESET_MESSAGE = """\
38
23
  """
39
24
 
40
25
 
41
- async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
42
- """Create an admin reset link and log it with the provided message."""
43
- token = passphrase.generate()
44
- expiry = authsession.reset_expires()
45
- db.create_reset_token(
46
- user_uuid=user_uuid,
47
- passphrase=token,
48
- expiry=expiry,
49
- token_type=session_type,
50
- )
51
- reset_link = hostutil.reset_link_url(token)
26
+ def _log_reset_link(message: str, passphrase: str) -> str:
27
+ """Log a reset link message and return the URL."""
28
+ reset_link = hostutil.reset_link_url(passphrase)
52
29
  logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
53
30
  return reset_link
54
31
 
55
32
 
56
- async def bootstrap_system() -> dict:
33
+ async def bootstrap_system() -> None:
57
34
  """
58
35
  Bootstrap the entire system with default data.
59
36
 
60
- Returns:
61
- dict: Contains information about created entities and reset link
37
+ Uses db.bootstrap() which performs all operations in a single transaction.
38
+ The transaction log will show a single "bootstrap" action with all changes.
62
39
  """
63
- # Create permission first - will fail if already exists
64
- perm0 = Permission(
65
- uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin"
66
- )
67
- db.create_permission(perm0)
68
-
69
- # Create org admin permission - allows managing users within an org
70
- perm_org_admin = Permission(
71
- uuid=uuid7.create(), scope="auth:org:admin", display_name="Org Admin"
72
- )
73
- db.create_permission(perm_org_admin)
74
-
75
- org = Org(uuid7.create(), "Organization")
76
- db.create_organization(org)
77
-
78
- # Allow this org to grant global admin and org admin permissions
79
- db.add_permission_to_organization(str(org.uuid), perm0.scope)
80
- db.add_permission_to_organization(str(org.uuid), perm_org_admin.scope)
81
-
82
- # Create an Administration role granting both org and global admin
83
- role = Role(
84
- uuid7.create(),
85
- org.uuid,
86
- "Administration",
87
- permissions=[perm0.scope, perm_org_admin.scope],
88
- )
89
- db.create_role(role)
90
-
91
- user = User(
92
- uuid=uuid7.create(),
93
- display_name="Admin",
94
- role_uuid=role.uuid,
95
- created_at=datetime.now(timezone.utc),
96
- visits=0,
97
- )
98
- db.create_user(user)
99
-
100
- # Generate reset link and log it
101
- reset_link = await _create_and_log_admin_reset_link(
102
- user.uuid, "✅ Bootstrap completed!", "admin bootstrap"
103
- )
104
-
105
- return {
106
- "user": user,
107
- "org": org,
108
- "role": role,
109
- "permissions": [
110
- perm0,
111
- *[
112
- db.get_permission_by_scope(p)
113
- for p in org.permissions
114
- if db.get_permission_by_scope(p)
115
- ],
116
- ],
117
- "reset_link": reset_link,
118
- }
40
+ # Call the single-transaction bootstrap function
41
+ reset_passphrase = db.bootstrap()
42
+
43
+ # Log the reset link (this is separate from the transaction log)
44
+ _log_reset_link("✅ Bootstrap completed!", reset_passphrase)
119
45
 
120
46
 
121
47
  async def check_admin_credentials() -> bool:
@@ -127,13 +53,15 @@ async def check_admin_credentials() -> bool:
127
53
  """
128
54
  try:
129
55
  # Get permission organizations to find admin users
130
- permission_orgs = db.get_permission_organizations("auth:admin")
131
-
132
- if not permission_orgs:
56
+ p = next(
57
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
58
+ )
59
+ if not p or not p.orgs:
133
60
  return False
134
61
 
135
62
  # Get users from the first organization with admin permission
136
- org_users = db.get_organization_users(str(permission_orgs[0].uuid))
63
+ first_org_uuid = next(iter(p.orgs))
64
+ org_users = db.get_organization_users(first_org_uuid)
137
65
  admin_users = [user for user, role in org_users if role == "Administration"]
138
66
 
139
67
  if not admin_users:
@@ -141,15 +69,19 @@ async def check_admin_credentials() -> bool:
141
69
 
142
70
  # Check first admin user for credentials
143
71
  admin_user = admin_users[0]
144
- credentials = db.get_credentials_by_user_uuid(admin_user.uuid)
145
72
 
146
- if not credentials:
73
+ if not db.get_user_credential_ids(admin_user.uuid):
147
74
  # Admin exists but has no credentials, create reset link
148
- await _create_and_log_admin_reset_link(
149
- admin_user.uuid,
150
- "⚠️ Admin user has no credentials!",
151
- "admin registration",
75
+
76
+ token = passphrase.generate()
77
+ expiry = authsession.reset_expires()
78
+ db.create_reset_token(
79
+ user_uuid=admin_user.uuid,
80
+ passphrase=token,
81
+ expiry=expiry,
82
+ token_type="admin registration",
152
83
  )
84
+ _log_reset_link("⚠️ Admin user has no credentials!", token)
153
85
  return True
154
86
 
155
87
  return False
@@ -165,16 +97,12 @@ async def bootstrap_if_needed() -> bool:
165
97
  Returns:
166
98
  bool: True if bootstrapping was performed, False if system was already set up
167
99
  """
168
- try:
169
- # Check if the admin permission exists - if it does, system is already bootstrapped
170
- db.get_permission("auth:admin")
100
+ # Check if the admin permission exists - if it does, system is already bootstrapped
101
+ if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
171
102
  # Permission exists, system is already bootstrapped
172
103
  # Check if admin needs credentials (only for already-bootstrapped systems)
173
104
  await check_admin_credentials()
174
105
  return False
175
- except Exception:
176
- # Permission doesn't exist, need to bootstrap
177
- pass
178
106
 
179
107
  # No admin permission found, need to bootstrap
180
108
  # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
paskia/db/__init__.py CHANGED
@@ -1,24 +1,25 @@
1
1
  """
2
2
  Database module for WebAuthn passkey authentication.
3
3
 
4
- Read: Access _db._data directly, use build_* to convert to public structs.
5
- CTX: get_session_context(key) returns SessionContext with effective permissions.
4
+ Read: Access data() directly, use build_* to convert to public structs.
5
+ CTX: data().session_ctx(key) returns SessionContext with effective permissions.
6
6
  Write: Functions validate and commit, or raise ValueError.
7
7
 
8
8
  Usage:
9
9
  from paskia import db
10
10
 
11
11
  # Read (after init)
12
- user_data = db._db._data.users[user_uuid]
12
+ user_data = db.data().users[user_uuid]
13
13
  user = db.build_user(user_uuid)
14
14
 
15
15
  # Context
16
- ctx = db.get_session_context(session_key)
16
+ ctx = db.data().session_ctx(session_key)
17
17
 
18
18
  # Write
19
19
  db.create_user(user)
20
20
  """
21
21
 
22
+ import paskia.db.operations as operations
22
23
  from paskia.db.background import (
23
24
  start_background,
24
25
  start_cleanup,
@@ -26,59 +27,37 @@ from paskia.db.background import (
26
27
  stop_cleanup,
27
28
  )
28
29
  from paskia.db.operations import (
29
- DB,
30
- _db,
31
- add_permission_to_organization,
30
+ add_permission_to_org,
32
31
  add_permission_to_role,
33
- build_credential,
34
- build_org,
35
- build_permission,
36
- build_reset_token,
37
- build_role,
38
- build_session,
39
- build_user,
32
+ bootstrap,
40
33
  cleanup_expired,
41
34
  create_credential,
42
35
  create_credential_session,
43
- create_organization,
36
+ create_org,
44
37
  create_permission,
45
38
  create_reset_token,
46
39
  create_role,
47
40
  create_session,
48
41
  create_user,
49
42
  delete_credential,
50
- delete_organization,
43
+ delete_org,
51
44
  delete_permission,
52
45
  delete_reset_token,
53
46
  delete_role,
54
47
  delete_session,
55
48
  delete_sessions_for_user,
56
49
  delete_user,
57
- get_credential_by_id,
58
- get_credentials_by_user_uuid,
59
- get_organization,
60
50
  get_organization_users,
61
- get_permission,
62
- get_permission_by_scope,
63
- get_permission_organizations,
64
51
  get_reset_token,
65
- get_role,
66
- get_roles_by_organization,
67
- get_session,
68
- get_session_context,
69
- get_user_by_uuid,
52
+ get_user_credential_ids,
70
53
  get_user_organization,
71
54
  init,
72
- list_organizations,
73
- list_permissions,
74
- list_sessions_for_user,
75
55
  login,
76
- remove_permission_from_organization,
56
+ remove_permission_from_org,
77
57
  remove_permission_from_role,
78
- rename_permission,
79
58
  set_session_host,
80
59
  update_credential_sign_count,
81
- update_organization_name,
60
+ update_org_name,
82
61
  update_permission,
83
62
  update_role_name,
84
63
  update_session,
@@ -87,6 +66,7 @@ from paskia.db.operations import (
87
66
  update_user_role_in_organization,
88
67
  )
89
68
  from paskia.db.structs import (
69
+ DB,
90
70
  Credential,
91
71
  Org,
92
72
  Permission,
@@ -97,6 +77,12 @@ from paskia.db.structs import (
97
77
  User,
98
78
  )
99
79
 
80
+
81
+ def data() -> DB:
82
+ """Get the database instance for direct read access."""
83
+ return operations._db
84
+
85
+
100
86
  __all__ = [
101
87
  # Types
102
88
  "Credential",
@@ -109,7 +95,7 @@ __all__ = [
109
95
  "SessionContext",
110
96
  "User",
111
97
  # Instance
112
- "_db",
98
+ "data",
113
99
  "init",
114
100
  # Background
115
101
  "start_background",
@@ -118,44 +104,31 @@ __all__ = [
118
104
  "stop_cleanup",
119
105
  # Builders
120
106
  "build_credential",
121
- "build_org",
122
107
  "build_permission",
123
108
  "build_reset_token",
124
109
  "build_role",
125
110
  "build_session",
126
111
  "build_user",
127
112
  # Read ops
128
- "get_credential_by_id",
129
- "get_credentials_by_user_uuid",
130
- "get_organization",
131
113
  "get_organization_users",
132
- "get_permission",
133
- "get_permission_by_scope",
134
- "get_permission_organizations",
135
114
  "get_reset_token",
136
- "get_role",
137
- "get_roles_by_organization",
138
- "get_session",
139
- "get_session_context",
140
- "get_user_by_uuid",
115
+ "get_user_credential_ids",
141
116
  "get_user_organization",
142
- "list_organizations",
143
- "list_permissions",
144
- "list_sessions_for_user",
145
117
  # Write ops
146
- "add_permission_to_organization",
118
+ "add_permission_to_org",
147
119
  "add_permission_to_role",
120
+ "bootstrap",
148
121
  "cleanup_expired",
149
122
  "create_credential",
150
123
  "create_credential_session",
151
- "create_organization",
124
+ "create_org",
152
125
  "create_permission",
153
126
  "create_reset_token",
154
127
  "create_role",
155
128
  "create_session",
156
129
  "create_user",
157
130
  "delete_credential",
158
- "delete_organization",
131
+ "delete_org",
159
132
  "delete_permission",
160
133
  "delete_reset_token",
161
134
  "delete_role",
@@ -163,12 +136,11 @@ __all__ = [
163
136
  "delete_sessions_for_user",
164
137
  "delete_user",
165
138
  "login",
166
- "remove_permission_from_organization",
139
+ "remove_permission_from_org",
167
140
  "remove_permission_from_role",
168
- "rename_permission",
169
141
  "set_session_host",
170
142
  "update_credential_sign_count",
171
- "update_organization_name",
143
+ "update_org_name",
172
144
  "update_permission",
173
145
  "update_role_name",
174
146
  "update_session",
paskia/db/background.py CHANGED
@@ -6,62 +6,34 @@ Periodically flushes pending changes to disk and cleans up expired items.
6
6
 
7
7
  import asyncio
8
8
  import logging
9
- from datetime import datetime, timezone
9
+ from datetime import UTC, datetime
10
10
 
11
- from paskia.db.jsonl import flush_changes
11
+ from paskia.db.operations import _store, cleanup_expired
12
12
 
13
- # Flush changes to disk every N seconds
14
- FLUSH_INTERVAL = 1
15
- # Cleanup expired items every N seconds (cheap when nothing to remove)
16
- CLEANUP_INTERVAL = 1
13
+ FLUSH_INTERVAL = 0.1 # Flush to disk
14
+ CLEANUP_INTERVAL = 1 # Expired item cleanup
17
15
 
18
16
 
19
17
  _logger = logging.getLogger(__name__)
20
18
  _background_task: asyncio.Task | None = None
21
19
 
22
20
 
23
- def cleanup() -> None:
24
- """Remove expired sessions and reset tokens from the database."""
25
- from paskia.db.operations import _db
26
-
27
- if _db is None or _db._data is None:
28
- return
29
-
30
- with _db.transaction("expiry"):
31
- current_time = datetime.now(timezone.utc)
32
-
33
- # Clean expired sessions
34
- to_delete_sessions = [
35
- k for k, s in _db._data.sessions.items() if s.expiry < current_time
36
- ]
37
- for k in to_delete_sessions:
38
- del _db._data.sessions[k]
39
-
40
- # Clean expired reset tokens
41
- to_delete_tokens = [
42
- k for k, t in _db._data.reset_tokens.items() if t.expiry < current_time
43
- ]
44
- for k in to_delete_tokens:
45
- del _db._data.reset_tokens[k]
46
-
47
-
48
21
  async def flush() -> None:
49
22
  """Write all pending database changes to disk."""
50
- from paskia.db.operations import _db
51
23
 
52
- if _db is None:
53
- _logger.warning("flush() called but _db is None")
24
+ if _store is None:
25
+ _logger.warning("flush() called but _store is None")
54
26
  return
55
- await flush_changes(_db.db_path, _db._pending_changes)
27
+ await _store.flush()
56
28
 
57
29
 
58
30
  async def _background_loop():
59
31
  """Background task that periodically flushes changes and cleans up."""
60
32
  # Run cleanup immediately on startup to clear old expired items
61
- cleanup()
33
+ cleanup_expired()
62
34
  await flush()
63
35
 
64
- last_cleanup = datetime.now(timezone.utc)
36
+ last_cleanup = datetime.now(UTC)
65
37
 
66
38
  while True:
67
39
  try:
@@ -69,10 +41,10 @@ async def _background_loop():
69
41
  # Flush pending changes to disk
70
42
  await flush()
71
43
 
72
- # Run cleanup less frequently
73
- now = datetime.now(timezone.utc)
44
+ # Run cleanup periodically
45
+ now = datetime.now(UTC)
74
46
  if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
75
- cleanup()
47
+ cleanup_expired()
76
48
  await flush() # Flush cleanup changes
77
49
  last_cleanup = now
78
50
  except asyncio.CancelledError:
@@ -101,6 +73,14 @@ async def start_background():
101
73
  if loop is not task_loop:
102
74
  _logger.debug("Background task in different event loop, restarting")
103
75
  _background_task = None
76
+ else:
77
+ # Task is running in the same event loop - this is an error
78
+ raise RuntimeError(
79
+ "Background task is already running. "
80
+ "start_background() must not be called multiple times in the same event loop."
81
+ )
82
+ except RuntimeError:
83
+ raise # Re-raise RuntimeError from above
104
84
  except Exception as e:
105
85
  _logger.debug("Error checking background task loop: %s, restarting", e)
106
86
  _background_task = None