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
@@ -0,0 +1,953 @@
1
+ """ProjectedNodeStore and TableProjector: relational SQL views materialized
2
+ co-transactionally from MeshStore NIP-01 events and P2Panda causal logs.
3
+
4
+ Every write to `ProjectedNodeStore` is a signed NIP-01 event (or a Kind 9850
5
+ envelope carrying a signed `P2PandaOperation`). `MeshStore` remains the single
6
+ source of truth for:
7
+ - BIP-340 signature verification and canonical event IDs
8
+ - Replaceable (0, 3, 10000..19999) and addressable (30000..39999) supersession
9
+ - NIP-09 (`kind == 5`) `e`-tag and `a`-tag coordinate deletions and tombstones
10
+ - NIP-40 expiration filtering
11
+ - NIP-77 Negentropy `enumerate_range` and `fingerprint_range` set reconciliation
12
+ - Kind 30803 shard coverage claims, handover, and retention guards
13
+
14
+ Whenever `MeshStore` commits `_put(event)`, `_drop(event_id)`, or `forget(event_id)`
15
+ inside `with self._lock, self._atomic():`, `ProjectedNodeStore` updates all
16
+ registered relational SQL tables through `self.dialect.sql(...)` in the exact
17
+ same database transaction. Because `ProjectorSpec` enforces a 1-to-1 primary
18
+ key correspondence with the NIP-01 storage class of each event kind, the
19
+ projected SQL tables converge identically across all nodes regardless of event
20
+ arrival order, supersession order, deletion order, or backfill order.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from typing import Any, Callable, Mapping, Optional, Sequence, TypedDict
26
+
27
+ from fleetmesh_core.crypto import crypto
28
+ from fleetmesh_core.storage import store
29
+ from fleetmesh_core.storage.projection import (
30
+ CAUSAL_KIND,
31
+ SYSTEM_COLUMNS,
32
+ FieldSpec,
33
+ ProjectedRow,
34
+ ProjectedValue,
35
+ ProjectionError,
36
+ ProjectorSpec,
37
+ encode_deletion,
38
+ encode_event,
39
+ extract_row,
40
+ unwrap_p2panda_event,
41
+ wrap_p2panda_event,
42
+ )
43
+ from fleetmesh_core.wire.p2panda import P2PandaOperation
44
+ from node.compartment.schema import Column, Index, Table
45
+ from node.interfaces import NostrEvent, NostrFilter
46
+ from node.protocols.nostr.eventstore import NodeStore
47
+
48
+ __all__ = [
49
+ 'CAUSAL_KIND',
50
+ 'CausalProjector',
51
+ 'FieldSpec',
52
+ 'ProjectedNodeStore',
53
+ 'ProjectedRow',
54
+ 'ProjectedValue',
55
+ 'ProjectionError',
56
+ 'ProjectionRefusal',
57
+ 'ProjectionVerifyReport',
58
+ 'ProjectorSpec',
59
+ 'TableProjector',
60
+ ]
61
+
62
+
63
+ class ProjectionRefusal(TypedDict):
64
+ event_id: str
65
+ kind: int
66
+ table: str
67
+ reason: str
68
+ at: int
69
+
70
+
71
+ class ProjectionVerifyReport(TypedDict):
72
+ ok: bool
73
+ tables: dict[str, int]
74
+ missing_events: list[str]
75
+ orphan_rows: list[str]
76
+ mismatched_rows: list[str]
77
+
78
+
79
+ class TableProjector:
80
+ """Binds a pure `ProjectorSpec` to a declarative `node.compartment.schema.Table`
81
+ and generates dialect-neutral SQL for co-transactional upsert and drop."""
82
+
83
+ def __init__(
84
+ self,
85
+ spec: ProjectorSpec,
86
+ *,
87
+ indices: Sequence[Index] = (),
88
+ without_rowid: bool = False,
89
+ ) -> None:
90
+ if not isinstance(spec, ProjectorSpec):
91
+ raise TypeError(f'spec must be a ProjectorSpec, got {type(spec).__name__}')
92
+ self.spec: ProjectorSpec = spec
93
+ self.without_rowid: bool = bool(without_rowid)
94
+
95
+ single_pk = spec.primary_key[0] if len(spec.primary_key) == 1 else None
96
+ cols: list[Column] = []
97
+ for f in spec.fields:
98
+ is_pk = (single_pk is not None and f.name == single_pk)
99
+ cols.append(
100
+ Column(
101
+ f.name,
102
+ f.sql_type(),
103
+ not_null=(f.required or is_pk or f.name in spec.primary_key),
104
+ primary_key=is_pk,
105
+ )
106
+ )
107
+ # System provenance columns on every projected table
108
+ cols.append(Column('_event_id', 'TEXT', not_null=True))
109
+ cols.append(Column('_pubkey', 'TEXT', not_null=True))
110
+ cols.append(Column('_created_at', 'INTEGER', not_null=True))
111
+
112
+ event_idx_name = f'idx_{spec.table_name}__event_id'
113
+ all_indices: list[Index] = [
114
+ Index(event_idx_name, ('_event_id',), table=spec.table_name),
115
+ ]
116
+ for idx in indices:
117
+ bound = idx.bind_table(spec.table_name)
118
+ if bound.name != event_idx_name:
119
+ all_indices.append(bound)
120
+
121
+ table_pk: tuple[str, ...] = () if single_pk is not None else spec.primary_key
122
+ self.table: Table = Table(
123
+ spec.table_name,
124
+ cols,
125
+ primary_key=table_pk,
126
+ indices=all_indices,
127
+ without_rowid=self.without_rowid,
128
+ )
129
+ probs = self.table.problems()
130
+ if probs:
131
+ raise ProjectionError('; '.join(str(p) for p in probs))
132
+
133
+ self._col_names: tuple[str, ...] = tuple(f.name for f in spec.fields) + SYSTEM_COLUMNS
134
+ placeholders = ', '.join('?' for _ in self._col_names)
135
+ col_list = ', '.join(self._col_names)
136
+ self._upsert_sql: str = (
137
+ f'INSERT OR REPLACE INTO {spec.table_name} ({col_list}) VALUES ({placeholders})'
138
+ )
139
+ self._delete_by_event_sql: str = (
140
+ f'DELETE FROM {spec.table_name} WHERE _event_id = ?'
141
+ )
142
+
143
+ @property
144
+ def table_name(self) -> str:
145
+ return self.spec.table_name
146
+
147
+ @property
148
+ def kind(self) -> int:
149
+ return self.spec.kind
150
+
151
+ def column_names(self) -> tuple[str, ...]:
152
+ return self._col_names
153
+
154
+ def upsert_sql(self) -> str:
155
+ return self._upsert_sql
156
+
157
+ def delete_by_event_sql(self) -> str:
158
+ return self._delete_by_event_sql
159
+
160
+ def project(
161
+ self,
162
+ event: Mapping[str, Any],
163
+ ) -> tuple[Optional[tuple[ProjectedValue, ...]], Optional[str]]:
164
+ """Extract a positional value tuple matching `column_names()`, or `(None, why_refused)`."""
165
+ row, err = extract_row(self.spec, event)
166
+ if err is not None or row is None:
167
+ return None, err
168
+ return tuple(row[c] for c in self._col_names), None
169
+
170
+
171
+ class CausalProjector:
172
+ """Projects verified `P2PandaOperation` chains carried in Kind 9850 events
173
+ in strict per-`(log_id, author)` sequence order `0, 1, 2, ...`.
174
+
175
+ Out-of-order operations arriving via Negentropy or relay gossip are buffered
176
+ in `p2panda_causal_ops` (`applied = 0`) and automatically drained as soon
177
+ as their missing predecessor operations arrive.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ log_id: str,
183
+ tables: Sequence[Table],
184
+ apply_fn: Callable[[Any, P2PandaOperation, Mapping[str, Any]], Optional[str]],
185
+ *,
186
+ shard_id: Optional[str] = None,
187
+ ) -> None:
188
+ if not log_id or not isinstance(log_id, str):
189
+ raise ProjectionError('CausalProjector log_id must be a non-empty string')
190
+ self.log_id: str = log_id
191
+ self.shard_id: str = shard_id or f'causal:{log_id}'
192
+ self.tables: tuple[Table, ...] = tuple(tables)
193
+ self.apply_fn = apply_fn
194
+ for tbl in self.tables:
195
+ probs = tbl.problems()
196
+ if probs:
197
+ raise ProjectionError('; '.join(str(p) for p in probs))
198
+
199
+ def shard_filter(self) -> NostrFilter:
200
+ """Return the NIP-01 filter covering all Kind 9850 operations for `log_id`."""
201
+ return {'kinds': [CAUSAL_KIND], '#L': [self.log_id]} # type: ignore[typeddict-unknown-key]
202
+
203
+
204
+ _CAUSAL_OPS_TABLE = Table(
205
+ 'p2panda_causal_ops',
206
+ (
207
+ Column('event_id', 'TEXT', primary_key=True),
208
+ Column('log_id', 'TEXT', not_null=True),
209
+ Column('op_id', 'TEXT', not_null=True),
210
+ Column('author', 'TEXT', not_null=True),
211
+ Column('seq_num', 'INTEGER', not_null=True),
212
+ Column('backlink', 'TEXT'),
213
+ Column('timestamp', 'INTEGER', not_null=True),
214
+ Column('applied', 'INTEGER', not_null=True, default=0),
215
+ Column('raw_event', 'TEXT', not_null=True),
216
+ ),
217
+ indices=(
218
+ Index('idx_p2panda_causal_ops_log_author_seq', ('log_id', 'author', 'seq_num')),
219
+ Index('idx_p2panda_causal_ops_op_id', ('op_id',)),
220
+ ),
221
+ )
222
+
223
+
224
+ class ProjectedNodeStore(NodeStore):
225
+ """`NodeStore` extended with co-transactional `TableProjector` and
226
+ `CausalProjector` materialization."""
227
+
228
+ SCHEMA: tuple[Any, ...] = NodeStore.SCHEMA
229
+ MAX_REFUSALS: int = 512
230
+
231
+ def __init__(
232
+ self,
233
+ path: str = ':memory:',
234
+ *,
235
+ projectors: Sequence[TableProjector | ProjectorSpec] = (),
236
+ causal_projectors: Sequence[CausalProjector] = (),
237
+ **kw: Any,
238
+ ) -> None:
239
+ self._projectors_by_table: dict[str, TableProjector] = {}
240
+ self._projectors_by_kind: dict[int, list[TableProjector]] = {}
241
+ self._causal_by_log: dict[str, CausalProjector] = {}
242
+ self.projection_refusals: list[ProjectionRefusal] = []
243
+ self.projected_counts: dict[str, int] = {}
244
+
245
+ extra_tables: list[Table] = [_CAUSAL_OPS_TABLE]
246
+ for p in projectors:
247
+ tp = p if isinstance(p, TableProjector) else TableProjector(p)
248
+ if tp.table_name in self._projectors_by_table:
249
+ raise ProjectionError(f'duplicate TableProjector for table {tp.table_name!r}')
250
+ self._projectors_by_table[tp.table_name] = tp
251
+ self._projectors_by_kind.setdefault(tp.kind, []).append(tp)
252
+ extra_tables.append(tp.table)
253
+
254
+ for cp in causal_projectors:
255
+ if cp.log_id in self._causal_by_log:
256
+ raise ProjectionError(f'duplicate CausalProjector for log_id {cp.log_id!r}')
257
+ self._causal_by_log[cp.log_id] = cp
258
+ for tbl in cp.tables:
259
+ extra_tables.append(tbl)
260
+
261
+ combined_schema = list(NodeStore.SCHEMA) + extra_tables
262
+ NodeStore.__init__(self, path, schema=combined_schema, **kw)
263
+ if not self.readonly:
264
+ self._ensure_projector_columns(extra_tables)
265
+ if (self._projectors_by_table or self._causal_by_log) and self.count() > 0:
266
+ self.backfill()
267
+
268
+ def _ensure_projector_columns(self, tables: Sequence[Table]) -> None:
269
+ """Ensure any newly added `Column`s on existing projected tables are
270
+ migrated via `ALTER TABLE ... ADD COLUMN` prior to `backfill()`."""
271
+ with self._lock:
272
+ for tbl in tables:
273
+ if not self.dialect.has_table(self.db, tbl.name):
274
+ continue
275
+ for col in tbl.columns:
276
+ if not col.primary_key and not self.dialect.has_column(self.db, tbl.name, col.name):
277
+ default_clause = (
278
+ f' DEFAULT {col.default}'
279
+ if col.default is not None
280
+ else (" DEFAULT ''" if col.type.upper() == 'TEXT' else ' DEFAULT 0')
281
+ )
282
+ null_clause = '' if col.nullable else ' NOT NULL'
283
+ ddl = f'ALTER TABLE {tbl.name} ADD COLUMN {col.name} {col.type}{null_clause}{default_clause}'
284
+ try:
285
+ self.execute(ddl)
286
+ except Exception as exc:
287
+ if not self.dialect.is_duplicate_column(exc):
288
+ raise
289
+
290
+ # ---- Projector Registration ---------------------------------------------
291
+
292
+ def projectors(self) -> tuple[TableProjector, ...]:
293
+ return tuple(self._projectors_by_table.values())
294
+
295
+ def causal_projectors(self) -> tuple[CausalProjector, ...]:
296
+ return tuple(self._causal_by_log.values())
297
+
298
+ def projector(self, table_name: str) -> TableProjector:
299
+ tp = self._projectors_by_table.get(table_name)
300
+ if tp is None:
301
+ raise KeyError(f'unknown projected table {table_name!r}')
302
+ return tp
303
+
304
+ def register_projector(self, projector: TableProjector | ProjectorSpec) -> TableProjector:
305
+ """Register a `TableProjector` on an already-open store, create its
306
+ table/indices if needed, and backfill from `events`."""
307
+ tp = projector if isinstance(projector, TableProjector) else TableProjector(projector)
308
+ with self._lock:
309
+ if tp.table_name in self._projectors_by_table:
310
+ return self._projectors_by_table[tp.table_name]
311
+ self._projectors_by_table[tp.table_name] = tp
312
+ self._projectors_by_kind.setdefault(tp.kind, []).append(tp)
313
+ self.SCHEMA = tuple(self.SCHEMA) + (tp.table,)
314
+ if not self.readonly:
315
+ if hasattr(self.dialect, 'learn_table'):
316
+ self.dialect.learn_table(tp.table)
317
+ self.execute(tp.table.to_sql(dialect=self.dialect.name))
318
+ for sec in tp.table.secondary_sql(dialect=self.dialect.name):
319
+ self.execute(sec)
320
+ self._ensure_projector_columns((tp.table,))
321
+ self.commit()
322
+ self.backfill(table_name=tp.table_name)
323
+ return tp
324
+
325
+ def register_causal_projector(self, causal: CausalProjector) -> CausalProjector:
326
+ """Register a `CausalProjector` on an already-open store and backfill
327
+ any Kind 9850 operations for its `log_id`."""
328
+ with self._lock:
329
+ if causal.log_id in self._causal_by_log:
330
+ return self._causal_by_log[causal.log_id]
331
+ self._causal_by_log[causal.log_id] = causal
332
+ self.SCHEMA = tuple(self.SCHEMA) + causal.tables
333
+ if not self.readonly:
334
+ for tbl in causal.tables:
335
+ if hasattr(self.dialect, 'learn_table'):
336
+ self.dialect.learn_table(tbl)
337
+ self.execute(tbl.to_sql(dialect=self.dialect.name))
338
+ for sec in tbl.secondary_sql(dialect=self.dialect.name):
339
+ self.execute(sec)
340
+ self._ensure_projector_columns(causal.tables)
341
+ self.commit()
342
+ self._backfill_causal(causal.log_id)
343
+ return causal
344
+
345
+ # ---- Co-Transactional Store Hooks ---------------------------------------
346
+
347
+ def _record_refusal(self, event_id: str, kind: int, table: str, reason: str) -> None:
348
+ entry: ProjectionRefusal = {
349
+ 'event_id': str(event_id),
350
+ 'kind': int(kind),
351
+ 'table': str(table),
352
+ 'reason': str(reason),
353
+ 'at': self.clock(),
354
+ }
355
+ self.projection_refusals.append(entry)
356
+ if len(self.projection_refusals) > self.MAX_REFUSALS:
357
+ del self.projection_refusals[: len(self.projection_refusals) - self.MAX_REFUSALS]
358
+
359
+ def _project_put(self, event: Mapping[str, Any]) -> None:
360
+ kind = int(event['kind'])
361
+ eid = str(event['id'])
362
+
363
+ # 1. Declarative TableProjectors for this kind
364
+ for tp in self._projectors_by_kind.get(kind, ()):
365
+ vals, why = tp.project(event)
366
+ if why is not None or vals is None:
367
+ self._record_refusal(eid, kind, tp.table_name, why or 'invalid payload')
368
+ continue
369
+ self.execute('SAVEPOINT sp_proj_row')
370
+ try:
371
+ self.execute(tp.upsert_sql(), vals)
372
+ except Exception as exc:
373
+ self.execute('ROLLBACK TO SAVEPOINT sp_proj_row')
374
+ self.execute('RELEASE SAVEPOINT sp_proj_row')
375
+ self._record_refusal(eid, kind, tp.table_name, f'{type(exc).__name__}: {exc}')
376
+ continue
377
+ self.execute('RELEASE SAVEPOINT sp_proj_row')
378
+ self.projected_counts[tp.table_name] = self.projected_counts.get(tp.table_name, 0) + 1
379
+
380
+ # 2. Causal P2Panda operation envelopes (Kind 9850)
381
+ if kind == CAUSAL_KIND and self._causal_by_log:
382
+ self._project_causal_put(event)
383
+
384
+ def _rebuild_causal_projector_tables(self, cp: CausalProjector) -> None:
385
+ """Reset `cp.tables` and replay all buffered operations in `p2panda_causal_ops`
386
+ for `cp.log_id` from genesis in strict causal sequence order."""
387
+ for tbl in cp.tables:
388
+ self.execute(f'DELETE FROM {tbl.name}')
389
+ self.execute(
390
+ 'UPDATE p2panda_causal_ops SET applied = 0 WHERE log_id = ?',
391
+ (cp.log_id,),
392
+ )
393
+ self.projected_counts[cp.log_id] = 0
394
+ authors = [
395
+ str(r[0])
396
+ for r in self._all(
397
+ 'SELECT DISTINCT author FROM p2panda_causal_ops WHERE log_id = ? ORDER BY author ASC',
398
+ (cp.log_id,),
399
+ )
400
+ ]
401
+ for author in authors:
402
+ self._drain_causal_author(cp, author)
403
+
404
+ def _project_drop(self, event_id: str, kind: Optional[int] = None) -> None:
405
+ target_projectors = (
406
+ self._projectors_by_kind.get(kind, ())
407
+ if kind is not None
408
+ else tuple(self._projectors_by_table.values())
409
+ )
410
+ for tp in target_projectors:
411
+ self.execute(tp.delete_by_event_sql(), (event_id,))
412
+ if self._causal_by_log and (kind is None or kind == CAUSAL_KIND):
413
+ causal_row = self._one(
414
+ 'SELECT log_id, applied FROM p2panda_causal_ops WHERE event_id = ?',
415
+ (event_id,),
416
+ )
417
+ self.execute('DELETE FROM p2panda_causal_ops WHERE event_id = ?', (event_id,))
418
+ if causal_row is not None:
419
+ log_id_str, was_applied = str(causal_row[0]), int(causal_row[1])
420
+ if was_applied and log_id_str in self._causal_by_log:
421
+ self._rebuild_causal_projector_tables(self._causal_by_log[log_id_str])
422
+
423
+ def _put(self, event: Mapping[str, Any]) -> None:
424
+ with self._lock, self._atomic():
425
+ store.SqliteStore._put(self, dict(event))
426
+ self._project_put(event)
427
+
428
+ def _drop(self, event_id: str) -> None:
429
+ with self._lock, self._atomic():
430
+ row = self._one('SELECT kind FROM events WHERE id = ?', (event_id,))
431
+ kind = int(row[0]) if row is not None else None
432
+ NodeStore._drop(self, event_id)
433
+ self._project_drop(event_id, kind=kind)
434
+
435
+ def forget(self, event_id: str) -> bool:
436
+ with self._lock, self._atomic():
437
+ row = self._one('SELECT kind FROM events WHERE id = ?', (event_id,))
438
+ kind = int(row[0]) if row is not None else None
439
+ cur = self.execute('DELETE FROM events WHERE id = ?', (event_id,))
440
+ try:
441
+ deleted = cur.rowcount > 0
442
+ finally:
443
+ cur.close()
444
+ self.execute('DELETE FROM origin WHERE id = ?', (event_id,))
445
+ self.execute('DELETE FROM tags WHERE id = ?', (event_id,))
446
+ self._project_drop(event_id, kind=kind)
447
+ return deleted
448
+
449
+ # ---- Causal P2Panda Ordering Engine -------------------------------------
450
+
451
+ def _project_causal_put(self, event: Mapping[str, Any]) -> None:
452
+ eid = str(event['id'])
453
+ log_id, op, why = unwrap_p2panda_event(event)
454
+ if why is not None or log_id is None or op is None:
455
+ self._record_refusal(eid, CAUSAL_KIND, 'p2panda_causal_ops', why or 'invalid causal event')
456
+ return
457
+ cp = self._causal_by_log.get(log_id)
458
+ if cp is None:
459
+ return
460
+ author = op.author.lower()
461
+ op_id = op.id.lower()
462
+ backlink = op.backlink.lower() if op.backlink else None
463
+
464
+ # Check for conflicting operation at the same (log_id, author, seq_num)
465
+ existing = self._one(
466
+ 'SELECT op_id, applied FROM p2panda_causal_ops WHERE log_id = ? AND author = ? AND seq_num = ?',
467
+ (log_id, author, op.seq_num),
468
+ )
469
+ if existing is not None:
470
+ if str(existing[0]).lower() == op_id:
471
+ return
472
+ self._record_refusal(
473
+ eid,
474
+ CAUSAL_KIND,
475
+ log_id,
476
+ f'fork conflict at seq_num={op.seq_num} for author {author}',
477
+ )
478
+ return
479
+
480
+ self.execute(
481
+ 'INSERT OR REPLACE INTO p2panda_causal_ops '
482
+ '(event_id, log_id, op_id, author, seq_num, backlink, timestamp, applied, raw_event) '
483
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)',
484
+ (
485
+ eid,
486
+ log_id,
487
+ op_id,
488
+ author,
489
+ op.seq_num,
490
+ backlink,
491
+ op.header.timestamp,
492
+ crypto.canonical(event).decode(),
493
+ ),
494
+ )
495
+ self._drain_causal_author(cp, author)
496
+
497
+ def _drain_causal_author(self, cp: CausalProjector, author: str) -> None:
498
+ """Advance `(cp.log_id, author)` through any contiguous buffered operations
499
+ starting from `highest_applied_seq + 1`."""
500
+ log_id = cp.log_id
501
+ latest = self._one(
502
+ 'SELECT seq_num, op_id, timestamp FROM p2panda_causal_ops '
503
+ 'WHERE log_id = ? AND author = ? AND applied = 1 '
504
+ 'ORDER BY seq_num DESC LIMIT 1',
505
+ (log_id, author),
506
+ )
507
+ if latest is None:
508
+ expected_seq = 0
509
+ prev_op_id: Optional[str] = None
510
+ prev_ts: int = 0
511
+ else:
512
+ expected_seq = int(latest[0]) + 1
513
+ prev_op_id = str(latest[1]).lower()
514
+ prev_ts = int(latest[2])
515
+
516
+ while True:
517
+ cand = self._one(
518
+ 'SELECT event_id, op_id, backlink, timestamp, raw_event FROM p2panda_causal_ops '
519
+ 'WHERE log_id = ? AND author = ? AND seq_num = ? AND applied = 0 '
520
+ 'ORDER BY event_id ASC LIMIT 1',
521
+ (log_id, author, expected_seq),
522
+ )
523
+ if cand is None:
524
+ break
525
+ cand_eid = str(cand[0])
526
+ cand_op_id = str(cand[1]).lower()
527
+ cand_backlink = str(cand[2]).lower() if cand[2] else None
528
+ cand_ts = int(cand[3])
529
+ cand_event = json.loads(str(cand[4]))
530
+
531
+ if expected_seq == 0:
532
+ if cand_backlink is not None and cand_backlink != '':
533
+ self._record_refusal(
534
+ cand_eid,
535
+ CAUSAL_KIND,
536
+ log_id,
537
+ f'genesis operation for {author} must have null backlink',
538
+ )
539
+ break
540
+ else:
541
+ if cand_backlink != prev_op_id:
542
+ self._record_refusal(
543
+ cand_eid,
544
+ CAUSAL_KIND,
545
+ log_id,
546
+ f'backlink mismatch at seq_num={expected_seq}: expected {prev_op_id}, got {cand_backlink}',
547
+ )
548
+ break
549
+ if cand_ts < prev_ts:
550
+ self._record_refusal(
551
+ cand_eid,
552
+ CAUSAL_KIND,
553
+ log_id,
554
+ f'operation timestamp {cand_ts} precedes predecessor {prev_ts}',
555
+ )
556
+ break
557
+
558
+ _, op, why = unwrap_p2panda_event(cand_event)
559
+ if why is not None or op is None:
560
+ self._record_refusal(cand_eid, CAUSAL_KIND, log_id, why or 'unwrap failed')
561
+ break
562
+
563
+ self.execute('SAVEPOINT sp_proj_causal')
564
+ try:
565
+ err = cp.apply_fn(self, op, cand_event)
566
+ except Exception as exc:
567
+ self.execute('ROLLBACK TO SAVEPOINT sp_proj_causal')
568
+ self.execute('RELEASE SAVEPOINT sp_proj_causal')
569
+ self._record_refusal(cand_eid, CAUSAL_KIND, log_id, f'{type(exc).__name__}: {exc}')
570
+ break
571
+ if err is not None:
572
+ self.execute('ROLLBACK TO SAVEPOINT sp_proj_causal')
573
+ self.execute('RELEASE SAVEPOINT sp_proj_causal')
574
+ self._record_refusal(cand_eid, CAUSAL_KIND, log_id, err)
575
+ break
576
+ self.execute('RELEASE SAVEPOINT sp_proj_causal')
577
+
578
+ self.execute(
579
+ 'UPDATE p2panda_causal_ops SET applied = 1 WHERE event_id = ?',
580
+ (cand_eid,),
581
+ )
582
+ self.projected_counts[log_id] = self.projected_counts.get(log_id, 0) + 1
583
+ prev_op_id = cand_op_id
584
+ prev_ts = cand_ts
585
+ expected_seq += 1
586
+
587
+ # ---- High-Level Write Helpers -------------------------------------------
588
+
589
+ def _monotonic_timestamp(
590
+ self,
591
+ spec: ProjectorSpec,
592
+ pubkey: str,
593
+ row: Mapping[str, Any],
594
+ now: int,
595
+ ) -> int:
596
+ """Ensure a locally authored replaceable/addressable update or post-delete
597
+ re-creation has a `created_at` strictly greater than any prior live
598
+ event or NIP-09 coordinate deletion at the same coordinate."""
599
+ ts = int(now)
600
+ if store.is_addressable(spec.kind):
601
+ d_val = ''
602
+ for f in spec.fields:
603
+ if f.source == 'tag' and f.tag_name == 'd':
604
+ d_val = str(row.get(f.name, f.default or ''))
605
+ break
606
+ coord = f'{spec.kind}:{pubkey}:{d_val}'
607
+ for ev in self._by_author_kind(pubkey, spec.kind):
608
+ if store.coordinate(ev) == coord and int(ev['created_at']) >= ts:
609
+ ts = int(ev['created_at']) + 1
610
+ for del_ev in self._by_author_kind(pubkey, 5):
611
+ if any(t[1] == coord for t in store.tags(del_ev, 'a') if len(t) > 1):
612
+ if int(del_ev['created_at']) >= ts:
613
+ ts = int(del_ev['created_at']) + 1
614
+ ts = self.forward_clock(coord, ts)
615
+ elif store.is_replaceable(spec.kind):
616
+ coord = f'{spec.kind}:{pubkey}:'
617
+ for ev in self._by_author_kind(pubkey, spec.kind):
618
+ if int(ev['created_at']) >= ts:
619
+ ts = int(ev['created_at']) + 1
620
+ for del_ev in self._by_author_kind(pubkey, 5):
621
+ if any(t[1] == coord for t in store.tags(del_ev, 'a') if len(t) > 1):
622
+ if int(del_ev['created_at']) >= ts:
623
+ ts = int(del_ev['created_at']) + 1
624
+ ts = self.forward_clock(coord, ts)
625
+ return ts
626
+
627
+ def forward_clock(self, coord: str, now: int) -> int:
628
+ """Return a strictly monotonic timestamp for `coord` (matching
629
+ `Discovery.forward_clock`)."""
630
+ with self._lock:
631
+ if not hasattr(self, '_forward_clocks'):
632
+ self._forward_clocks: dict[str, int] = {}
633
+ last = self._forward_clocks.get(coord)
634
+ ts = int(now)
635
+ if last is not None and ts <= last:
636
+ ts = last + 1
637
+ self._forward_clocks[coord] = ts
638
+ return ts
639
+
640
+ def put_record(
641
+ self,
642
+ table_name: str,
643
+ row: Mapping[str, Any],
644
+ sk_hex: str,
645
+ *,
646
+ created_at: Optional[int] = None,
647
+ now: Optional[int] = None,
648
+ extra_tags: Sequence[Sequence[str]] = (),
649
+ expiration_ts: Optional[int] = None,
650
+ origin: str = NodeStore.LOCAL,
651
+ ) -> tuple[NostrEvent, str]:
652
+ """Encode `row` into a signed NIP-01 event for `table_name` and insert it
653
+ co-transactionally into both `events` and the projected SQL table."""
654
+ tp = self.projector(table_name)
655
+ effective_now = int(now) if now is not None else self.clock()
656
+ pubkey = crypto.pubkey(sk_hex)
657
+ with self._lock:
658
+ effective_ts = (
659
+ int(created_at)
660
+ if created_at is not None
661
+ else self._monotonic_timestamp(tp.spec, pubkey, row, effective_now)
662
+ )
663
+ ev = encode_event(
664
+ tp.spec,
665
+ sk_hex,
666
+ row,
667
+ effective_ts,
668
+ extra_tags=extra_tags,
669
+ expiration_ts=expiration_ts,
670
+ )
671
+ outcome = self.insert_event(ev, now=max(effective_now, effective_ts))
672
+ if outcome == 'stored':
673
+ self.note_origin(ev['id'], origin)
674
+ return ev, outcome
675
+
676
+ def delete_record(
677
+ self,
678
+ table_name: str,
679
+ sk_hex: str,
680
+ *,
681
+ d_tag: Optional[str] = None,
682
+ event_id: Optional[str] = None,
683
+ reason: str = '',
684
+ created_at: Optional[int] = None,
685
+ now: Optional[int] = None,
686
+ origin: str = NodeStore.LOCAL,
687
+ ) -> tuple[NostrEvent, str]:
688
+ """Sign and insert a NIP-09 (`kind == 5`) deletion event removing a
689
+ record from `table_name` and `events` co-transactionally."""
690
+ tp = self.projector(table_name)
691
+ effective_now = int(now) if now is not None else self.clock()
692
+ pubkey = crypto.pubkey(sk_hex)
693
+ with self._lock:
694
+ effective_ts = int(created_at) if created_at is not None else effective_now
695
+ if created_at is None:
696
+ if store.is_addressable(tp.kind) and d_tag is not None:
697
+ coord = f'{tp.kind}:{pubkey}:{d_tag}'
698
+ for ev in self._by_author_kind(pubkey, tp.kind):
699
+ if store.coordinate(ev) == coord and int(ev['created_at']) > effective_ts:
700
+ effective_ts = int(ev['created_at'])
701
+ effective_ts = self.forward_clock(coord, effective_ts)
702
+ elif store.is_replaceable(tp.kind):
703
+ coord = f'{tp.kind}:{pubkey}:'
704
+ for ev in self._by_author_kind(pubkey, tp.kind):
705
+ if int(ev['created_at']) > effective_ts:
706
+ effective_ts = int(ev['created_at'])
707
+ effective_ts = self.forward_clock(coord, effective_ts)
708
+ del_ev = encode_deletion(
709
+ tp.spec,
710
+ sk_hex,
711
+ effective_ts,
712
+ d_tag=d_tag,
713
+ event_id=event_id,
714
+ reason=reason,
715
+ )
716
+ outcome = self.insert_event(del_ev, now=max(effective_now, effective_ts))
717
+ if outcome == 'stored':
718
+ self.note_origin(del_ev['id'], origin)
719
+ return del_ev, outcome
720
+
721
+ def put_causal_operation(
722
+ self,
723
+ log_id: str,
724
+ op: P2PandaOperation,
725
+ sk_hex: str,
726
+ *,
727
+ shard: Optional[str] = None,
728
+ created_at: Optional[int] = None,
729
+ now: Optional[int] = None,
730
+ origin: str = NodeStore.LOCAL,
731
+ ) -> tuple[NostrEvent, str]:
732
+ """Wrap a signed `P2PandaOperation` in a Kind 9850 event and insert it."""
733
+ effective_now = int(now) if now is not None else self.clock()
734
+ effective_ts = int(created_at) if created_at is not None else effective_now
735
+ ev = wrap_p2panda_event(sk_hex, log_id, op, effective_ts, shard=shard)
736
+ with self._lock:
737
+ outcome = self.insert_event(ev, now=max(effective_now, effective_ts))
738
+ if outcome == 'stored':
739
+ self.note_origin(ev['id'], origin)
740
+ return ev, outcome
741
+
742
+ # ---- Query, Backfill & Verification Helpers -----------------------------
743
+
744
+ def query_records(
745
+ self,
746
+ table_name: str,
747
+ where: str = '',
748
+ args: Sequence[Any] = (),
749
+ order_by: str = '',
750
+ limit: Optional[int] = None,
751
+ ) -> list[ProjectedRow]:
752
+ """Query rows from a projected SQL table as typed dictionaries."""
753
+ tp = self.projector(table_name)
754
+ cols = tp.column_names()
755
+ sql = f"SELECT {', '.join(cols)} FROM {tp.table_name}"
756
+ if where:
757
+ sql += f' WHERE {where}'
758
+ if order_by:
759
+ sql += f' ORDER BY {order_by}'
760
+ if limit is not None:
761
+ sql += f' LIMIT {int(limit)}'
762
+ with self._lock:
763
+ rows = self._all(sql, args)
764
+ return [dict(zip(cols, r)) for r in rows]
765
+
766
+ def get_record(
767
+ self,
768
+ table_name: str,
769
+ pk: ProjectedValue | Sequence[ProjectedValue] | Mapping[str, ProjectedValue],
770
+ ) -> Optional[ProjectedRow]:
771
+ """Fetch one row from `table_name` by its primary key."""
772
+ tp = self.projector(table_name)
773
+ pk_cols = tp.spec.primary_key
774
+ vals: tuple[ProjectedValue, ...]
775
+ if isinstance(pk, Mapping):
776
+ vals = tuple(pk[c] for c in pk_cols)
777
+ elif isinstance(pk, Sequence) and not isinstance(pk, str):
778
+ vals = tuple(pk)
779
+ else:
780
+ vals = (pk,)
781
+ if len(vals) != len(pk_cols):
782
+ raise ValueError(f'expected {len(pk_cols)} primary key values for {table_name}, got {len(vals)}')
783
+ where = ' AND '.join(f'{c} = ?' for c in pk_cols)
784
+ matches = self.query_records(table_name, where=where, args=vals, limit=1)
785
+ return matches[0] if matches else None
786
+
787
+ def table_count(self, table_name: str) -> int:
788
+ tp = self.projector(table_name)
789
+ with self._lock:
790
+ row = self._one(f'SELECT COUNT(*) FROM {tp.table_name}')
791
+ return int(row[0]) if row else 0
792
+
793
+ def _backfill_causal(self, log_id: Optional[str] = None) -> int:
794
+ targets = (
795
+ [self._causal_by_log[log_id]]
796
+ if log_id is not None and log_id in self._causal_by_log
797
+ else list(self._causal_by_log.values())
798
+ )
799
+ if not targets:
800
+ return 0
801
+ count = 0
802
+ with self._lock, self._atomic():
803
+ rows = self._all(
804
+ 'SELECT json FROM events WHERE kind = ? ORDER BY created_at ASC, id ASC',
805
+ (CAUSAL_KIND,),
806
+ )
807
+ for (raw_json,) in rows:
808
+ ev = json.loads(raw_json)
809
+ l_tag = store.first_tag(ev, 'L')
810
+ ev_log = l_tag[1] if (l_tag and len(l_tag) > 1) else None
811
+ if log_id is not None and ev_log != log_id:
812
+ continue
813
+ self._project_causal_put(ev)
814
+ count += 1
815
+ return count
816
+
817
+ def backfill(self, table_name: Optional[str] = None) -> dict[str, int]:
818
+ """Rebuild one or all projected SQL tables from the live `events` table."""
819
+ targets = (
820
+ [self.projector(table_name)]
821
+ if table_name is not None
822
+ else list(self._projectors_by_table.values())
823
+ )
824
+ rebuilt: dict[str, int] = {}
825
+ with self._lock, self._atomic():
826
+ for tp in targets:
827
+ self.execute(f'DELETE FROM {tp.table_name}')
828
+ rows = self._all(
829
+ 'SELECT json FROM events WHERE kind = ? ORDER BY created_at ASC, id ASC',
830
+ (tp.kind,),
831
+ )
832
+ n = 0
833
+ for (raw_json,) in rows:
834
+ ev = json.loads(raw_json)
835
+ vals, why = tp.project(ev)
836
+ if why is not None or vals is None:
837
+ self._record_refusal(str(ev['id']), tp.kind, tp.table_name, why or 'invalid payload')
838
+ continue
839
+ self.execute(tp.upsert_sql(), vals)
840
+ n += 1
841
+ rebuilt[tp.table_name] = n
842
+ self.projected_counts[tp.table_name] = n
843
+ if table_name is None and self._causal_by_log:
844
+ self.execute('DELETE FROM p2panda_causal_ops')
845
+ for cp in self._causal_by_log.values():
846
+ for tbl in cp.tables:
847
+ self.execute(f'DELETE FROM {tbl.name}')
848
+ self._backfill_causal()
849
+ return rebuilt
850
+
851
+ def verify_projection(self) -> ProjectionVerifyReport:
852
+ """Audit every registered `TableProjector` and `CausalProjector` against
853
+ the live `events` table, checking for missing events, orphan rows, or
854
+ column mismatches."""
855
+ tables_count: dict[str, int] = {}
856
+ missing_events: list[str] = []
857
+ orphan_rows: list[str] = []
858
+ mismatched_rows: list[str] = []
859
+
860
+ with self._lock:
861
+ for tp in self._projectors_by_table.values():
862
+ cols = tp.column_names()
863
+ sql_rows = self._all(
864
+ f"SELECT {', '.join(cols)} FROM {tp.table_name}"
865
+ )
866
+ tables_count[tp.table_name] = len(sql_rows)
867
+ orphan_eids = {
868
+ str(r[0])
869
+ for r in self._all(
870
+ f'SELECT _event_id FROM {tp.table_name} WHERE _event_id NOT IN (SELECT id FROM events)'
871
+ )
872
+ }
873
+ by_eid: dict[str, tuple[object, ...]] = {}
874
+ for r in sql_rows:
875
+ row_map = dict(zip(cols, r))
876
+ eid = str(row_map['_event_id'])
877
+ by_eid[eid] = tuple(r)
878
+ if eid in orphan_eids:
879
+ orphan_rows.append(f'{tp.table_name}:{eid}')
880
+
881
+ ev_rows = self._all(
882
+ 'SELECT json FROM events WHERE kind = ? ORDER BY created_at ASC, id ASC',
883
+ (tp.kind,),
884
+ )
885
+ for (raw_json,) in ev_rows:
886
+ ev = json.loads(raw_json)
887
+ eid = str(ev['id'])
888
+ expected_vals, why = tp.project(ev)
889
+ if why is not None or expected_vals is None:
890
+ continue
891
+ actual_vals = by_eid.get(eid)
892
+ if actual_vals is None:
893
+ missing_events.append(f'{tp.table_name}:{eid}')
894
+ elif tuple(actual_vals) != tuple(expected_vals):
895
+ mismatched_rows.append(f'{tp.table_name}:{eid}')
896
+
897
+ if self._causal_by_log:
898
+ causal_eids = {
899
+ str(r[0])
900
+ for r in self._all('SELECT event_id FROM p2panda_causal_ops')
901
+ }
902
+ for (orphan_eid,) in self._all(
903
+ 'SELECT event_id FROM p2panda_causal_ops '
904
+ 'WHERE event_id NOT IN (SELECT id FROM events) ORDER BY event_id ASC'
905
+ ):
906
+ orphan_rows.append(f'p2panda_causal_ops:{orphan_eid}')
907
+ causal_ev_rows = self._all(
908
+ 'SELECT json FROM events WHERE kind = ? ORDER BY created_at ASC, id ASC',
909
+ (CAUSAL_KIND,),
910
+ )
911
+ for (raw_json,) in causal_ev_rows:
912
+ ev = json.loads(raw_json)
913
+ log_id, op, why = unwrap_p2panda_event(ev)
914
+ if why is not None or op is None or not log_id or log_id not in self._causal_by_log:
915
+ continue
916
+ eid = str(ev['id'])
917
+ if eid not in causal_eids:
918
+ missing_events.append(f'p2panda_causal_ops:{eid}')
919
+
920
+ is_ok = not (missing_events or orphan_rows or mismatched_rows)
921
+ return {
922
+ 'ok': is_ok,
923
+ 'tables': tables_count,
924
+ 'missing_events': missing_events,
925
+ 'orphan_rows': orphan_rows,
926
+ 'mismatched_rows': mismatched_rows,
927
+ }
928
+
929
+ def shard_filters(self) -> dict[str, NostrFilter]:
930
+ """Return `{shard_id: nostr_filter}` aggregated across all registered
931
+ `TableProjector`s and `CausalProjector`s that declare a `shard_id`."""
932
+ with self._lock:
933
+ by_shard_kinds: dict[str, list[int]] = {}
934
+ out: dict[str, NostrFilter] = {}
935
+ for tp in self._projectors_by_table.values():
936
+ sid = tp.spec.shard_id
937
+ if not sid:
938
+ continue
939
+ kinds = by_shard_kinds.setdefault(sid, [])
940
+ if tp.kind not in kinds:
941
+ kinds.append(tp.kind)
942
+ for sid, kinds in by_shard_kinds.items():
943
+ out[sid] = {'kinds': sorted(kinds)}
944
+ for cp in self._causal_by_log.values():
945
+ out[cp.shard_id] = cp.shard_filter()
946
+ return out
947
+
948
+ def protected_filters(self) -> list[NostrFilter]:
949
+ """Return the list of NIP-01 filters covering all projected tables with
950
+ a `shard_id` and all registered `CausalProjector` logs so `Retention`
951
+ never evicts events backing claimed projected tables."""
952
+ return list(self.shard_filters().values())
953
+