fleetpull 0.1.0__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 (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. fleetpull-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,417 @@
1
+ # src/fleetpull/state/cursors.py
2
+ """The cursor persistence layer: translator between cursors and ``cursors``-table rows.
3
+
4
+ Owns the serialization the pure cursor leaf (``incremental/``) and the migration
5
+ runner (``state/migrations.py``) deliberately don't (DESIGN §4/§5). A
6
+ ``DateWatermark`` serializes its ``watermark`` to ISO-8601 UTC text via the timing
7
+ codec; a ``FeedToken`` stores its opaque token verbatim (fleetpull never parses
8
+ it). The ``kind`` column discriminates the union arm on read; ``updated_at`` is
9
+ written from the injected ``Clock``. Runs after ``migrate_to_head`` — the
10
+ ``cursors`` table must already exist.
11
+
12
+ A row read with an unrecognized ``kind``, or a ``date_watermark`` ``value`` that is
13
+ not parseable ISO-8601 UTC, is state-store corruption and raises
14
+ ``ConfigurationError``, consistent with the other §5 corruption stances.
15
+ ``get_cursor`` returning ``None`` means exactly "no cursor has been persisted for
16
+ this (provider, endpoint)" — the store never fabricates one and never interprets
17
+ absence; that decision lives in the caller. Two writes, one arm each, both
18
+ kind-guarded inside their statements (so a cursor row can never silently
19
+ change arm — the §5 kind-guard doctrine, total since the feed arm landed
20
+ 2026-07-21 and the earlier unguarded general upsert was deleted with it):
21
+ ``advance_watermark_forward`` is the watermark arm's write, additionally
22
+ carrying the strictly-forward monotonicity guard in-statement (the recorded
23
+ §5 exception to the dumb-store stance, 2026-07-20), because its concurrent
24
+ prefix-committing callers cannot enforce monotonicity race-free from outside
25
+ the statement; ``commit_feed_token`` is the feed arm's write —
26
+ kind-guarded last-write-wins, with monotonicity deliberately left to the
27
+ caller's serial per-page sequencing (the reasoning on the method).
28
+ """
29
+
30
+ import logging
31
+ import sqlite3
32
+ from datetime import datetime
33
+ from enum import StrEnum
34
+ from typing import Final
35
+
36
+ from fleetpull.exceptions import ConfigurationError
37
+ from fleetpull.incremental import DateWatermark, FeedToken, IncrementalCursor
38
+ from fleetpull.state.database import (
39
+ SqliteScalar,
40
+ StateDatabase,
41
+ expect_text,
42
+ parse_stored_instant,
43
+ )
44
+ from fleetpull.timing import Clock, to_iso8601
45
+ from fleetpull.vocabulary import Provider
46
+
47
+ __all__: list[str] = ['CursorKind', 'CursorStore']
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ class CursorKind(StrEnum):
53
+ """
54
+ The ``cursors.kind`` discriminator: which arm of the union a row holds.
55
+
56
+ The read path dispatches over this discriminator to reconstruct the arm, and
57
+ ``CursorKind(kind_text)`` gives a free corrupt-discriminator guard. The string
58
+ values equal the migration's CHECK literals exactly; the two are held in two
59
+ places by deliberate boundary discipline (the migration runner owns its DDL,
60
+ the store owns serialization, and neither imports the other), pinned by the
61
+ round-trip tests that run against a real migrated table with the CHECK active.
62
+ """
63
+
64
+ DATE_WATERMARK = 'date_watermark'
65
+ FEED_TOKEN = 'feed_token'
66
+
67
+
68
+ def _deserialize_cursor(
69
+ provider: Provider, endpoint: str, kind_text: str, value_text: str
70
+ ) -> IncrementalCursor:
71
+ """
72
+ Reconstruct an incremental cursor from its stored ``(kind, value)`` row.
73
+
74
+ The raising read-side half of the codec: ``kind_text`` discriminates the arm
75
+ and ``value_text`` is deserialized into it. A ``date_watermark`` value is
76
+ parsed back from ISO-8601 UTC via the timing codec; a ``feed_token`` value is
77
+ the opaque token, returned verbatim. A ``kind_text`` outside the two known
78
+ discriminators, or a ``date_watermark`` ``value_text`` that is not parseable
79
+ ISO-8601 UTC, is state-store corruption (the §5 stance) and raises
80
+ ``ConfigurationError``.
81
+
82
+ Args:
83
+ provider: The provider whose cursor row this is; identifies the corrupt
84
+ row in a raised error, not otherwise consulted.
85
+ endpoint: The endpoint whose cursor row this is; identifies the corrupt
86
+ row in a raised error, not otherwise consulted.
87
+ kind_text: The row's ``kind`` discriminator column.
88
+ value_text: The row's ``value`` column.
89
+
90
+ Returns:
91
+ The reconstructed ``DateWatermark`` or ``FeedToken``.
92
+
93
+ Raises:
94
+ ConfigurationError: ``kind_text`` is not a known cursor kind, or a
95
+ ``date_watermark`` ``value_text`` is not parseable ISO-8601 UTC —
96
+ either is state-store corruption.
97
+ """
98
+ try:
99
+ kind: CursorKind = CursorKind(kind_text)
100
+ except ValueError as error:
101
+ raise ConfigurationError(
102
+ 'state database holds an unrecognized cursor kind',
103
+ provider=provider.value,
104
+ endpoint=endpoint,
105
+ detail=(
106
+ f'cursor kind {kind_text!r} is not one of '
107
+ f'{[member.value for member in CursorKind]}'
108
+ ),
109
+ ) from error
110
+ match kind:
111
+ case CursorKind.DATE_WATERMARK:
112
+ watermark: datetime = parse_stored_instant(
113
+ value_text,
114
+ provider=provider,
115
+ endpoint=endpoint,
116
+ column='watermark cursor value',
117
+ )
118
+ return DateWatermark(watermark=watermark)
119
+ case CursorKind.FEED_TOKEN:
120
+ return FeedToken(from_version=value_text)
121
+
122
+
123
+ _SELECT_CURSOR_SQL: Final[str] = (
124
+ 'SELECT kind, value FROM cursors WHERE provider = ? AND endpoint = ?'
125
+ )
126
+
127
+ # The feed arm's kind-guarded last-write-wins commit (DESIGN section 5,
128
+ # 2026-07-21) — and the shared upsert skeleton both writes are stated on:
129
+ # the in-statement kind guard keeps a stored watermark
130
+ # untouched (the caller distinguishes that refusal loudly — see
131
+ # commit_feed_token); the value is otherwise overwritten unconditionally.
132
+ # Deliberately NO monotonicity guard, unlike the watermark's: the token is
133
+ # opaque by doctrine (section 8's probe-settled decision 4 — the version
134
+ # order is the provider's, never fleetpull's to compare), a lexical guard
135
+ # would bet on the observed-but-uncontracted 16-hex encoding, and the feed
136
+ # drive is the only writer and strictly serial (one page after another under
137
+ # the single-driver-per-state-database assumption), so there is no
138
+ # interleaving for an in-statement guard to defend against — the situation
139
+ # that justified the watermark exception does not exist here.
140
+ _COMMIT_FEED_TOKEN_SQL: Final[str] = """
141
+ INSERT INTO cursors (provider, endpoint, kind, value, updated_at)
142
+ VALUES (?, ?, ?, ?, ?)
143
+ ON CONFLICT (provider, endpoint) DO UPDATE SET
144
+ value = excluded.value,
145
+ updated_at = excluded.updated_at
146
+ WHERE cursors.kind = excluded.kind
147
+ """
148
+
149
+ # The atomic forward-only advance (DESIGN section 5, 2026-07-20): the feed
150
+ # write's kind-guarded upsert plus the monotonicity conjunct, derived so the
151
+ # skeleton is stated once and only the conjunct distinguishes the arms. The
152
+ # guard lives INSIDE the statement, so concurrent unit
153
+ # completions racing their prefix commits can never interleave a stale
154
+ # read into a backward write. Lexical > on ``to_iso8601``'s fixed-width
155
+ # Z-form is chronological. The kind guard keeps a feed cursor untouched;
156
+ # the caller distinguishes that case loudly (see advance_watermark_forward).
157
+ _ADVANCE_WATERMARK_SQL: Final[str] = (
158
+ _COMMIT_FEED_TOKEN_SQL.rstrip('\n') + ' AND excluded.value > cursors.value\n'
159
+ )
160
+
161
+
162
+ def _stored_kind(
163
+ connection: sqlite3.Connection, provider: Provider, endpoint: str
164
+ ) -> SqliteScalar:
165
+ """Read the stored cursor row's ``kind`` for a refused write's diagnostic.
166
+
167
+ Runs on the refusing write's own connection, inside its still-open
168
+ transaction — the guarded upsert already refused and changed nothing,
169
+ so the read sees exactly the row the guard compared against.
170
+
171
+ Args:
172
+ connection: The refusing write's open connection.
173
+ provider: The provider whose row to read.
174
+ endpoint: The endpoint whose row to read.
175
+
176
+ Returns:
177
+ The stored ``kind`` scalar, or ``None`` when no row exists.
178
+ """
179
+ row: tuple[SqliteScalar, SqliteScalar] | None = connection.execute(
180
+ _SELECT_CURSOR_SQL, (provider.value, endpoint)
181
+ ).fetchone()
182
+ return None if row is None else row[0]
183
+
184
+
185
+ def _guarded_upsert(
186
+ connection: sqlite3.Connection, sql: str, params: tuple[str, ...]
187
+ ) -> bool:
188
+ """Execute one guarded cursor upsert and report whether it wrote.
189
+
190
+ The Python half of the shared upsert skeleton: the guard semantics stay
191
+ entirely inside ``sql`` (the §5 guard-placement doctrine); this helper
192
+ only executes and detects whether the guarded statement changed a row.
193
+
194
+ Args:
195
+ connection: The write's open connection.
196
+ sql: The guarded upsert statement.
197
+ params: The statement's positional bindings.
198
+
199
+ Returns:
200
+ ``True`` when a row was inserted or updated; ``False`` when the
201
+ in-statement guard refused the write.
202
+ """
203
+ changes_before: int = connection.total_changes
204
+ connection.execute(sql, params)
205
+ return connection.total_changes > changes_before
206
+
207
+
208
+ class CursorStore:
209
+ """
210
+ Persists and reads per-(provider, endpoint) incremental cursors.
211
+
212
+ The translator between the ``IncrementalCursor`` union (§4) and
213
+ ``cursors``-table rows: it owns the serialization the cursor leaf and the
214
+ migration runner deliberately don't. Runs after ``migrate_to_head`` (the
215
+ table must exist). ``get_cursor`` reconstructs the tagged-union arm from
216
+ the row's ``kind`` discriminator; the two writes — one per arm, both
217
+ stamped with the injected ``Clock`` — each carry the kind guard inside
218
+ their statement, so a cursor row can never silently change arm (§5's
219
+ kind-guard doctrine): ``advance_watermark_forward`` is the watermark
220
+ arm's strictly-forward advance, and ``commit_feed_token`` is the feed
221
+ arm's last-write-wins commit. No unguarded general write exists (the
222
+ earlier ``set_cursor`` upsert, scaffolding for the then-unbuilt feed arm,
223
+ was deleted when the guarded feed commit landed, 2026-07-21).
224
+
225
+ The store stays deliberately dumb otherwise: it never fabricates a
226
+ cursor and never interprets absence; resume-on-absence policy lives in
227
+ the orchestrator (§5).
228
+
229
+ Args:
230
+ database: The initialized, migrated state database supplying connections.
231
+ clock: The clock stamping ``updated_at`` on every write.
232
+ """
233
+
234
+ def __init__(self, database: StateDatabase, clock: Clock) -> None:
235
+ self._database: StateDatabase = database
236
+ self._clock: Clock = clock
237
+
238
+ def get_cursor(self, provider: Provider, endpoint: str) -> IncrementalCursor | None:
239
+ """
240
+ Read the persisted cursor for one (provider, endpoint), if any.
241
+
242
+ Args:
243
+ provider: The provider whose cursor to read.
244
+ endpoint: The endpoint whose cursor to read.
245
+
246
+ Returns:
247
+ The reconstructed ``DateWatermark`` or ``FeedToken``, or ``None`` when
248
+ no cursor has been persisted for this (provider, endpoint). ``None``
249
+ means exactly that absence — the store neither fabricates a cursor nor
250
+ interprets the gap; the resume-on-absence decision lives above it (§5).
251
+
252
+ Raises:
253
+ ConfigurationError: The stored row is corrupt — an unrecognized
254
+ ``kind`` or an unparseable ``date_watermark`` value.
255
+ RuntimeError: A ``kind`` or ``value`` column came back non-text,
256
+ violating the STRICT ``TEXT NOT NULL`` schema contract.
257
+
258
+ Side Effects:
259
+ Opens a connection and reads one row.
260
+ """
261
+ with self._database.connect() as connection:
262
+ row: tuple[SqliteScalar, SqliteScalar] | None = connection.execute(
263
+ _SELECT_CURSOR_SQL, (provider.value, endpoint)
264
+ ).fetchone()
265
+ if row is None:
266
+ return None
267
+ kind_text, value_text = row
268
+ # The columns are TEXT NOT NULL under STRICT, so non-text is a SQLite
269
+ # contract violation, surfaced loudly (the database.py narrowing pattern).
270
+ return _deserialize_cursor(
271
+ provider,
272
+ endpoint,
273
+ expect_text(kind_text, 'cursors.kind'),
274
+ expect_text(value_text, 'cursors.value'),
275
+ )
276
+
277
+ def commit_feed_token(
278
+ self, provider: Provider, endpoint: str, to_version: str
279
+ ) -> None:
280
+ """
281
+ Commit the feed cursor to ``to_version`` — kind-guarded last-write-wins.
282
+
283
+ The feed arm's only write (DESIGN §5, 2026-07-21). The kind guard
284
+ lives inside the statement, mirroring the watermark advance: a feed
285
+ token never overwrites a stored watermark (a refused write here is
286
+ always a cross-mode wiring bug, surfaced loudly). There is
287
+ deliberately NO monotonicity guard, unlike the watermark's two
288
+ reasons deep: the token is opaque by doctrine (§8's probe-settled
289
+ decision 4 — a lexical comparison would bet on the observed 16-hex
290
+ encoding GeoTab never contracted), and the feed drive is the only
291
+ writer and strictly serial (per-page commits of a version-ordered
292
+ stream under the single-driver assumption), so no interleaving
293
+ exists for a guard to defend against. Forward motion is therefore
294
+ the protocol's and the caller's property, not the store's;
295
+ last-write-wins is the documented semantic.
296
+
297
+ Args:
298
+ provider: The provider whose feed cursor to commit.
299
+ endpoint: The endpoint whose feed cursor to commit.
300
+ to_version: The page's ``toVersion`` — the opaque resume token,
301
+ stored verbatim (fleetpull never parses it). Re-committing
302
+ the stored value (the at-head empty page) is a valid no-op
303
+ rewrite.
304
+
305
+ Raises:
306
+ ConfigurationError: The stored cursor is a date watermark — a
307
+ cross-mode write is a wiring bug upstream, surfaced loudly
308
+ rather than silently skipped.
309
+
310
+ Side Effects:
311
+ Opens a connection; inserts or overwrites at most one row; commits.
312
+ """
313
+ updated_at: str = to_iso8601(self._clock.now_utc())
314
+ with self._database.transaction() as connection:
315
+ committed: bool = _guarded_upsert(
316
+ connection,
317
+ _COMMIT_FEED_TOKEN_SQL,
318
+ (
319
+ provider.value,
320
+ endpoint,
321
+ CursorKind.FEED_TOKEN.value,
322
+ to_version,
323
+ updated_at,
324
+ ),
325
+ )
326
+ if not committed:
327
+ # Last-write-wins can only be refused by the kind guard, so a
328
+ # refusal is always the cross-mode bug; the diagnostic read
329
+ # names the stored kind.
330
+ stored_kind = _stored_kind(connection, provider, endpoint)
331
+ raise ConfigurationError(
332
+ 'cross-mode feed-token commit refused',
333
+ provider=provider.value,
334
+ endpoint=endpoint,
335
+ detail=f'stored cursor kind is {stored_kind!r}, not a feed token',
336
+ )
337
+ logger.debug(
338
+ 'feed token committed: provider=%s endpoint=%s to_version=%s',
339
+ provider.value,
340
+ endpoint,
341
+ to_version,
342
+ )
343
+
344
+ def advance_watermark_forward(
345
+ self, provider: Provider, endpoint: str, observed: datetime
346
+ ) -> bool:
347
+ """
348
+ Advance the date watermark to ``observed`` iff that is strictly forward.
349
+
350
+ The one write with the monotonicity guard INSIDE the statement (the
351
+ deliberate exception to this store's no-discipline stance, added with
352
+ the prefix-advance rule -- DESIGN §5, 2026-07-20): concurrent unit
353
+ completions race their prefix commits, and a read-compare-write in the
354
+ caller could interleave a stale read into a backward write. The
355
+ guarded upsert inserts when no cursor exists, advances when
356
+ ``observed`` is strictly beyond the stored watermark, and changes
357
+ nothing otherwise -- atomically, whatever the caller interleaving.
358
+
359
+ Args:
360
+ provider: The provider whose watermark to advance.
361
+ endpoint: The endpoint whose watermark to advance.
362
+ observed: The candidate watermark -- a folded in-window maximum
363
+ event time.
364
+
365
+ Returns:
366
+ ``True`` when the cursor row was inserted or advanced; ``False``
367
+ when ``observed`` was not strictly forward of the stored value.
368
+
369
+ Raises:
370
+ ValueError: ``observed`` is naive or not UTC (surfaced from the
371
+ timing codec).
372
+ ConfigurationError: The stored cursor is a feed token -- a
373
+ cross-mode write is a wiring bug upstream, surfaced loudly
374
+ rather than silently skipped.
375
+
376
+ Side Effects:
377
+ Opens a connection; inserts or updates at most one row; commits.
378
+ """
379
+ value: str = to_iso8601(observed)
380
+ updated_at: str = to_iso8601(self._clock.now_utc())
381
+ with self._database.transaction() as connection:
382
+ advanced: bool = _guarded_upsert(
383
+ connection,
384
+ _ADVANCE_WATERMARK_SQL,
385
+ (
386
+ provider.value,
387
+ endpoint,
388
+ CursorKind.DATE_WATERMARK.value,
389
+ value,
390
+ updated_at,
391
+ ),
392
+ )
393
+ if not advanced:
394
+ # Not-forward is normal; a kind mismatch is a bug. One
395
+ # diagnostic read distinguishes them.
396
+ stored_kind = _stored_kind(connection, provider, endpoint)
397
+ if (
398
+ stored_kind is not None
399
+ and stored_kind != CursorKind.DATE_WATERMARK.value
400
+ ):
401
+ raise ConfigurationError(
402
+ 'cross-mode watermark advance refused',
403
+ provider=provider.value,
404
+ endpoint=endpoint,
405
+ detail=(
406
+ f'stored cursor kind is {stored_kind!r}, not a '
407
+ f'date watermark'
408
+ ),
409
+ )
410
+ if advanced:
411
+ logger.debug(
412
+ 'watermark advanced: provider=%s endpoint=%s watermark=%s',
413
+ provider.value,
414
+ endpoint,
415
+ value,
416
+ )
417
+ return advanced