fleetmesh-node 0.2.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 (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
node/storage/base.py ADDED
@@ -0,0 +1,385 @@
1
+ """The backend-independent half of a store: the lock, the transaction, and the
2
+ declarative schema.
3
+ Bound to `SqliteDialect` by default. Everything here is backend-independent
4
+ and tested across supported backends.
5
+
6
+ Subclasses declare their schema rather than running DDL in ``__init__``:
7
+
8
+ - ``SCHEMA``: ``CREATE TABLE IF NOT EXISTS`` / ``CREATE INDEX IF NOT EXISTS``
9
+ statements, one per entry, executed in order on every open. They must be
10
+ idempotent, which the ``IF NOT EXISTS`` form guarantees.
11
+ - ``MIGRATIONS``: ``(version, sql)`` pairs, ascending. Each is applied once,
12
+ when the stored schema version is below ``version``; afterwards the version
13
+ is raised to the highest applied. A fresh database runs through every
14
+ migration too, so ``SCHEMA`` can stay the original DDL and a column added
15
+ later is expressed exactly once, as the ``ALTER TABLE`` that added it. A
16
+ database from before the version was tracked may already carry such a column
17
+ (the old open path added it under a ``has_column`` guard): a duplicate-column
18
+ error from an ``ADD COLUMN`` migration is therefore treated as "already
19
+ applied", which the dialect decides rather than a message match here.
20
+ - ``_post_schema()``: hook for data backfills that need Python, run inside the
21
+ same transaction after the migrations.
22
+
23
+ All of it runs in ``_ensure_schema()`` as one transaction at open, so a crash
24
+ midway leaves the database as it was.
25
+
26
+ ``DURABILITY`` names what a store needs of its backend, for the three classes
27
+ in docs/storage-backends.md section 6:
28
+
29
+ 'commit' a write is durable before the call returns
30
+ 'window' may be batched, durable before the window closes or before a
31
+ receipt citing it leaves
32
+ 'cache' may be lost; rebuildable from elsewhere
33
+
34
+ It is declared, not yet enforced: on SQLite every write is already a commit,
35
+ so the classes describe the contract a networked backend will have to honour.
36
+ I15 is the defect that exists because this was never named - see
37
+ docs/audit-2026-10-07-4-issues.md.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import contextlib
42
+ import threading
43
+ import time
44
+ from typing import Any, Callable, Iterator, Optional, Sequence, TypedDict, Union
45
+
46
+ from ..compartment.schema import Migration
47
+ from .dialect import Dialect, SqliteDialect
48
+ from .locator import redact
49
+
50
+ LEASE_SECONDS: int = 900
51
+
52
+
53
+ class StoreLease(TypedDict):
54
+ """The lease as `meshnode health` reports it.
55
+
56
+ A named record rather than a loose mapping, because check-dicts.py holds
57
+ this tree to typing what it returns, and a row an operator reads by field
58
+ name is exactly the case that rule exists for.
59
+ """
60
+ owner: str
61
+ expires_at: int
62
+ mine: bool
63
+ expires_in: int
64
+
65
+
66
+ class StoreOwnedError(RuntimeError):
67
+ """Another node holds a live lease on this database.
68
+
69
+ Raised by `open()` rather than letting two nodes write one database. One
70
+ node per database is a decision (docs/storage-backends.md header), and it
71
+ is a decision a connection string can break where a file could not:
72
+ `meters` is keyed (peer, epoch, side) and `grants` (peer, direction), and
73
+ neither includes the node, so two nodes would interleave rows, read each
74
+ other's accounting as their own, and price windows that never happened
75
+ with nothing in the schema complaining.
76
+ """
77
+
78
+
79
+
80
+ class _Cursor:
81
+ """A cursor whose SQL goes through the dialect on the way out.
82
+
83
+ `transaction()` used to yield the driver's own cursor, so every
84
+ `cur.execute(...)` inside a `with store.transaction() as cur:` block
85
+ circumvented the translation the base exists to apply - 37 statements
86
+ across the tree, invisible while the SQLite dialect's `sql()` was the
87
+ identity function. Handing out something that bypasses your own interface
88
+ is the flaw; counting the call sites was the symptom.
89
+
90
+ Everything else - fetchone, fetchall, rowcount, description - passes
91
+ straight through to the real cursor.
92
+ """
93
+
94
+ __slots__ = ('_cur', '_dialect')
95
+
96
+ def __init__(self, cur: Any, dialect: Dialect) -> None:
97
+ self._cur = cur
98
+ self._dialect = dialect
99
+
100
+ def execute(self, q: str, args: Sequence[Any] = ()) -> Any:
101
+ return self._cur.execute(self._dialect.sql(q), tuple(args))
102
+
103
+ def executemany(self, q: str, seq: Any) -> Any:
104
+ return self._cur.executemany(self._dialect.sql(q), seq)
105
+
106
+ def __iter__(self) -> Any:
107
+ return iter(self._cur)
108
+
109
+ def __getattr__(self, name: str) -> Any:
110
+ return getattr(self._cur, name)
111
+
112
+
113
+ class BaseStore:
114
+ """Thread-safe persistence with a declarative schema, over any dialect."""
115
+
116
+ SCHEMA: tuple[Any, ...] = ()
117
+ MIGRATIONS: tuple[Union[Migration, tuple[int, str]], ...] = ()
118
+ DURABILITY: str = 'commit'
119
+
120
+ def __init__(self, path: str = ':memory:', timeout: float = 5.0,
121
+ dialect: Optional[Dialect] = None, owner: Optional[str] = None,
122
+ lease_seconds: int = LEASE_SECONDS,
123
+ clock: Optional[Callable[[], int]] = None,
124
+ readonly: bool = False,
125
+ schema: Optional[Sequence[Any]] = None) -> None:
126
+ self.path: str = path
127
+ self.dialect: Dialect = dialect or SqliteDialect()
128
+ self._lock: threading.RLock = threading.RLock()
129
+ self.owner: Optional[str] = owner
130
+ self.lease_seconds: int = int(lease_seconds)
131
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
132
+ self.readonly: bool = readonly
133
+ if schema is not None:
134
+ self.SCHEMA = tuple(schema)
135
+ self.db: Any = self.dialect.connect(path, timeout, readonly)
136
+ if not readonly:
137
+ self.dialect.tune(self.db, path, self._lock)
138
+ # Claimed before the schema runs: migrations are writes, and a
139
+ # database another node owns is not this node's to migrate.
140
+ if owner is not None:
141
+ self._claim_lease(owner)
142
+ self._ensure_schema()
143
+
144
+ # ---- One node per database ------------------------------------------------
145
+
146
+ def _claim_lease(self, owner: str) -> None:
147
+ """Take the lease, or refuse because another node holds a live one.
148
+
149
+ A stale lease is takeable, which is what makes an ordinary restart
150
+ work: a node that died holds a lease nobody renews, and the next start
151
+ - its own or a replacement's - takes it once it expires.
152
+ """
153
+ now = self.clock()
154
+ with self._lock:
155
+ self.db.execute(self.dialect.sql(
156
+ 'CREATE TABLE IF NOT EXISTS fleetmesh_owner ('
157
+ ' only_row INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)'))
158
+ cur = self.db.execute(self.dialect.sql(
159
+ 'SELECT owner, expires_at FROM fleetmesh_owner WHERE only_row = 1'))
160
+ try:
161
+ row = cur.fetchone()
162
+ finally:
163
+ cur.close()
164
+ if row is not None and row[0] != owner and int(row[1]) > now:
165
+ raise StoreOwnedError(
166
+ 'database %s is held by node %s until %d (now %d); one node per database'
167
+ % (self.safe_path, str(row[0])[:16], int(row[1]), now))
168
+ self.db.execute(self.dialect.sql(
169
+ 'INSERT OR REPLACE INTO fleetmesh_owner VALUES (1, ?, ?)'),
170
+ (owner, now + self.lease_seconds))
171
+ self.db.commit()
172
+
173
+ def renew_lease(self) -> None:
174
+ """Push the lease out. The tick calls this; a node that stops renewing
175
+ releases the database to its successor once the lease runs out."""
176
+ if self.owner is None:
177
+ return
178
+ with self._lock:
179
+ self.db.execute(self.dialect.sql(
180
+ 'INSERT OR REPLACE INTO fleetmesh_owner VALUES (1, ?, ?)'),
181
+ (self.owner, self.clock() + self.lease_seconds))
182
+ self.db.commit()
183
+
184
+ def lease(self) -> Optional[StoreLease]:
185
+ """The lease as health should report it, or None when none is held."""
186
+ with self._lock:
187
+ if not self.dialect.has_table(self.db, 'fleetmesh_owner'):
188
+ return None
189
+ cur = self.db.execute(self.dialect.sql(
190
+ 'SELECT owner, expires_at FROM fleetmesh_owner WHERE only_row = 1'))
191
+ try:
192
+ row = cur.fetchone()
193
+ finally:
194
+ cur.close()
195
+ if row is None:
196
+ return None
197
+ return StoreLease(owner=str(row[0]), expires_at=int(row[1]),
198
+ mine=row[0] == self.owner,
199
+ expires_in=int(row[1]) - self.clock())
200
+
201
+ # ---- Declarative Schema -------------------------------------------------
202
+
203
+ @property
204
+ def safe_path(self) -> str:
205
+ """`path` with any credentials removed, for a message or a log.
206
+
207
+ A SQLite target is a filename and comes back unchanged. A server
208
+ target is a connection string and carries a password, so anything an
209
+ operator reads - a refusal, a log line, a health block - must use
210
+ this and not `path`. The lease refusal below is the case that found
211
+ it: it is the one message a misconfigured fleet is guaranteed to
212
+ produce, and it printed the whole URL.
213
+ """
214
+ return redact(self.path) if '://' in self.path else self.path
215
+
216
+ def schema_name(self) -> str:
217
+ """How this store identifies itself to the dialect's version table.
218
+
219
+ One node per database means all fourteen stores share one database on a
220
+ server backend, where a single version row would make their separate
221
+ migration series collide.
222
+ """
223
+ return type(self).__name__
224
+
225
+ def user_version(self) -> int:
226
+ """This store's stored schema version (0 if it never migrated)."""
227
+ with self._lock:
228
+ return self.dialect.get_version(self.db, self.schema_name())
229
+
230
+ def _post_schema(self) -> None:
231
+ """Data backfill hook, run inside the schema transaction. No-op by default."""
232
+
233
+ def _ensure_schema(self) -> None:
234
+ """Apply ``SCHEMA`` then any pending ``MIGRATIONS`` in one transaction."""
235
+ if not self.SCHEMA and not self.MIGRATIONS:
236
+ return
237
+ with self._lock:
238
+ self.dialect.begin(self.db)
239
+ try:
240
+ for entry in self.SCHEMA:
241
+ if hasattr(entry, 'to_sql'):
242
+ sql_stmt = entry.to_sql(dialect=self.dialect.name)
243
+ if hasattr(self.dialect, 'learn_table'):
244
+ self.dialect.learn_table(entry)
245
+ self.db.execute(self.dialect.sql(sql_stmt))
246
+ if hasattr(entry, 'secondary_sql'):
247
+ for sec in entry.secondary_sql(dialect=self.dialect.name):
248
+ self.db.execute(self.dialect.sql(sec))
249
+ else:
250
+ self.db.execute(self.dialect.sql(entry))
251
+ current = self.dialect.get_version(self.db, self.schema_name())
252
+ target = current
253
+ for m in self.MIGRATIONS:
254
+ if isinstance(m, Migration):
255
+ version, sql = m.to_tuple()
256
+ else:
257
+ version, sql = m
258
+ if version <= current:
259
+ continue
260
+ try:
261
+ self.db.execute(self.dialect.sql(sql))
262
+ except BaseException as e:
263
+ if not self.dialect.is_duplicate_column(e):
264
+ raise
265
+ target = max(target, version)
266
+ if target != current:
267
+ self.dialect.set_version(self.db, self.schema_name(), target)
268
+ self._post_schema()
269
+ self.dialect.commit(self.db)
270
+ except BaseException:
271
+ self.dialect.rollback(self.db)
272
+ raise
273
+
274
+ def integrity_check(self) -> Optional[str]:
275
+ """None if the database is sound, else what is wrong with it."""
276
+ with self._lock:
277
+ return self.dialect.integrity_check(self.db)
278
+
279
+ def vacuum(self, pages: int = 50) -> int:
280
+ """Reclaim freed space and refresh planner statistics, per the dialect."""
281
+ with self._lock:
282
+ return self.dialect.vacuum(self.db, pages)
283
+
284
+ def close(self) -> None:
285
+ """Safely close the underlying database connection."""
286
+ db = getattr(self, 'db', None)
287
+ if db is None:
288
+ return
289
+ self.db = None
290
+ lock = getattr(self, '_lock', None)
291
+ if lock is not None:
292
+ try:
293
+ with lock:
294
+ try:
295
+ db.close()
296
+ except Exception:
297
+ pass
298
+ except Exception:
299
+ try:
300
+ db.close()
301
+ except Exception:
302
+ pass
303
+ else:
304
+ try:
305
+ db.close()
306
+ except Exception:
307
+ pass
308
+
309
+ def __del__(self) -> None:
310
+ try:
311
+ self.close()
312
+ except Exception:
313
+ pass
314
+
315
+ # ---- Query Execution with Guaranteed Cursor Cleanup ---------------------
316
+
317
+ def _one(self, q: str, args: Sequence[Any] = ()) -> Optional[tuple[Any, ...]]:
318
+ """Execute query and fetch one row, closing the cursor before releasing the lock.
319
+
320
+ Prevents InterfaceError/SQLITE_MISUSE from unfinalized cursors collected by GC.
321
+ """
322
+ with self._lock:
323
+ cur = self.db.execute(self.dialect.sql(q), tuple(args))
324
+ try:
325
+ return cur.fetchone()
326
+ finally:
327
+ cur.close()
328
+
329
+ def _all(self, q: str, args: Sequence[Any] = ()) -> list[tuple[Any, ...]]:
330
+ """Execute query and fetch all rows, closing the cursor before releasing the lock."""
331
+ with self._lock:
332
+ cur = self.db.execute(self.dialect.sql(q), tuple(args))
333
+ try:
334
+ return cur.fetchall()
335
+ finally:
336
+ cur.close()
337
+
338
+ def execute(self, q: str, args: Sequence[Any] = ()) -> Any:
339
+ """Execute a single statement and return the cursor within the locked context."""
340
+ with self._lock:
341
+ return self.db.execute(self.dialect.sql(q), tuple(args))
342
+
343
+ def executescript(self, script: str) -> None:
344
+ """Execute multiple SQL statements and commit."""
345
+ with self._lock:
346
+ self.db.executescript(self.dialect.sql(script))
347
+ self.db.commit()
348
+
349
+ def commit(self) -> None:
350
+ """Commit active transaction under lock."""
351
+ with self._lock:
352
+ self.db.commit()
353
+
354
+ @contextlib.contextmanager
355
+ def transaction(self) -> Iterator[Any]:
356
+ """Atomic block: commit on success, rollback on exception.
357
+
358
+ The retry a serializable backend would need belongs here and nowhere
359
+ else, which is what keeps CockroachDB a later addition rather than a
360
+ rewrite (docs/storage-backends.md section 5). None is built: SQLite
361
+ never raises a retryable error, and neither does a Postgres backend
362
+ that keeps this one-connection-behind-one-lock shape.
363
+ """
364
+ with self._lock:
365
+ cur = self.db.cursor()
366
+ try:
367
+ yield _Cursor(cur, self.dialect)
368
+ self.db.commit()
369
+ except Exception:
370
+ self.db.rollback()
371
+ raise
372
+ finally:
373
+ cur.close()
374
+
375
+ # ---- Schema Reflection Helpers ------------------------------------------
376
+
377
+ def has_table(self, table_name: str) -> bool:
378
+ """Return True if the specified table exists in the database."""
379
+ with self._lock:
380
+ return self.dialect.has_table(self.db, table_name)
381
+
382
+ def has_column(self, table_name: str, column_name: str) -> bool:
383
+ """Return True if the column exists in the table schema."""
384
+ with self._lock:
385
+ return self.dialect.has_column(self.db, table_name, column_name)
@@ -0,0 +1,209 @@
1
+ """What differs between one SQL backend and another, and nothing else.
2
+
3
+ The thirteen stores on `BaseStore` declare their schema and write ordinary
4
+ SQL. Almost all of it is portable; this module holds the parts that are not,
5
+ so that a second backend is one class here rather than edits at 152 execute
6
+ sites. The census behind that number, and why the list is this short, is
7
+ docs/storage-backends.md section 4.
8
+
9
+ A dialect owns:
10
+
11
+ connect/tune opening the connection and the per-backend setup that
12
+ follows it (SQLite's PRAGMAs, and nothing on a server)
13
+ sql() rendering a statement written in the house dialect -
14
+ `?` placeholders and `INSERT OR REPLACE/IGNORE` - into
15
+ what this backend accepts. SQLite's is identity, which
16
+ is what makes the split provably behaviour-preserving.
17
+ schema version read and write it (SQLite: `PRAGMA user_version`)
18
+ reflection has_table / has_column
19
+ errors is_duplicate_column, so a migration that was already
20
+ applied is recognised without matching a message string
21
+ at the call site
22
+ maintenance vacuum, which means different things per backend
23
+
24
+ Nothing here knows what a meter or a grant is.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import sqlite3
30
+ import threading
31
+ from typing import Any, Optional, Protocol
32
+
33
+
34
+ def ensure_incremental_vacuum(db: sqlite3.Connection, path: str,
35
+ max_bytes: int = 256 * 1024 * 1024) -> bool:
36
+ """One-off migration of a pre-existing auto_vacuum=NONE database.
37
+
38
+ PRAGMA auto_vacuum only takes effect on an empty database; on one created
39
+ before incremental vacuum was introduced it is silently ignored, and
40
+ incremental_vacuum() then reclaims nothing forever. Switching needs a full
41
+ VACUUM, which rewrites the file once. Done at open, before other writers,
42
+ and skipped above ``max_bytes`` (override FLEETMESH_VACUUM_MIGRATE_MAX_BYTES)
43
+ so a very large store is never blocked at startup - operators run
44
+ ``VACUUM`` by hand there. Returns True if the database was converted.
45
+ """
46
+ if path == ':memory:' or path.startswith('file::memory:'):
47
+ return False
48
+ try:
49
+ if db.execute('PRAGMA auto_vacuum').fetchone()[0] == 2:
50
+ return False
51
+ if not db.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone():
52
+ return False # empty: the pragma alone already applies
53
+ limit = int(os.environ.get('FLEETMESH_VACUUM_MIGRATE_MAX_BYTES', max_bytes))
54
+ size = os.path.getsize(path) if os.path.exists(path) else 0
55
+ if size > limit:
56
+ return False
57
+ db.execute('PRAGMA auto_vacuum=INCREMENTAL')
58
+ db.execute('VACUUM')
59
+ return db.execute('PRAGMA auto_vacuum').fetchone()[0] == 2
60
+ except (sqlite3.Error, OSError):
61
+ return False
62
+
63
+
64
+ class Dialect(Protocol):
65
+ """The backend-specific half of a store. One implementation per backend."""
66
+
67
+ name: str
68
+ # The expression that gives rows in insertion order, or None where the
69
+ # backend has none. SQLite has `rowid`; PostgreSQL has nothing equivalent -
70
+ # `ctid` is a physical location, not insertion order, and is not stable
71
+ # across updates. A store that needs insertion order carries its own
72
+ # column and uses this only to backfill rows written before it did.
73
+ rowid_expr: Optional[str]
74
+
75
+ def connect(self, target: str, timeout: float, readonly: bool = False) -> Any: ...
76
+ def tune(self, db: Any, target: str, lock: threading.RLock) -> None: ...
77
+ def sql(self, statement: str) -> str: ...
78
+ def begin(self, db: Any) -> None: ...
79
+ def commit(self, db: Any) -> None: ...
80
+ def rollback(self, db: Any) -> None: ...
81
+ def get_version(self, db: Any, store: str) -> int: ...
82
+ def set_version(self, db: Any, store: str, version: int) -> None: ...
83
+ def has_table(self, db: Any, table: str) -> bool: ...
84
+ def has_column(self, db: Any, table: str, column: str) -> bool: ...
85
+ def is_duplicate_column(self, exc: BaseException) -> bool: ...
86
+ def vacuum(self, db: Any, pages: int) -> int: ...
87
+ def integrity_check(self, db: Any) -> Optional[str]: ...
88
+ def learn_table(self, table: Any) -> None: ...
89
+ def errors(self) -> tuple[type[BaseException], ...]: ...
90
+
91
+
92
+ class SqliteDialect:
93
+ """SQLite, which is the default and the only backend that needs no operator.
94
+
95
+ `sql()` is the identity function: the house dialect *is* SQLite's, so the
96
+ extraction of BaseStore from BaseSqliteStore cannot change a statement.
97
+ A second dialect is where the translation lives.
98
+ """
99
+
100
+ name: str = 'sqlite'
101
+ rowid_expr: Optional[str] = 'rowid'
102
+
103
+ def learn_table(self, table: Any) -> None:
104
+ """SQLite requires no reflection for upsert queries."""
105
+
106
+ def connect(self, target: str, timeout: float, readonly: bool = False) -> sqlite3.Connection:
107
+ if readonly and target != ':memory:' and not target.startswith('file:'):
108
+ # What the CLI has always done when it reads a store beside a
109
+ # running node: never write, never take the WAL writer lock, never
110
+ # create a table the node has not created yet.
111
+ return sqlite3.connect('file:%s?mode=ro' % target, uri=True,
112
+ timeout=timeout, check_same_thread=False)
113
+ return sqlite3.connect(target, timeout=timeout, check_same_thread=False)
114
+
115
+ def tune(self, db: sqlite3.Connection, target: str, lock: threading.RLock) -> None:
116
+ """Apply high-concurrency PRAGMAs while holding the lock."""
117
+ with lock:
118
+ # WAL mode applies to file-backed databases; :memory: ignores it
119
+ if target != ':memory:' and not target.startswith('file::memory:'):
120
+ # auto_vacuum must precede journal_mode=WAL and table creation; on an
121
+ # existing auto_vacuum=NONE database it is a no-op (needs a one-off VACUUM).
122
+ try:
123
+ db.execute('PRAGMA auto_vacuum=INCREMENTAL')
124
+ except sqlite3.OperationalError:
125
+ pass
126
+ try:
127
+ db.execute('PRAGMA journal_mode=WAL')
128
+ except sqlite3.OperationalError:
129
+ pass
130
+ db.execute('PRAGMA busy_timeout=5000')
131
+ db.execute('PRAGMA synchronous=NORMAL')
132
+ db.execute('PRAGMA foreign_keys=ON')
133
+ ensure_incremental_vacuum(db, target)
134
+
135
+ def sql(self, statement: str) -> str:
136
+ return statement
137
+
138
+ def begin(self, db: sqlite3.Connection) -> None:
139
+ db.execute('BEGIN')
140
+
141
+ def commit(self, db: sqlite3.Connection) -> None:
142
+ db.execute('COMMIT')
143
+
144
+ def rollback(self, db: sqlite3.Connection) -> None:
145
+ db.execute('ROLLBACK')
146
+
147
+ def get_version(self, db: sqlite3.Connection, store: str) -> int:
148
+ # `store` is ignored: each SQLite store is its own file, so
149
+ # PRAGMA user_version is already per store. It matters on a backend
150
+ # where every store shares one database - see PostgresDialect.
151
+ cur = db.execute('PRAGMA user_version')
152
+ try:
153
+ row = cur.fetchone()
154
+ finally:
155
+ cur.close()
156
+ return int(row[0]) if row else 0
157
+
158
+ def set_version(self, db: sqlite3.Connection, store: str, version: int) -> None:
159
+ db.execute('PRAGMA user_version=%d' % int(version))
160
+
161
+ def has_table(self, db: sqlite3.Connection, table: str) -> bool:
162
+ cur = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,))
163
+ try:
164
+ return cur.fetchone() is not None
165
+ finally:
166
+ cur.close()
167
+
168
+ def has_column(self, db: sqlite3.Connection, table: str, column: str) -> bool:
169
+ cur = db.execute('PRAGMA table_info(%s)' % table)
170
+ try:
171
+ return column in {r[1] for r in cur.fetchall()}
172
+ finally:
173
+ cur.close()
174
+
175
+ def is_duplicate_column(self, exc: BaseException) -> bool:
176
+ return isinstance(exc, sqlite3.OperationalError) and 'duplicate column name' in str(exc)
177
+
178
+ def vacuum(self, db: sqlite3.Connection, pages: int) -> int:
179
+ """Incremental vacuum to reclaim freed pages, and optimize the planner."""
180
+ try:
181
+ mode = db.execute('PRAGMA auto_vacuum').fetchone()[0]
182
+ if mode != 2: # not INCREMENTAL: incremental_vacuum is a no-op
183
+ db.execute('PRAGMA optimize')
184
+ return 0
185
+ before = db.execute('PRAGMA freelist_count').fetchone()[0]
186
+ db.execute('PRAGMA incremental_vacuum(%d)' % int(pages)).fetchall()
187
+ after = db.execute('PRAGMA freelist_count').fetchone()[0]
188
+ db.execute('PRAGMA optimize')
189
+ return max(0, before - after)
190
+ except Exception:
191
+ return 0
192
+
193
+ def integrity_check(self, db: sqlite3.Connection) -> Optional[str]:
194
+ """None if the database is sound, else what is wrong with it.
195
+
196
+ `PRAGMA quick_check` is SQLite's, and a file-integrity check is a
197
+ file-backed idea: a server backend answers for its own soundness and
198
+ returns None here rather than pretending to run this.
199
+ """
200
+ cur = db.execute('PRAGMA quick_check')
201
+ try:
202
+ row = cur.fetchone()
203
+ finally:
204
+ cur.close()
205
+ verdict = str(row[0]) if row else 'no answer'
206
+ return None if verdict == 'ok' else verdict
207
+
208
+ def errors(self) -> tuple[type[BaseException], ...]:
209
+ return (sqlite3.Error,)