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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""p2panda identity adapter for Fleetmesh.
|
|
2
|
+
|
|
3
|
+
Maps the node's Ed25519 transport key to a p2panda author identity.
|
|
4
|
+
Conforms to ProtocolIdentityAdapter (fleetmesh_core.wire.interfaces).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from node.protocols.base import Ed25519IdentityAdapter
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'P2PandaIdentityAdapter',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class P2PandaIdentityAdapter(Ed25519IdentityAdapter):
|
|
16
|
+
"""Translates between Fleetmesh keys and p2panda author identities."""
|
|
17
|
+
|
|
18
|
+
uri_prefixes: tuple[str, ...] = ("p2panda:", "panda:")
|
|
19
|
+
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""fleetmesh-protocol-p2panda: p2panda protocol package for Fleetmesh.
|
|
2
|
+
|
|
3
|
+
Conforms to ProtocolPackage (fleetmesh_core.wire.interfaces.ProtocolPackage).
|
|
4
|
+
Mounts p2panda operation log ingress onto ProtocolHostRouter, provides
|
|
5
|
+
P2PandaRung for TransportRouter ladder, and manages append-only SQLite persistence.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from typing import Any, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
from fleetmesh_core.wire.interfaces import (
|
|
15
|
+
ProtocolHealth,
|
|
16
|
+
ProtocolHostRouter,
|
|
17
|
+
ProtocolIdentityAdapter,
|
|
18
|
+
)
|
|
19
|
+
from fleetmesh_core.wire.p2panda import P2PandaOperation, P2PandaOperationDict
|
|
20
|
+
from node.protocols.base import BaseProtocolPackage
|
|
21
|
+
from .identity import P2PandaIdentityAdapter
|
|
22
|
+
from .rung import P2PandaRung
|
|
23
|
+
from .store import P2PandaLogStore, P2PandaStoreError
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'P2PandaProtocolPackage',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class P2PandaProtocolPackage(BaseProtocolPackage):
|
|
33
|
+
"""The p2panda protocol suite for Fleetmesh."""
|
|
34
|
+
|
|
35
|
+
name: str = 'p2panda'
|
|
36
|
+
version: str = '0.4.0'
|
|
37
|
+
standards: tuple[str, ...] = (
|
|
38
|
+
'p2panda-v0.4.0',
|
|
39
|
+
'cbor-rfc8949',
|
|
40
|
+
'ed25519-chain',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
node_dir: Optional[str] = None,
|
|
46
|
+
keystore: Optional[Any] = None,
|
|
47
|
+
blob_store: Optional[Any] = None,
|
|
48
|
+
store: Optional[P2PandaLogStore] = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.node_dir = node_dir
|
|
52
|
+
self.keystore = keystore
|
|
53
|
+
self.blob_store = blob_store
|
|
54
|
+
self.config: Mapping[str, Any] = {}
|
|
55
|
+
self._enabled: bool = True
|
|
56
|
+
self._running: bool = False
|
|
57
|
+
self._messages_in: int = 0
|
|
58
|
+
self._messages_out: int = 0
|
|
59
|
+
|
|
60
|
+
# Identity Adapter
|
|
61
|
+
if keystore is not None:
|
|
62
|
+
self.identity: Optional[P2PandaIdentityAdapter] = P2PandaIdentityAdapter.from_keystore(keystore)
|
|
63
|
+
else:
|
|
64
|
+
self.identity = None
|
|
65
|
+
|
|
66
|
+
# Persistent Log Store
|
|
67
|
+
if store is not None:
|
|
68
|
+
self.store: P2PandaLogStore = store
|
|
69
|
+
elif node_dir:
|
|
70
|
+
import os
|
|
71
|
+
db_path = os.path.join(node_dir, "p2panda.db")
|
|
72
|
+
self.store = P2PandaLogStore(db_path)
|
|
73
|
+
else:
|
|
74
|
+
self.store = P2PandaLogStore(":memory:")
|
|
75
|
+
|
|
76
|
+
# Outbound Transport Rung
|
|
77
|
+
self.rung: P2PandaRung = P2PandaRung(
|
|
78
|
+
store=self.store,
|
|
79
|
+
identity=self.identity,
|
|
80
|
+
enabled=self._enabled,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self._host: Optional[ProtocolHostRouter] = None
|
|
84
|
+
|
|
85
|
+
def configure(self, config: Mapping[str, Any]) -> None:
|
|
86
|
+
self.config = dict(config)
|
|
87
|
+
self._enabled = bool(self.config.get('enabled', True))
|
|
88
|
+
self.rung.enabled = self._enabled
|
|
89
|
+
|
|
90
|
+
def identity_adapter(self, keystore: Any) -> Optional[ProtocolIdentityAdapter]:
|
|
91
|
+
if keystore is not None:
|
|
92
|
+
return P2PandaIdentityAdapter.from_keystore(keystore)
|
|
93
|
+
return self.identity
|
|
94
|
+
|
|
95
|
+
def mount_ingress(self, host: ProtocolHostRouter) -> None:
|
|
96
|
+
if not self._enabled:
|
|
97
|
+
return
|
|
98
|
+
self._host = host
|
|
99
|
+
|
|
100
|
+
from node.transport import ws
|
|
101
|
+
|
|
102
|
+
# 1. GET /p2panda/status
|
|
103
|
+
def _handle_status(conn: Any, req: Any) -> bool:
|
|
104
|
+
body_dict = {
|
|
105
|
+
'status': 'online' if self._running else 'configured',
|
|
106
|
+
'name': self.name,
|
|
107
|
+
'version': self.version,
|
|
108
|
+
'author': self.identity.author_id if self.identity else '',
|
|
109
|
+
'operations_count': self.store.count_operations(),
|
|
110
|
+
'authors_count': len(self.store.list_authors()),
|
|
111
|
+
}
|
|
112
|
+
body = json.dumps(body_dict, indent=2).encode('utf-8')
|
|
113
|
+
ws.http_response(conn, "200 OK", body, "application/json")
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
# 2. GET & POST /p2panda/operations
|
|
117
|
+
def _op_to_json_dict(op: P2PandaOperation) -> P2PandaOperationDict:
|
|
118
|
+
return op.to_dict(json_safe=True)
|
|
119
|
+
|
|
120
|
+
def _handle_operations(conn: Any, req: Any) -> bool:
|
|
121
|
+
method = getattr(req, 'method', 'GET').upper()
|
|
122
|
+
uri = getattr(req, 'path', '')
|
|
123
|
+
parsed = urllib.parse.urlparse(uri)
|
|
124
|
+
|
|
125
|
+
if method == 'GET':
|
|
126
|
+
qparams = urllib.parse.parse_qs(parsed.query)
|
|
127
|
+
author = qparams.get('author', [None])[0]
|
|
128
|
+
target_route = parsed.path
|
|
129
|
+
if not author and target_route.startswith('/p2panda/operations/'):
|
|
130
|
+
tail = target_route[len('/p2panda/operations/'):].strip('/')
|
|
131
|
+
if tail:
|
|
132
|
+
single_op = self.store.get_operation(tail)
|
|
133
|
+
if single_op is not None:
|
|
134
|
+
ws.http_response(
|
|
135
|
+
conn,
|
|
136
|
+
"200 OK",
|
|
137
|
+
json.dumps(_op_to_json_dict(single_op)).encode('utf-8'),
|
|
138
|
+
"application/json",
|
|
139
|
+
)
|
|
140
|
+
return True
|
|
141
|
+
author = tail
|
|
142
|
+
|
|
143
|
+
if author:
|
|
144
|
+
from_seq = int(qparams.get('from_seq', [0])[0])
|
|
145
|
+
limit = int(qparams.get('limit', [100])[0])
|
|
146
|
+
ops = self.store.get_log(author, from_seq=from_seq, limit=limit)
|
|
147
|
+
ops_data = [_op_to_json_dict(op) for op in ops]
|
|
148
|
+
body = json.dumps({'author': author, 'operations': ops_data}).encode('utf-8')
|
|
149
|
+
ws.http_response(conn, "200 OK", body, "application/json")
|
|
150
|
+
return True
|
|
151
|
+
else:
|
|
152
|
+
authors = self.store.list_authors()
|
|
153
|
+
body = json.dumps({'authors': authors, 'total_operations': self.store.count_operations()}).encode('utf-8')
|
|
154
|
+
ws.http_response(conn, "200 OK", body, "application/json")
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
elif method == 'POST':
|
|
158
|
+
try:
|
|
159
|
+
payload = ws.read_body(conn, req)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
ws.http_response(conn, "400 Bad Request", json.dumps({'error': str(e)}).encode('utf-8'))
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
headers = getattr(req, 'headers', {})
|
|
165
|
+
content_type = headers.get(b'content-type', b'').decode('utf-8', errors='ignore').lower()
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
if 'cbor' in content_type:
|
|
169
|
+
op = P2PandaOperation.from_cbor(payload)
|
|
170
|
+
else:
|
|
171
|
+
op_dict = json.loads(payload.decode('utf-8'))
|
|
172
|
+
op = P2PandaOperation.from_dict(op_dict)
|
|
173
|
+
|
|
174
|
+
inserted = self.store.append_operation(op, verify_chain=True)
|
|
175
|
+
self._messages_in += 1
|
|
176
|
+
status = "201 Created" if inserted else "200 OK"
|
|
177
|
+
ws.http_response(
|
|
178
|
+
conn,
|
|
179
|
+
status,
|
|
180
|
+
json.dumps({'op_id': op.id, 'inserted': inserted, 'seq_num': op.seq_num}).encode('utf-8'),
|
|
181
|
+
"application/json",
|
|
182
|
+
)
|
|
183
|
+
return True
|
|
184
|
+
except P2PandaStoreError as e:
|
|
185
|
+
ws.http_response(conn, "422 Unprocessable Entity", json.dumps({'error': str(e)}).encode('utf-8'))
|
|
186
|
+
return True
|
|
187
|
+
except Exception as e:
|
|
188
|
+
ws.http_response(conn, "400 Bad Request", json.dumps({'error': str(e)}).encode('utf-8'))
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
host.mount_route('/p2panda/status', _handle_status, methods=('GET',), owner=self.name)
|
|
194
|
+
host.mount_route('/p2panda/operations', _handle_operations, methods=('GET', 'POST'), owner=self.name)
|
|
195
|
+
|
|
196
|
+
def transport_rung(self, context: Any) -> Optional[Any]:
|
|
197
|
+
if not self._enabled:
|
|
198
|
+
return None
|
|
199
|
+
return self.rung
|
|
200
|
+
|
|
201
|
+
def compartments(self) -> tuple[type[Any], ...]:
|
|
202
|
+
return ()
|
|
203
|
+
|
|
204
|
+
def start(self) -> None:
|
|
205
|
+
if not self._enabled:
|
|
206
|
+
return
|
|
207
|
+
self._running = True
|
|
208
|
+
|
|
209
|
+
def stop(self) -> None:
|
|
210
|
+
self._running = False
|
|
211
|
+
if self.store is not None:
|
|
212
|
+
self.store.close()
|
|
213
|
+
|
|
214
|
+
def health(self) -> ProtocolHealth:
|
|
215
|
+
if not self._enabled:
|
|
216
|
+
return ProtocolHealth(
|
|
217
|
+
healthy=True,
|
|
218
|
+
status="disabled",
|
|
219
|
+
connections_active=0,
|
|
220
|
+
messages_in=0,
|
|
221
|
+
messages_out=0,
|
|
222
|
+
errors=(),
|
|
223
|
+
)
|
|
224
|
+
return ProtocolHealth(
|
|
225
|
+
healthy=True,
|
|
226
|
+
status="serving" if self._running else "configured",
|
|
227
|
+
connections_active=0,
|
|
228
|
+
messages_in=self._messages_in,
|
|
229
|
+
messages_out=self._messages_out,
|
|
230
|
+
errors=(),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def descriptor_capabilities(self) -> tuple[tuple[str, str], ...]:
|
|
234
|
+
if not self._enabled:
|
|
235
|
+
return ()
|
|
236
|
+
return (('c', 'p2panda'), ('c', 'cbor'))
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""p2panda transport rung for TransportRouter ladder.
|
|
2
|
+
|
|
3
|
+
Standard library only. Delivers messages wrapped as append-only p2panda operations
|
|
4
|
+
to local log and sync peers.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Callable, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from fleetmesh_core.wire.cbor import cbor_encode
|
|
12
|
+
from fleetmesh_core.wire.p2panda import P2PandaOperation
|
|
13
|
+
from node.transport.const import TransportError
|
|
14
|
+
from node.transport.ladder import FaultLog
|
|
15
|
+
from node.transport.rung import Rung, Verdict, endpoints_of
|
|
16
|
+
from .identity import P2PandaIdentityAdapter
|
|
17
|
+
from .store import P2PandaLogStore
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
'P2PandaRung',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class P2PandaRung(Rung):
|
|
27
|
+
"""Outbound transport rung delivering through p2panda append-only operation chains."""
|
|
28
|
+
|
|
29
|
+
name = 'p2panda'
|
|
30
|
+
rank = 15
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
store: Optional[P2PandaLogStore] = None,
|
|
35
|
+
identity: Optional[P2PandaIdentityAdapter] = None,
|
|
36
|
+
enabled: bool = True,
|
|
37
|
+
clock: Optional[Callable[[], int]] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.store = store
|
|
40
|
+
self.identity = identity
|
|
41
|
+
self.enabled = enabled
|
|
42
|
+
self.clock = clock
|
|
43
|
+
self.faults = FaultLog(rung=self.name, clock=clock)
|
|
44
|
+
|
|
45
|
+
def available(self) -> bool:
|
|
46
|
+
"""The local side of this rung is up and an identity is configured."""
|
|
47
|
+
return (
|
|
48
|
+
self.enabled
|
|
49
|
+
and self.identity is not None
|
|
50
|
+
and bool(self.identity.author_id)
|
|
51
|
+
and self.store is not None
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def target_of(self, desc: Optional[Mapping[str, Any]], theirs: Mapping[str, int]) -> Optional[str]:
|
|
55
|
+
"""Where this rung would reach the peer that published desc."""
|
|
56
|
+
if desc is not None:
|
|
57
|
+
_, where = endpoints_of(desc)
|
|
58
|
+
if 'p2panda' in where:
|
|
59
|
+
return where['p2panda']
|
|
60
|
+
for t in desc.get('tags', []):
|
|
61
|
+
if not t:
|
|
62
|
+
continue
|
|
63
|
+
if t[0] == 'c' and len(t) > 1 and t[1] == 'p2panda':
|
|
64
|
+
return ''
|
|
65
|
+
if 'p2panda' in theirs:
|
|
66
|
+
return ''
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def deliver(self, message: Mapping[str, Any], to_pubkey: str, target: str, anonymous: bool) -> Verdict:
|
|
70
|
+
"""Deliver message by signing and appending a p2panda operation to the log."""
|
|
71
|
+
if not self.available():
|
|
72
|
+
raise TransportError('p2panda rung unavailable or not configured with signing identity and store')
|
|
73
|
+
|
|
74
|
+
assert self.identity is not None
|
|
75
|
+
assert self.store is not None
|
|
76
|
+
|
|
77
|
+
if self.identity._private_key is None:
|
|
78
|
+
raise TransportError('p2panda identity does not have private signing key configured')
|
|
79
|
+
|
|
80
|
+
author_id = self.identity.author_id
|
|
81
|
+
try:
|
|
82
|
+
latest_seq = self.store.get_latest_seq(author_id)
|
|
83
|
+
next_seq = latest_seq + 1
|
|
84
|
+
backlink = None
|
|
85
|
+
if latest_seq >= 0:
|
|
86
|
+
latest_op = self.store.get_latest_operation(author_id)
|
|
87
|
+
if latest_op is not None:
|
|
88
|
+
backlink = latest_op.id
|
|
89
|
+
|
|
90
|
+
# Canonical CBOR serialization of the payload
|
|
91
|
+
payload_bytes = cbor_encode(dict(message), canonical=True)
|
|
92
|
+
|
|
93
|
+
extensions = {
|
|
94
|
+
'target': to_pubkey,
|
|
95
|
+
'anonymous': bool(anonymous),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
op = P2PandaOperation.create(
|
|
99
|
+
public_key=author_id,
|
|
100
|
+
body=payload_bytes,
|
|
101
|
+
seq_num=next_seq,
|
|
102
|
+
backlink=backlink,
|
|
103
|
+
private_key=self.identity._private_key,
|
|
104
|
+
extensions=extensions,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self.store.append_operation(op, verify_chain=True)
|
|
108
|
+
|
|
109
|
+
# If target specifies an HTTP/HTTPS endpoint, push operation
|
|
110
|
+
if target and (target.startswith('http://') or target.startswith('https://')):
|
|
111
|
+
self._push_http(target, op)
|
|
112
|
+
|
|
113
|
+
return {self.name: (True, f'delivered via operation {op.id[:16]} seq={op.seq_num}')}
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
self.faults.record(f'deliver failed: {e}', ident=to_pubkey[:16] if to_pubkey else None)
|
|
117
|
+
raise TransportError(f'p2panda delivery failed: {e}') from e
|
|
118
|
+
|
|
119
|
+
def _push_http(self, target_url: str, op: P2PandaOperation) -> None:
|
|
120
|
+
import urllib.error
|
|
121
|
+
import urllib.request
|
|
122
|
+
|
|
123
|
+
url = target_url
|
|
124
|
+
if not url.endswith('/p2panda/operations') and not url.endswith('/operations'):
|
|
125
|
+
url = f"{url.rstrip('/')}/p2panda/operations"
|
|
126
|
+
|
|
127
|
+
data = op.to_cbor()
|
|
128
|
+
req = urllib.request.Request(
|
|
129
|
+
url,
|
|
130
|
+
data=data,
|
|
131
|
+
headers={
|
|
132
|
+
'Content-Type': 'application/cbor',
|
|
133
|
+
'User-Agent': 'Fleetmesh-p2panda/0.4.0',
|
|
134
|
+
},
|
|
135
|
+
method='POST',
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(req, timeout=5.0) as resp:
|
|
139
|
+
if resp.status not in (200, 201, 202):
|
|
140
|
+
raise TransportError(f"HTTP upstream returned status {resp.status}")
|
|
141
|
+
except urllib.error.URLError as e:
|
|
142
|
+
raise TransportError(f"Failed to push operation to {url}: {e}") from e
|
|
143
|
+
|
|
144
|
+
def health(self) -> Optional[Mapping[str, Any]]:
|
|
145
|
+
return {
|
|
146
|
+
'available': self.available(),
|
|
147
|
+
'author_id': self.identity.author_id if self.identity else '',
|
|
148
|
+
'ops_stored': self.store.count_operations() if self.store else 0,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def fault_log(self) -> Optional[FaultLog]:
|
|
152
|
+
return self.faults
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""p2panda SQLite persistent operation log store.
|
|
2
|
+
|
|
3
|
+
Standard library only. Manages persistence and chain verification for:
|
|
4
|
+
- Append-only cryptographic operation logs.
|
|
5
|
+
- Monotonic sequence numbers and backlink verification.
|
|
6
|
+
- Author log indexing and query pagination.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from fleetmesh_core.conformance.errors import FleetmeshError
|
|
15
|
+
from fleetmesh_core.wire.p2panda import P2PandaHeader, P2PandaOperation
|
|
16
|
+
from node.compartment.schema import Column, Index, Table
|
|
17
|
+
from node.storage import SqliteStore, store_errors
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
'P2PandaStoreError',
|
|
21
|
+
'P2PandaLogStore',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class P2PandaStoreError(FleetmeshError):
|
|
26
|
+
"""Base error for p2panda storage operations."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
from node.protocols.isotime import now_iso as _now_iso # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_COLS = ('op_id, author, seq_num, backlink, previous_seq_num, payload_size, payload_hash, '
|
|
34
|
+
'timestamp, signature, version, body, extensions_json, created_at')
|
|
35
|
+
"""The column order `_row_to_op` reads positionally.
|
|
36
|
+
|
|
37
|
+
The selects said `SELECT *` and the rows were indexed by name through a
|
|
38
|
+
`sqlite3.Row` factory. The base hands back plain tuples, so the order is
|
|
39
|
+
written down once here and every select names it - which is sturdier anyway:
|
|
40
|
+
`SELECT *` plus positional indexing would break on the next column added.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class P2PandaLogStore(SqliteStore):
|
|
45
|
+
"""Append-only p2panda operation logs, one row per operation.
|
|
46
|
+
|
|
47
|
+
A `SqliteStore` rather than a hand-rolled connection: the base owns the
|
|
48
|
+
connection, the lock, the per-store schema version, transaction control and
|
|
49
|
+
the dialect that rewrites placeholders and upserts for another backend.
|
|
50
|
+
This store opened its own connection per call and declared its schema as a
|
|
51
|
+
SQL string, so it was SQLite-only and invisible to the locator - see
|
|
52
|
+
docs/storage-backends.md.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
SCHEMA = (
|
|
56
|
+
Table('p2panda_operations', (
|
|
57
|
+
Column('op_id', 'TEXT', primary_key=True),
|
|
58
|
+
Column('author', 'TEXT', not_null=True),
|
|
59
|
+
Column('seq_num', 'INTEGER', not_null=True),
|
|
60
|
+
Column('backlink', 'TEXT'),
|
|
61
|
+
Column('previous_seq_num', 'INTEGER'),
|
|
62
|
+
Column('payload_size', 'INTEGER', not_null=True),
|
|
63
|
+
Column('payload_hash', 'TEXT', not_null=True),
|
|
64
|
+
Column('timestamp', 'INTEGER', not_null=True),
|
|
65
|
+
Column('signature', 'TEXT', not_null=True),
|
|
66
|
+
Column('version', 'INTEGER', not_null=True, default='1'),
|
|
67
|
+
Column('body', 'BLOB'),
|
|
68
|
+
Column('extensions_json', 'TEXT'),
|
|
69
|
+
Column('created_at', 'TEXT', not_null=True),
|
|
70
|
+
), indices=(
|
|
71
|
+
# The log's real constraint: one operation per (author, seq_num).
|
|
72
|
+
# It was a table-level UNIQUE in the SQL; a unique index is the
|
|
73
|
+
# typed layer's way of saying it, and it also serves the lookup
|
|
74
|
+
# the old non-unique index on the same columns existed for.
|
|
75
|
+
Index('idx_p2p_ops_author_seq', ('author', 'seq_num'), unique=True),
|
|
76
|
+
Index('idx_p2p_ops_backlink', ('backlink',)),
|
|
77
|
+
)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def __init__(self, db_path: str = ":memory:"):
|
|
81
|
+
if db_path != ":memory:":
|
|
82
|
+
os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
|
|
83
|
+
super().__init__(db_path)
|
|
84
|
+
|
|
85
|
+
def _row_to_op(self, row: Sequence[Any]) -> P2PandaOperation:
|
|
86
|
+
ext = None
|
|
87
|
+
if row[11]:
|
|
88
|
+
try:
|
|
89
|
+
ext = json.loads(row[11])
|
|
90
|
+
except Exception:
|
|
91
|
+
ext = None
|
|
92
|
+
|
|
93
|
+
header = P2PandaHeader(
|
|
94
|
+
version=int(row[9]),
|
|
95
|
+
public_key=str(row[1]),
|
|
96
|
+
signature=str(row[8]) if row[8] else None,
|
|
97
|
+
payload_size=int(row[5]),
|
|
98
|
+
payload_hash=str(row[6]),
|
|
99
|
+
timestamp=int(row[7]),
|
|
100
|
+
seq_num=int(row[2]),
|
|
101
|
+
backlink=str(row[3]) if row[3] else None,
|
|
102
|
+
previous_seq_num=int(row[4]) if row[4] is not None else None,
|
|
103
|
+
extensions=ext,
|
|
104
|
+
)
|
|
105
|
+
body = bytes(row[10]) if row[10] is not None else None
|
|
106
|
+
return P2PandaOperation(header=header, body=body)
|
|
107
|
+
|
|
108
|
+
def append_operation(self, op: P2PandaOperation, verify_chain: bool = True) -> bool:
|
|
109
|
+
"""Append an operation to the author's log.
|
|
110
|
+
|
|
111
|
+
Validates cryptographic integrity and chain monotonicity if verify_chain is True.
|
|
112
|
+
Returns True if newly inserted, False if idempotent duplicate.
|
|
113
|
+
"""
|
|
114
|
+
if verify_chain:
|
|
115
|
+
if not op.verify_signature():
|
|
116
|
+
raise P2PandaStoreError(f"Signature verification failed for operation {op.id}")
|
|
117
|
+
if not op.verify_payload():
|
|
118
|
+
raise P2PandaStoreError(f"Payload integrity verification failed for operation {op.id}")
|
|
119
|
+
|
|
120
|
+
author = op.author.lower()
|
|
121
|
+
op_id = op.id.lower()
|
|
122
|
+
|
|
123
|
+
with self._lock:
|
|
124
|
+
try:
|
|
125
|
+
with self.transaction() as cur:
|
|
126
|
+
|
|
127
|
+
# Check if identical op_id already exists
|
|
128
|
+
cur.execute("SELECT op_id FROM p2panda_operations WHERE op_id = ?", (op_id,))
|
|
129
|
+
if cur.fetchone() is not None:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
if verify_chain:
|
|
133
|
+
# Query latest operation for author
|
|
134
|
+
cur.execute(
|
|
135
|
+
"SELECT op_id, seq_num, timestamp FROM p2panda_operations WHERE author = ? ORDER BY seq_num DESC LIMIT 1",
|
|
136
|
+
(author,),
|
|
137
|
+
)
|
|
138
|
+
latest_row = cur.fetchone()
|
|
139
|
+
|
|
140
|
+
if latest_row is None:
|
|
141
|
+
# Genesis operation for author
|
|
142
|
+
if op.seq_num != 0:
|
|
143
|
+
raise P2PandaStoreError(
|
|
144
|
+
f"Genesis operation for author {author} must have seq_num 0, got {op.seq_num}"
|
|
145
|
+
)
|
|
146
|
+
if op.backlink is not None and op.backlink != '':
|
|
147
|
+
raise P2PandaStoreError(
|
|
148
|
+
f"Genesis operation for author {author} must have null backlink, got {op.backlink}"
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
latest_op_id = str(latest_row[0]).lower()
|
|
152
|
+
latest_seq = int(latest_row[1])
|
|
153
|
+
latest_ts = int(latest_row[2])
|
|
154
|
+
|
|
155
|
+
expected_seq = latest_seq + 1
|
|
156
|
+
if op.seq_num != expected_seq:
|
|
157
|
+
if op.seq_num <= latest_seq:
|
|
158
|
+
raise P2PandaStoreError(
|
|
159
|
+
f"Fork or duplicate sequence conflict at seq_num={op.seq_num} for author {author}"
|
|
160
|
+
)
|
|
161
|
+
raise P2PandaStoreError(
|
|
162
|
+
f"Missing predecessor operation: author {author} is at seq_num {latest_seq}, got {op.seq_num}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if op.backlink is None or op.backlink.lower() != latest_op_id:
|
|
166
|
+
raise P2PandaStoreError(
|
|
167
|
+
f"Backlink mismatch: expected {latest_op_id}, got {op.backlink}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if op.header.timestamp < latest_ts:
|
|
171
|
+
raise P2PandaStoreError(
|
|
172
|
+
f"Operation timestamp ({op.header.timestamp}) is earlier than predecessor ({latest_ts})"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
ext_json = json.dumps(op.header.extensions) if op.header.extensions is not None else None
|
|
176
|
+
|
|
177
|
+
cur.execute(
|
|
178
|
+
"""
|
|
179
|
+
INSERT INTO p2panda_operations (
|
|
180
|
+
op_id, author, seq_num, backlink, previous_seq_num,
|
|
181
|
+
payload_size, payload_hash, timestamp, signature,
|
|
182
|
+
version, body, extensions_json, created_at
|
|
183
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
184
|
+
""",
|
|
185
|
+
(
|
|
186
|
+
op_id,
|
|
187
|
+
author,
|
|
188
|
+
op.seq_num,
|
|
189
|
+
op.backlink.lower() if op.backlink else None,
|
|
190
|
+
op.header.previous_seq_num,
|
|
191
|
+
op.header.payload_size,
|
|
192
|
+
op.header.payload_hash.lower(),
|
|
193
|
+
op.header.timestamp,
|
|
194
|
+
op.header.signature.lower() if op.header.signature else '',
|
|
195
|
+
op.header.version,
|
|
196
|
+
op.body,
|
|
197
|
+
ext_json,
|
|
198
|
+
_now_iso(),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
return True
|
|
202
|
+
except store_errors() as e:
|
|
203
|
+
# A sequence collision or the author-seq uniqueness violation.
|
|
204
|
+
# `store_errors()` rather than `sqlite3.IntegrityError`: the
|
|
205
|
+
# narrow catch stays narrow and stays correct for a second
|
|
206
|
+
# driver with its own exception hierarchy.
|
|
207
|
+
raise P2PandaStoreError(f"Database integrity conflict: {e}") from e
|
|
208
|
+
|
|
209
|
+
def get_operation(self, op_id: str) -> Optional[P2PandaOperation]:
|
|
210
|
+
"""Fetch an operation by its op_id."""
|
|
211
|
+
clean_id = op_id.lower().strip()
|
|
212
|
+
with self._lock:
|
|
213
|
+
row = self._one("SELECT " + _COLS + " FROM p2panda_operations WHERE op_id = ?", (clean_id,))
|
|
214
|
+
return self._row_to_op(row) if row else None
|
|
215
|
+
|
|
216
|
+
def get_operation_by_seq(self, author: str, seq_num: int) -> Optional[P2PandaOperation]:
|
|
217
|
+
"""Fetch an operation by author public key and sequence number."""
|
|
218
|
+
clean_author = author.lower().strip()
|
|
219
|
+
with self._lock:
|
|
220
|
+
row = self._one(
|
|
221
|
+
"SELECT " + _COLS + " FROM p2panda_operations WHERE author = ? AND seq_num = ?",
|
|
222
|
+
(clean_author, int(seq_num)),
|
|
223
|
+
)
|
|
224
|
+
return self._row_to_op(row) if row else None
|
|
225
|
+
|
|
226
|
+
def get_latest_operation(self, author: str) -> Optional[P2PandaOperation]:
|
|
227
|
+
"""Fetch the latest operation in an author's log."""
|
|
228
|
+
clean_author = author.lower().strip()
|
|
229
|
+
with self._lock:
|
|
230
|
+
row = self._one(
|
|
231
|
+
"SELECT " + _COLS + " FROM p2panda_operations "
|
|
232
|
+
"WHERE author = ? ORDER BY seq_num DESC LIMIT 1",
|
|
233
|
+
(clean_author,),
|
|
234
|
+
)
|
|
235
|
+
return self._row_to_op(row) if row else None
|
|
236
|
+
|
|
237
|
+
def get_latest_seq(self, author: str) -> int:
|
|
238
|
+
"""Return the highest seq_num for author, or -1 if no operations exist."""
|
|
239
|
+
clean_author = author.lower().strip()
|
|
240
|
+
with self._lock:
|
|
241
|
+
row = self._one(
|
|
242
|
+
"SELECT MAX(seq_num) FROM p2panda_operations WHERE author = ?",
|
|
243
|
+
(clean_author,),
|
|
244
|
+
)
|
|
245
|
+
if row and row[0] is not None:
|
|
246
|
+
return int(row[0])
|
|
247
|
+
return -1
|
|
248
|
+
|
|
249
|
+
def get_log(self, author: str, from_seq: int = 0, limit: int = 100) -> list[P2PandaOperation]:
|
|
250
|
+
"""Return ordered operations for an author from from_seq onwards."""
|
|
251
|
+
clean_author = author.lower().strip()
|
|
252
|
+
with self._lock:
|
|
253
|
+
rows = self._all(
|
|
254
|
+
"SELECT " + _COLS + " FROM p2panda_operations "
|
|
255
|
+
"WHERE author = ? AND seq_num >= ? ORDER BY seq_num ASC LIMIT ?",
|
|
256
|
+
(clean_author, int(from_seq), max(1, min(int(limit), 100_000))),
|
|
257
|
+
)
|
|
258
|
+
return [self._row_to_op(r) for r in rows]
|
|
259
|
+
|
|
260
|
+
def count_operations(self) -> int:
|
|
261
|
+
"""Return total count of operations across all authors."""
|
|
262
|
+
with self._lock:
|
|
263
|
+
row = self._one("SELECT COUNT(*) AS total FROM p2panda_operations")
|
|
264
|
+
return int(row[0]) if row else 0
|
|
265
|
+
|
|
266
|
+
def list_authors(self) -> list[str]:
|
|
267
|
+
"""Return a sorted list of unique authors with operations in store."""
|
|
268
|
+
with self._lock:
|
|
269
|
+
return [str(r[0]) for r in
|
|
270
|
+
self._all("SELECT DISTINCT author FROM p2panda_operations ORDER BY author ASC")]
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
super().close()
|