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,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
|
+
|