postkit 0.3.0__tar.gz → 0.5.0__tar.gz

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 (148) hide show
  1. {postkit-0.3.0 → postkit-0.5.0}/PKG-INFO +9 -1
  2. postkit-0.5.0/README.md +47 -0
  3. {postkit-0.3.0 → postkit-0.5.0}/pyproject.toml +1 -1
  4. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/base.py +23 -0
  5. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/errors.py +44 -0
  6. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/outbox/client.py +13 -0
  7. postkit-0.5.0/src/postkit/presence/__init__.py +15 -0
  8. postkit-0.5.0/src/postkit/presence/client.py +274 -0
  9. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/queue/client.py +20 -4
  10. postkit-0.5.0/tests/authn/test_rls_active.py +26 -0
  11. postkit-0.5.0/tests/authz/test_rls_active.py +26 -0
  12. postkit-0.5.0/tests/config/test_rls_active.py +26 -0
  13. postkit-0.5.0/tests/integration/test_presence_hooks.py +189 -0
  14. postkit-0.5.0/tests/lease/test_rls_active.py +26 -0
  15. postkit-0.5.0/tests/meter/test_rls_active.py +55 -0
  16. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_horizon.py +38 -0
  17. postkit-0.5.0/tests/outbox/test_rls_active.py +63 -0
  18. postkit-0.5.0/tests/presence/__init__.py +0 -0
  19. postkit-0.5.0/tests/presence/conftest.py +81 -0
  20. postkit-0.5.0/tests/presence/helpers.py +88 -0
  21. postkit-0.5.0/tests/presence/test_flap.py +146 -0
  22. postkit-0.5.0/tests/presence/test_heartbeat.py +102 -0
  23. postkit-0.5.0/tests/presence/test_invariants.py +189 -0
  24. postkit-0.5.0/tests/presence/test_maintenance.py +98 -0
  25. postkit-0.5.0/tests/presence/test_rls_active.py +57 -0
  26. postkit-0.5.0/tests/presence/test_state_machine.py +177 -0
  27. postkit-0.5.0/tests/presence/test_tenancy.py +108 -0
  28. postkit-0.5.0/tests/presence/test_validation.py +100 -0
  29. postkit-0.5.0/tests/presence/test_wall_clock.py +56 -0
  30. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_ack.py +26 -9
  31. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_nack_ack_race.py +7 -4
  32. postkit-0.5.0/tests/queue/test_rls_active.py +57 -0
  33. postkit-0.5.0/tests/queue/test_rollback_recovery.py +162 -0
  34. {postkit-0.3.0 → postkit-0.5.0}/tests/test_error_codes.py +2 -0
  35. {postkit-0.3.0 → postkit-0.5.0}/uv.lock +1 -1
  36. postkit-0.3.0/README.md +0 -39
  37. {postkit-0.3.0 → postkit-0.5.0}/.gitignore +0 -0
  38. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/__init__.py +0 -0
  39. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authn/__init__.py +0 -0
  40. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authn/client.py +0 -0
  41. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authz/__init__.py +0 -0
  42. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/authz/client.py +0 -0
  43. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/config/__init__.py +0 -0
  44. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/config/client.py +0 -0
  45. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/lease/__init__.py +0 -0
  46. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/lease/client.py +0 -0
  47. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/meter/__init__.py +0 -0
  48. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/meter/client.py +0 -0
  49. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/outbox/__init__.py +0 -0
  50. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/py.typed +0 -0
  51. {postkit-0.3.0 → postkit-0.5.0}/src/postkit/queue/__init__.py +0 -0
  52. {postkit-0.3.0 → postkit-0.5.0}/tests/__init__.py +0 -0
  53. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/__init__.py +0 -0
  54. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/conftest.py +0 -0
  55. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/helpers.py +0 -0
  56. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_audit.py +0 -0
  57. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_base.py +0 -0
  58. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_credentials.py +0 -0
  59. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_disabled_user.py +0 -0
  60. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_e2e_demo.py +0 -0
  61. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_impersonation.py +0 -0
  62. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_lockout.py +0 -0
  63. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_maintenance.py +0 -0
  64. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_operator_impersonation.py +0 -0
  65. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_refresh_tokens.py +0 -0
  66. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_rls.py +0 -0
  67. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_sessions.py +0 -0
  68. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_tokens.py +0 -0
  69. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_users.py +0 -0
  70. {postkit-0.3.0 → postkit-0.5.0}/tests/authn/test_validation.py +0 -0
  71. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/__init__.py +0 -0
  72. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/conftest.py +0 -0
  73. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/helpers.py +0 -0
  74. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_access_patterns.py +0 -0
  75. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_audit.py +0 -0
  76. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_concurrency.py +0 -0
  77. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_consistency.py +0 -0
  78. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_e2e_demo.py +0 -0
  79. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_expiration.py +0 -0
  80. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_groups.py +0 -0
  81. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_hierarchy.py +0 -0
  82. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_listing.py +0 -0
  83. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_maintenance.py +0 -0
  84. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_namespaces.py +0 -0
  85. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_nested_teams.py +0 -0
  86. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_resource_hierarchy.py +0 -0
  87. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_rls.py +0 -0
  88. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_sdk.py +0 -0
  89. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_stress.py +0 -0
  90. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_transactions.py +0 -0
  91. {postkit-0.3.0 → postkit-0.5.0}/tests/authz/test_validation.py +0 -0
  92. {postkit-0.3.0 → postkit-0.5.0}/tests/config/__init__.py +0 -0
  93. {postkit-0.3.0 → postkit-0.5.0}/tests/config/conftest.py +0 -0
  94. {postkit-0.3.0 → postkit-0.5.0}/tests/config/helpers.py +0 -0
  95. {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_audit.py +0 -0
  96. {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_entries.py +0 -0
  97. {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_rls.py +0 -0
  98. {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_schemas.py +0 -0
  99. {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_tenancy.py +0 -0
  100. {postkit-0.3.0 → postkit-0.5.0}/tests/config/test_validation.py +0 -0
  101. {postkit-0.3.0 → postkit-0.5.0}/tests/conftest.py +0 -0
  102. {postkit-0.3.0 → postkit-0.5.0}/tests/helpers.py +0 -0
  103. {postkit-0.3.0 → postkit-0.5.0}/tests/integration/conftest.py +0 -0
  104. {postkit-0.3.0 → postkit-0.5.0}/tests/integration/test_api_keys.py +0 -0
  105. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/__init__.py +0 -0
  106. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/conftest.py +0 -0
  107. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/helpers.py +0 -0
  108. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_acquire.py +0 -0
  109. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_events.py +0 -0
  110. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_invariants.py +0 -0
  111. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_maintenance.py +0 -0
  112. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_renew_release.py +0 -0
  113. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_tenancy.py +0 -0
  114. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_validation.py +0 -0
  115. {postkit-0.3.0 → postkit-0.5.0}/tests/lease/test_verify_fencing.py +0 -0
  116. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/conftest.py +0 -0
  117. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/helpers.py +0 -0
  118. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_periods.py +0 -0
  119. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_reservation_expiry_race.py +0 -0
  120. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_rls.py +0 -0
  121. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_sdk.py +0 -0
  122. {postkit-0.3.0 → postkit-0.5.0}/tests/meter/test_validation.py +0 -0
  123. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/__init__.py +0 -0
  124. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/conftest.py +0 -0
  125. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/helpers.py +0 -0
  126. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_emit.py +0 -0
  127. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_lost_cursor.py +0 -0
  128. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_maintenance.py +0 -0
  129. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_subscribe_poll_ack.py +0 -0
  130. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_tenancy.py +0 -0
  131. {postkit-0.3.0 → postkit-0.5.0}/tests/outbox/test_validation.py +0 -0
  132. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/__init__.py +0 -0
  133. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/conftest.py +0 -0
  134. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/helpers.py +0 -0
  135. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_cron.py +0 -0
  136. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_dead_letters.py +0 -0
  137. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_e2e_demo.py +0 -0
  138. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_pull.py +0 -0
  139. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_purge.py +0 -0
  140. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_push.py +0 -0
  141. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_release_jobs.py +0 -0
  142. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_rls.py +0 -0
  143. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_schedules.py +0 -0
  144. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_stats.py +0 -0
  145. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_tenancy.py +0 -0
  146. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_tick.py +0 -0
  147. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_tick_timeouts.py +0 -0
  148. {postkit-0.3.0 → postkit-0.5.0}/tests/queue/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postkit
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: PostgreSQL-native auth, permissions, versioned config, usage tracking, and job queues
5
5
  Project-URL: Homepage, https://github.com/varunchopra/postkit
6
6
  Project-URL: Repository, https://github.com/varunchopra/postkit
@@ -65,6 +65,14 @@ user_id = authn.create_user("alice@example.com", password_hash="argon2...")
65
65
  session_id = authn.create_session(user_id, token_hash="sha256...")
66
66
  ```
67
67
 
68
+ ## Tenant Context and Transactions
69
+
70
+ Constructing a client calls `{module}.set_tenant(namespace)` immediately. The setting is transaction-scoped, so inside an open transaction the constructor taints that transaction's context for the module until commit or rollback; an unrelated client built mid-transaction can therefore change which rows a later raw SQL statement sees.
71
+
72
+ Every SDK call needs a transaction for that context. On autocommit connections each call runs in its own transaction and commits immediately. On non-autocommit connections each call joins the connection's open transaction: nothing is durable until you commit, and a rollback takes it all with it, including a queue claim from `pull()`. That is the intended model for transactional consumers; `nack`/`fail` accept the pending job a rollback leaves behind.
73
+
74
+ In CI, call `client.assert_rls_active()` during setup. A suite connecting as a superuser or `BYPASSRLS` role (the docker default) bypasses every RLS policy and exercises none of the tenancy model.
75
+
68
76
  ## Requirements
69
77
 
70
78
  - PostgreSQL 14+
@@ -0,0 +1,47 @@
1
+ # postkit SDK
2
+
3
+ Python client for postkit.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install postkit
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import psycopg
15
+ from postkit.authz import AuthzClient
16
+ from postkit.authn import AuthnClient
17
+
18
+ conn = psycopg.connect("postgresql://...")
19
+ cursor = conn.cursor()
20
+
21
+ # Authorization
22
+ authz = AuthzClient(cursor, namespace="my-app")
23
+ authz.set_hierarchy("repo", "admin", "write", "read")
24
+ authz.grant("admin", resource=("repo", "api"), subject=("user", "alice"))
25
+ if authz.check(("user", "alice"), "read", ("repo", "api")):
26
+ print("Access granted")
27
+
28
+ # Authentication
29
+ authn = AuthnClient(cursor, namespace="my-app")
30
+ user_id = authn.create_user("alice@example.com", password_hash="argon2...")
31
+ session_id = authn.create_session(user_id, token_hash="sha256...")
32
+ ```
33
+
34
+ ## Tenant Context and Transactions
35
+
36
+ Constructing a client calls `{module}.set_tenant(namespace)` immediately. The setting is transaction-scoped, so inside an open transaction the constructor taints that transaction's context for the module until commit or rollback; an unrelated client built mid-transaction can therefore change which rows a later raw SQL statement sees.
37
+
38
+ Every SDK call needs a transaction for that context. On autocommit connections each call runs in its own transaction and commits immediately. On non-autocommit connections each call joins the connection's open transaction: nothing is durable until you commit, and a rollback takes it all with it, including a queue claim from `pull()`. That is the intended model for transactional consumers; `nack`/`fail` accept the pending job a rollback leaves behind.
39
+
40
+ In CI, call `client.assert_rls_active()` during setup. A suite connecting as a superuser or `BYPASSRLS` role (the docker default) bypasses every RLS policy and exercises none of the tenancy model.
41
+
42
+ ## Requirements
43
+
44
+ - PostgreSQL 14+
45
+ - The postkit SQL schema installed in your database
46
+
47
+ See the [main repository](https://github.com/varunchopra/postkit) for SQL installation instructions.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "postkit"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "PostgreSQL-native auth, permissions, versioned config, usage tracking, and job queues"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -399,6 +399,29 @@ class BaseClient(ABC):
399
399
  self._on_behalf_of = None
400
400
  self._reason = None
401
401
 
402
+ def assert_rls_active(self) -> None:
403
+ """Raise unless row-level security applies to the connection's role.
404
+
405
+ Call from CI setup: a suite connecting as a superuser or BYPASSRLS
406
+ role bypasses every policy and exercises none of the tenancy model.
407
+
408
+ Raises:
409
+ The module's error class with error_code BIZ_RLS_NOT_ACTIVE
410
+ when the current role bypasses RLS.
411
+ """
412
+
413
+ def execute() -> None:
414
+ try:
415
+ self.cursor.execute(
416
+ sql.SQL("SELECT {}.assert_rls_active()").format(
417
+ sql.Identifier(self._schema)
418
+ )
419
+ )
420
+ except psycopg.Error as e:
421
+ self._handle_error(e)
422
+
423
+ self._with_context(execute)
424
+
402
425
  @staticmethod
403
426
  def _encode_cursor(event_time: datetime, event_id: int) -> str:
404
427
  """Encode pagination cursor as opaque string.
@@ -64,6 +64,7 @@ class AuthnErrorCode:
64
64
  # Business logic errors (BIZ_)
65
65
  BIZ_IMPERSONATE_SELF = "BIZ_IMPERSONATE_SELF"
66
66
  BIZ_IMPERSONATE_CHAIN = "BIZ_IMPERSONATE_CHAIN"
67
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
67
68
 
68
69
  # Credential state errors (CRED_)
69
70
  CRED_NO_MATERIAL = "CRED_NO_MATERIAL"
@@ -109,6 +110,7 @@ class AuthzErrorCode:
109
110
  BIZ_BULK_GROUP_MEMBERSHIP = "BIZ_BULK_GROUP_MEMBERSHIP"
110
111
  BIZ_BULK_PARENT_RELATION = "BIZ_BULK_PARENT_RELATION"
111
112
  BIZ_GRANT_NO_EXPIRATION = "BIZ_GRANT_NO_EXPIRATION"
113
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
112
114
 
113
115
  # Data state errors (DATA_)
114
116
  DATA_GRANT_NOT_FOUND = "DATA_GRANT_NOT_FOUND"
@@ -140,6 +142,7 @@ class ConfigErrorCode:
140
142
 
141
143
  # Business logic errors (BIZ_)
142
144
  BIZ_DELETE_ACTIVE_VERSION = "BIZ_DELETE_ACTIVE_VERSION"
145
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
143
146
 
144
147
 
145
148
  class LeaseErrorCode:
@@ -173,6 +176,9 @@ class LeaseErrorCode:
173
176
  # Internal errors (INT_)
174
177
  INT_COUNTER_MISSING = "INT_COUNTER_MISSING"
175
178
 
179
+ # Business logic errors (BIZ_)
180
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
181
+
176
182
 
177
183
  class MeterErrorCode:
178
184
  """Error codes for the meter module."""
@@ -200,6 +206,8 @@ class MeterErrorCode:
200
206
 
201
207
  # Business logic errors (BIZ_)
202
208
  BIZ_LEDGER_IMMUTABLE = "BIZ_LEDGER_IMMUTABLE"
209
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
210
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
203
211
 
204
212
  # Data state errors (DATA_)
205
213
  DATA_ACCOUNT_NOT_FOUND = "DATA_ACCOUNT_NOT_FOUND"
@@ -238,6 +246,39 @@ class OutboxErrorCode:
238
246
  BIZ_CONSUMER_UNKNOWN = "BIZ_CONSUMER_UNKNOWN"
239
247
  BIZ_CURSOR_LOST = "BIZ_CURSOR_LOST"
240
248
  BIZ_POSITION_BEYOND_HEAD = "BIZ_POSITION_BEYOND_HEAD"
249
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
250
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
251
+
252
+
253
+ class PresenceErrorCode:
254
+ """Error codes for the presence module."""
255
+
256
+ # Validation errors (VAL_)
257
+ VAL_NAMESPACE_NULL = "VAL_NAMESPACE_NULL"
258
+ VAL_NAMESPACE_EMPTY = "VAL_NAMESPACE_EMPTY"
259
+ VAL_NAMESPACE_TOO_LONG = "VAL_NAMESPACE_TOO_LONG"
260
+ VAL_NAMESPACE_INVALID_CHARS = "VAL_NAMESPACE_INVALID_CHARS"
261
+ VAL_NAMESPACE_WHITESPACE = "VAL_NAMESPACE_WHITESPACE"
262
+ VAL_ENTITY_NULL = "VAL_ENTITY_NULL"
263
+ VAL_ENTITY_EMPTY = "VAL_ENTITY_EMPTY"
264
+ VAL_ENTITY_TOO_LONG = "VAL_ENTITY_TOO_LONG"
265
+ VAL_ENTITY_INVALID_CHARS = "VAL_ENTITY_INVALID_CHARS"
266
+ VAL_ENTITY_WHITESPACE = "VAL_ENTITY_WHITESPACE"
267
+ VAL_ENTITIES_NULL = "VAL_ENTITIES_NULL"
268
+ VAL_KIND_NULL = "VAL_KIND_NULL"
269
+ VAL_KIND_EMPTY = "VAL_KIND_EMPTY"
270
+ VAL_KIND_TOO_LONG = "VAL_KIND_TOO_LONG"
271
+ VAL_KIND_FORMAT = "VAL_KIND_FORMAT"
272
+ VAL_TIMEOUT_NOT_POSITIVE = "VAL_TIMEOUT_NOT_POSITIVE"
273
+ VAL_STATUS_INVALID = "VAL_STATUS_INVALID"
274
+ VAL_TRIM_INTERVAL_NOT_POSITIVE = "VAL_TRIM_INTERVAL_NOT_POSITIVE"
275
+ VAL_NOT_POSITIVE = "VAL_NOT_POSITIVE"
276
+
277
+ # Business logic errors
278
+ ENTITY_UNKNOWN = "ENTITY_UNKNOWN"
279
+ HOOK_QUEUE_MISSING = "HOOK_QUEUE_MISSING"
280
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
281
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
241
282
 
242
283
 
243
284
  class QueueErrorCode:
@@ -270,10 +311,13 @@ class QueueErrorCode:
270
311
 
271
312
  # Business logic errors (BIZ_)
272
313
  BIZ_JOB_NOT_RUNNING = "BIZ_JOB_NOT_RUNNING"
314
+ BIZ_JOB_NOT_YOURS = "BIZ_JOB_NOT_YOURS"
273
315
  BIZ_SCHEDULE_DUPLICATE = "BIZ_SCHEDULE_DUPLICATE"
274
316
  BIZ_SCHEDULE_REQUIRES_SCHEDULE = "BIZ_SCHEDULE_REQUIRES_SCHEDULE"
275
317
  BIZ_SCHEDULE_CRON_AND_INTERVAL = "BIZ_SCHEDULE_CRON_AND_INTERVAL"
276
318
  BIZ_DEAD_LETTER_ALREADY_RETRIED = "BIZ_DEAD_LETTER_ALREADY_RETRIED"
319
+ BIZ_RLS_NOT_ACTIVE = "BIZ_RLS_NOT_ACTIVE"
320
+ BIZ_ALL_NAMESPACES_REQUIRES_BYPASS = "BIZ_ALL_NAMESPACES_REQUIRES_BYPASS"
277
321
 
278
322
  # Data state errors (DATA_)
279
323
  DATA_JOB_NOT_FOUND = "DATA_JOB_NOT_FOUND"
@@ -341,3 +341,16 @@ class OutboxClient(BaseClient):
341
341
  return self._fetch_all(
342
342
  "SELECT * FROM outbox.list_consumers(%s, %s)", (self.namespace, topic)
343
343
  )
344
+
345
+ def horizon_blockers(self) -> list[dict[str, Any]]:
346
+ """Backends whose open write transactions pin the visibility horizon.
347
+
348
+ Database-global, like the horizon itself. Seeing other sessions
349
+ requires pg_read_all_stats (or superuser).
350
+
351
+ Returns:
352
+ One dict per in-progress write transaction, oldest first:
353
+ pid, datname, xact_age, state, application_name, query,
354
+ is_horizon
355
+ """
356
+ return self._fetch_all("SELECT * FROM outbox.horizon_blockers()", ())
@@ -0,0 +1,15 @@
1
+ """Postkit Presence SDK - heartbeat liveness with transition edge detection."""
2
+
3
+ from postkit.errors import PresenceErrorCode
4
+ from postkit.presence.client import (
5
+ PresenceClient,
6
+ PresenceError,
7
+ PresenceValidationError,
8
+ )
9
+
10
+ __all__ = [
11
+ "PresenceClient",
12
+ "PresenceError",
13
+ "PresenceErrorCode",
14
+ "PresenceValidationError",
15
+ ]
@@ -0,0 +1,274 @@
1
+ """Postkit Presence SDK - heartbeat liveness with transition edge detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import timedelta
7
+ from typing import Any
8
+
9
+ from postkit.base import BaseClient, PostkitError
10
+
11
+
12
+ class PresenceError(PostkitError):
13
+ """Exception for presence operations."""
14
+
15
+
16
+ class PresenceValidationError(PresenceError):
17
+ """Raised when input validation fails."""
18
+
19
+
20
+ class PresenceClient(BaseClient):
21
+ """Client for Postkit presence module.
22
+
23
+ Heartbeat liveness for a fleet of entities. Register once, heartbeat
24
+ periodically, and run sweep() on a timer; the module emits an
25
+ append-only transition stream (alive -> dead -> alive) and can enqueue
26
+ alert jobs atomically with each edge when the queue module is
27
+ installed.
28
+
29
+ Example:
30
+ presence = PresenceClient(cursor, namespace="acme")
31
+
32
+ presence.register("worker-7") # once, idempotent
33
+ presence.heartbeat("worker-7") # every 10-60s
34
+
35
+ # From a cron, anywhere:
36
+ for death in presence.sweep():
37
+ page(death["entity_id"])
38
+ """
39
+
40
+ _schema = "presence"
41
+ _error_class = PresenceError
42
+ _module_sqlstate_map = {
43
+ "22023": PresenceValidationError, # invalid_parameter_value
44
+ "22004": PresenceValidationError, # null_value_not_allowed
45
+ "22001": PresenceValidationError, # string_data_right_truncation
46
+ "22026": PresenceValidationError, # string_data_length_mismatch
47
+ }
48
+
49
+ def _apply_actor_context(self) -> None:
50
+ """Apply actor context via presence.set_actor()."""
51
+ self.cursor.execute(
52
+ """SELECT presence.set_actor(
53
+ p_actor_id := %s,
54
+ p_request_id := %s,
55
+ p_on_behalf_of := %s,
56
+ p_reason := %s
57
+ )""",
58
+ (self._actor_id, self._request_id, self._on_behalf_of, self._reason),
59
+ )
60
+
61
+ def register(
62
+ self,
63
+ entity: str,
64
+ *,
65
+ kind: str | None = None,
66
+ timeout: timedelta | None = None,
67
+ metadata: dict[str, Any] | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Register an entity for liveness tracking, or update its attributes.
70
+
71
+ Idempotent: None arguments preserve what is stored, so deploys can
72
+ re-run register safely. Re-registering is an attribute update, not
73
+ a heartbeat - liveness only changes through heartbeat() and
74
+ sweep(). A new entity starts at 'unknown' and cannot die before its
75
+ first heartbeat.
76
+
77
+ Args:
78
+ entity: Entity id (e.g. 'worker-7', 'sensor:eu:42')
79
+ kind: Entity kind, keys the config row. None means 'default'
80
+ for a new entity and keeps the current kind on re-register
81
+ timeout: Per-entity liveness window replacing the kind's
82
+ dead_after. None keeps the current override
83
+ metadata: Metadata stored on the entity; None keeps the
84
+ existing metadata on re-register (new entities start empty)
85
+
86
+ Returns:
87
+ The entity row dict
88
+ """
89
+ result = self._fetch_one(
90
+ """SELECT * FROM presence.register(
91
+ p_namespace := %s,
92
+ p_entity := %s,
93
+ p_kind := %s,
94
+ p_timeout := %s,
95
+ p_metadata := %s::jsonb
96
+ )""",
97
+ (
98
+ self.namespace,
99
+ entity,
100
+ kind,
101
+ timeout,
102
+ json.dumps(metadata) if metadata is not None else None,
103
+ ),
104
+ write=True,
105
+ )
106
+ if result is None:
107
+ raise PresenceError("presence.register returned no row")
108
+ return result
109
+
110
+ def heartbeat(self, entity: str) -> str:
111
+ """Report an entity alive.
112
+
113
+ A dead or never-seen entity revives here and now - the revival
114
+ transition is emitted by this call, never deferred to a sweep. An
115
+ unregistered entity raises (registration is explicit;
116
+ heartbeat_many reports 'unknown' instead of raising).
117
+
118
+ Args:
119
+ entity: Entity id (must be registered)
120
+
121
+ Returns:
122
+ The resulting status (always 'alive')
123
+ """
124
+ result = self._fetch_val(
125
+ "SELECT presence.heartbeat(%s, %s)",
126
+ (self.namespace, entity),
127
+ write=True,
128
+ )
129
+ if result is None:
130
+ raise PresenceError("presence.heartbeat returned no value")
131
+ return str(result)
132
+
133
+ def heartbeat_many(self, entities: list[str]) -> list[dict[str, Any]]:
134
+ """Report a batch of entities alive in one round trip.
135
+
136
+ Per-entity semantics match heartbeat(), including revivals.
137
+ Unregistered entities come back with status 'unknown' instead of
138
+ raising - one typo must not abort a fleet batch.
139
+
140
+ Args:
141
+ entities: Entity ids
142
+
143
+ Returns:
144
+ One dict per distinct entity: entity_id and its resulting status
145
+ """
146
+ return self._fetch_all(
147
+ "SELECT * FROM presence.heartbeat_many(%s, %s)",
148
+ (self.namespace, entities),
149
+ write=True,
150
+ )
151
+
152
+ def sweep(self, *, limit: int = 1000) -> list[dict[str, Any]]:
153
+ """Mark overdue entities dead and deliver deferred death alerts.
154
+
155
+ Call from a cron or timer; nothing runs on its own, and death is
156
+ detected no faster than the sweep cadence. Deaths emit transitions
157
+ and fire the configured queue hooks in the same transaction.
158
+
159
+ Args:
160
+ limit: Maximum entities to process per call
161
+
162
+ Returns:
163
+ The death transitions emitted by this call
164
+ """
165
+ return self._fetch_all(
166
+ "SELECT * FROM presence.sweep(%s, %s)",
167
+ (self.namespace, limit),
168
+ write=True,
169
+ )
170
+
171
+ def deregister(self, entity: str) -> bool:
172
+ """Remove an entity deliberately, emitting a departed transition.
173
+
174
+ Intentional exit is not death: no death hooks fire and nobody gets
175
+ paged for a planned shutdown. Idempotent - removing an absent
176
+ entity returns False, never raises.
177
+
178
+ Args:
179
+ entity: Entity id
180
+
181
+ Returns:
182
+ True if the entity existed and was removed
183
+ """
184
+ result = self._fetch_val(
185
+ "SELECT presence.deregister(%s, %s)",
186
+ (self.namespace, entity),
187
+ write=True,
188
+ )
189
+ return bool(result)
190
+
191
+ def status(self, entity: str) -> dict[str, Any] | None:
192
+ """Inspect one entity, with the wall-clock truth alongside the cache.
193
+
194
+ The stored status lags until the next sweep; the returned overdue
195
+ flag is true when the entity is nominally alive but already past
196
+ its liveness window.
197
+
198
+ Args:
199
+ entity: Entity id
200
+
201
+ Returns:
202
+ Dict with the liveness fields plus overdue, or None when the
203
+ entity is not registered
204
+ """
205
+ return self._fetch_one(
206
+ "SELECT * FROM presence.status(%s, %s)", (self.namespace, entity)
207
+ )
208
+
209
+ def list_entities(
210
+ self, *, kind: str | None = None, status: str | None = None
211
+ ) -> list[dict[str, Any]]:
212
+ """List entities in the namespace.
213
+
214
+ Args:
215
+ kind: Kind filter (None = all kinds)
216
+ status: Status filter: 'unknown', 'alive', or 'dead'
217
+
218
+ Returns:
219
+ List of entity row dicts
220
+ """
221
+ return self._fetch_all(
222
+ "SELECT * FROM presence.list(%s, %s, %s)",
223
+ (self.namespace, kind, status),
224
+ )
225
+
226
+ def get_transitions(
227
+ self, entity: str | None = None, *, limit: int = 100
228
+ ) -> list[dict[str, Any]]:
229
+ """Read the transition history, newest first.
230
+
231
+ History, not a feed: delivery is the queue hooks and NOTIFY; do
232
+ not poll this by id.
233
+
234
+ Args:
235
+ entity: Entity filter (None = all entities)
236
+ limit: Maximum transitions to return
237
+
238
+ Returns:
239
+ List of transition dicts with actor context
240
+ """
241
+ return self._fetch_all(
242
+ "SELECT * FROM presence.get_transitions(%s, %s, %s)",
243
+ (self.namespace, entity, limit),
244
+ )
245
+
246
+ def get_stats(self) -> dict[str, Any]:
247
+ """Get namespace-wide presence statistics.
248
+
249
+ Returns:
250
+ Dict with total_entities, alive, dead, unknown, overdue, and
251
+ total_transitions counts
252
+ """
253
+ result = self._fetch_one(
254
+ "SELECT * FROM presence.get_stats(%s)", (self.namespace,)
255
+ )
256
+ return result or {}
257
+
258
+ def trim(
259
+ self, older_than: timedelta, *, limit: int = 10000
260
+ ) -> list[dict[str, Any]]:
261
+ """Delete old transitions. Retention has no default; pass it explicitly.
262
+
263
+ Args:
264
+ older_than: Delete transitions older than this (required, positive)
265
+ limit: Maximum transitions to delete per call
266
+
267
+ Returns:
268
+ One dict per namespace touched, with the deleted count
269
+ """
270
+ return self._fetch_all(
271
+ "SELECT * FROM presence.trim(%s, %s, %s)",
272
+ (older_than, self.namespace, limit),
273
+ write=True,
274
+ )
@@ -359,19 +359,25 @@ class QueueClient(BaseClient):
359
359
  *,
360
360
  error: str | None = None,
361
361
  backoff: timedelta | None = None,
362
+ worker_id: str | None = None,
362
363
  ) -> bool:
363
364
  """Return job to queue for retry (temporary failure).
364
365
 
366
+ Valid on running jobs and on pending jobs whose claim was rolled
367
+ back with the consumer's transaction.
368
+
365
369
  Args:
366
370
  job_id: Job ID
367
371
  error: Error message (stored for debugging)
368
372
  backoff: Custom backoff delay (default: exponential)
373
+ worker_id: If set, refuse jobs running under a different worker
369
374
 
370
375
  Returns:
371
376
  True if returned to queue, False if max attempts exceeded (moved to DLQ)
372
377
 
373
378
  Raises:
374
- QueueValidationError: If job exists but is not in running status
379
+ QueueValidationError: If job is settled (completed or dead), or
380
+ running under a different worker than worker_id
375
381
  QueueError: If job does not exist
376
382
  """
377
383
  result = self._fetch_val(
@@ -379,13 +385,15 @@ class QueueClient(BaseClient):
379
385
  p_namespace := %s,
380
386
  p_job_id := %s,
381
387
  p_error := %s,
382
- p_backoff := %s
388
+ p_backoff := %s,
389
+ p_worker_id := %s
383
390
  )""",
384
391
  (
385
392
  self.namespace,
386
393
  job_id,
387
394
  error,
388
395
  backoff,
396
+ worker_id,
389
397
  ),
390
398
  write=True,
391
399
  )
@@ -396,26 +404,34 @@ class QueueClient(BaseClient):
396
404
  job_id: int,
397
405
  *,
398
406
  error: str | None = None,
407
+ worker_id: str | None = None,
399
408
  ) -> bool:
400
409
  """Move job to dead letter queue (permanent failure).
401
410
 
411
+ Valid on running jobs and on pending jobs whose claim was rolled
412
+ back with the consumer's transaction.
413
+
402
414
  Args:
403
415
  job_id: Job ID
404
416
  error: Error message
417
+ worker_id: If set, refuse jobs running under a different worker
405
418
 
406
419
  Returns:
407
- True if moved to DLQ, False if job not found or not running
420
+ True if moved to DLQ, False if job settled, missing, or owned
421
+ by another worker
408
422
  """
409
423
  result = self._fetch_val(
410
424
  """SELECT queue.fail(
411
425
  p_namespace := %s,
412
426
  p_job_id := %s,
413
- p_error := %s
427
+ p_error := %s,
428
+ p_worker_id := %s
414
429
  )""",
415
430
  (
416
431
  self.namespace,
417
432
  job_id,
418
433
  error,
434
+ worker_id,
419
435
  ),
420
436
  write=True,
421
437
  )
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.authn import AuthnClient, AuthnError, AuthnErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = AuthnClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(AuthnError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == AuthnErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "authn")
21
+ conn = connect_as_rls_user(db_connection, "authn")
22
+ try:
23
+ AuthnClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.authz import AuthzClient, AuthzError, AuthzErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = AuthzClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(AuthzError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == AuthzErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "authz")
21
+ conn = connect_as_rls_user(db_connection, "authz")
22
+ try:
23
+ AuthzClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()
@@ -0,0 +1,26 @@
1
+ """assert_rls_active: catch test setups whose role bypasses RLS."""
2
+
3
+ import pytest
4
+ from postkit.config import ConfigClient, ConfigError, ConfigErrorCode
5
+
6
+ from tests.helpers import connect_as_rls_user, ensure_rls_role
7
+
8
+
9
+ class TestAssertRlsActive:
10
+ """Raises for bypass roles, passes when RLS applies."""
11
+
12
+ def test_raises_for_bypass_role(self, db_connection):
13
+ client = ConfigClient(db_connection.cursor(), "rls_assert")
14
+
15
+ with pytest.raises(ConfigError) as exc_info:
16
+ client.assert_rls_active()
17
+ assert exc_info.value.error_code == ConfigErrorCode.BIZ_RLS_NOT_ACTIVE
18
+
19
+ def test_passes_for_rls_role(self, db_connection):
20
+ ensure_rls_role(db_connection, "config")
21
+ conn = connect_as_rls_user(db_connection, "config")
22
+ try:
23
+ ConfigClient(conn.cursor(), "rls_assert").assert_rls_active()
24
+ conn.rollback()
25
+ finally:
26
+ conn.close()