rocket-engine 0.0.2b1__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.
- rocket_engine/__init__.py +8 -0
- rocket_engine/__main__.py +12 -0
- rocket_engine/adapters/README.md +54 -0
- rocket_engine/adapters/__init__.py +7 -0
- rocket_engine/adapters/api.py +39 -0
- rocket_engine/adapters/base.py +46 -0
- rocket_engine/adapters/cace/__init__.py +163 -0
- rocket_engine/adapters/circulax/README.md +10 -0
- rocket_engine/adapters/circulax/__init__.py +112 -0
- rocket_engine/adapters/circulax/_driver.py +190 -0
- rocket_engine/adapters/circulax/adapter.py +538 -0
- rocket_engine/adapters/circulax/results.py +291 -0
- rocket_engine/adapters/cli/README.md +34 -0
- rocket_engine/adapters/cli/__init__.py +305 -0
- rocket_engine/adapters/connectivity/__init__.py +196 -0
- rocket_engine/adapters/connectivity/results.py +214 -0
- rocket_engine/adapters/cosim/README.md +9 -0
- rocket_engine/adapters/cosim/__init__.py +1390 -0
- rocket_engine/adapters/cosim/results.py +214 -0
- rocket_engine/adapters/devsim/__init__.py +171 -0
- rocket_engine/adapters/digital_flow/README.md +9 -0
- rocket_engine/adapters/digital_flow/__init__.py +1121 -0
- rocket_engine/adapters/digital_flow/results.py +227 -0
- rocket_engine/adapters/digital_flow/state.py +462 -0
- rocket_engine/adapters/drc/README.klayout.md +7 -0
- rocket_engine/adapters/drc/README.md +7 -0
- rocket_engine/adapters/drc/__init__.py +1 -0
- rocket_engine/adapters/drc/klayout.py +952 -0
- rocket_engine/adapters/drc/magic.py +287 -0
- rocket_engine/adapters/drc/results.py +184 -0
- rocket_engine/adapters/drc/results_kl.py +188 -0
- rocket_engine/adapters/elmer/__init__.py +163 -0
- rocket_engine/adapters/envelope.py +120 -0
- rocket_engine/adapters/equivalence/__init__.py +156 -0
- rocket_engine/adapters/errors.py +181 -0
- rocket_engine/adapters/extract_magic/README.md +15 -0
- rocket_engine/adapters/extract_magic/__init__.py +425 -0
- rocket_engine/adapters/extract_magic/results.py +209 -0
- rocket_engine/adapters/formal_sby/README.md +7 -0
- rocket_engine/adapters/formal_sby/__init__.py +773 -0
- rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
- rocket_engine/adapters/formal_sby/results.py +185 -0
- rocket_engine/adapters/gdsfactory/__init__.py +194 -0
- rocket_engine/adapters/gdsfactory/_driver.py +459 -0
- rocket_engine/adapters/gdsfactory/adapter.py +609 -0
- rocket_engine/adapters/gdsfactory/results.py +181 -0
- rocket_engine/adapters/hdl_sim/README.md +7 -0
- rocket_engine/adapters/hdl_sim/__init__.py +369 -0
- rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
- rocket_engine/adapters/hdl_sim/results.py +178 -0
- rocket_engine/adapters/hdl_sim/verilator.py +167 -0
- rocket_engine/adapters/http/README.md +15 -0
- rocket_engine/adapters/http/__init__.py +458 -0
- rocket_engine/adapters/http/events.py +63 -0
- rocket_engine/adapters/ipc/README.md +9 -0
- rocket_engine/adapters/ipc/__init__.py +111 -0
- rocket_engine/adapters/lvs_netgen/README.md +20 -0
- rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
- rocket_engine/adapters/lvs_netgen/results.py +168 -0
- rocket_engine/adapters/mcp/README.md +19 -0
- rocket_engine/adapters/mcp/__init__.py +85 -0
- rocket_engine/adapters/mcp/api-schema.json +6107 -0
- rocket_engine/adapters/mcp/pull.py +246 -0
- rocket_engine/adapters/mcp/server.py +794 -0
- rocket_engine/adapters/meep/__init__.py +167 -0
- rocket_engine/adapters/meow/README.md +7 -0
- rocket_engine/adapters/meow/__init__.py +190 -0
- rocket_engine/adapters/meow/_driver.py +227 -0
- rocket_engine/adapters/meow/_envelope.py +108 -0
- rocket_engine/adapters/meow/adapter.py +491 -0
- rocket_engine/adapters/meow/results.py +218 -0
- rocket_engine/adapters/mutation/__init__.py +192 -0
- rocket_engine/adapters/ngspice/README.md +28 -0
- rocket_engine/adapters/ngspice/__init__.py +627 -0
- rocket_engine/adapters/ngspice/results.py +215 -0
- rocket_engine/adapters/postlayout/README.md +28 -0
- rocket_engine/adapters/postlayout/__init__.py +17 -0
- rocket_engine/adapters/postlayout/adapter.py +1062 -0
- rocket_engine/adapters/postlayout/results.py +330 -0
- rocket_engine/adapters/postlayout/translate.py +71 -0
- rocket_engine/adapters/profile.py +220 -0
- rocket_engine/adapters/sax/README.md +23 -0
- rocket_engine/adapters/sax/__init__.py +516 -0
- rocket_engine/adapters/sax/_driver.py +148 -0
- rocket_engine/adapters/sax/results.py +291 -0
- rocket_engine/adapters/stdio_console.py +1151 -0
- rocket_engine/adapters/syntax/README.md +5 -0
- rocket_engine/adapters/syntax/__init__.py +366 -0
- rocket_engine/adapters/syntax/results.py +181 -0
- rocket_engine/adapters/synth_yosys/README.md +7 -0
- rocket_engine/adapters/synth_yosys/__init__.py +655 -0
- rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
- rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
- rocket_engine/adapters/synth_yosys/results.py +173 -0
- rocket_engine/adapters/timing_sta/README.md +9 -0
- rocket_engine/adapters/timing_sta/__init__.py +726 -0
- rocket_engine/adapters/view_sync/__init__.py +185 -0
- rocket_engine/adapters/view_sync/results.py +189 -0
- rocket_engine/adapters/vlsir/__init__.py +142 -0
- rocket_engine/adapters/xschem/__init__.py +440 -0
- rocket_engine/adapters/xschem/deck.py +266 -0
- rocket_engine/clients/__init__.py +5 -0
- rocket_engine/clients/runtime.py +255 -0
- rocket_engine/composition.py +2279 -0
- rocket_engine/execution/README.md +30 -0
- rocket_engine/execution/__init__.py +150 -0
- rocket_engine/execution/apig/__init__.py +1 -0
- rocket_engine/execution/apig/pool.py +463 -0
- rocket_engine/execution/apig/submit.py +124 -0
- rocket_engine/execution/block_runs_scope.py +479 -0
- rocket_engine/execution/coalesce.py +414 -0
- rocket_engine/execution/console/__init__.py +1 -0
- rocket_engine/execution/console/apply_session.py +318 -0
- rocket_engine/execution/console/lifecycle.py +265 -0
- rocket_engine/execution/dispatch.py +205 -0
- rocket_engine/execution/errors.py +31 -0
- rocket_engine/execution/ports.py +797 -0
- rocket_engine/execution/pyexec/__init__.py +1 -0
- rocket_engine/execution/pyexec/apply_context.py +547 -0
- rocket_engine/execution/pyexec/approvals.py +173 -0
- rocket_engine/execution/pyexec/runner.py +404 -0
- rocket_engine/execution/pyexec/support.py +464 -0
- rocket_engine/execution/python_exec.py +897 -0
- rocket_engine/execution/queue.py +85 -0
- rocket_engine/execution/replay_safe.py +601 -0
- rocket_engine/execution/routing/__init__.py +1 -0
- rocket_engine/execution/routing/calls.py +364 -0
- rocket_engine/execution/routing/namespace.py +785 -0
- rocket_engine/execution/routing/ports.py +102 -0
- rocket_engine/execution/routing/tools.py +346 -0
- rocket_engine/execution/runner.py +311 -0
- rocket_engine/execution/runtime.py +1149 -0
- rocket_engine/execution/service.py +279 -0
- rocket_engine/execution/worker.py +464 -0
- rocket_engine/interfaces/README.md +26 -0
- rocket_engine/interfaces/__init__.py +58 -0
- rocket_engine/interfaces/apig_assets.py +407 -0
- rocket_engine/interfaces/celllink.py +461 -0
- rocket_engine/interfaces/documents.py +92 -0
- rocket_engine/interfaces/envelopes.py +131 -0
- rocket_engine/interfaces/errors.py +16 -0
- rocket_engine/interfaces/layout.py +410 -0
- rocket_engine/interfaces/layout_schema.yaml +278 -0
- rocket_engine/interfaces/paths.py +223 -0
- rocket_engine/interfaces/pdks.py +550 -0
- rocket_engine/interfaces/ports.py +72 -0
- rocket_engine/interfaces/references.py +115 -0
- rocket_engine/interfaces/refs.py +85 -0
- rocket_engine/interfaces/runparams.py +304 -0
- rocket_engine/interfaces/setup.py +552 -0
- rocket_engine/interfaces/setup_catalogue.yaml +243 -0
- rocket_engine/interfaces/setup_probes.py +66 -0
- rocket_engine/interfaces/vocabulary.py +58 -0
- rocket_engine/interfaces/yaml_dump.py +31 -0
- rocket_engine/library/README.md +22 -0
- rocket_engine/library/__init__.py +13 -0
- rocket_engine/library/apig_catalog.py +371 -0
- rocket_engine/library/catalog.py +185 -0
- rocket_engine/library/errors.py +16 -0
- rocket_engine/mutate/__init__.py +776 -0
- rocket_engine/mutate/operators.py +700 -0
- rocket_engine/ops/__init__.py +1 -0
- rocket_engine/ops/deferred.py +279 -0
- rocket_engine/ops/editors.py +404 -0
- rocket_engine/ops/evidence.py +194 -0
- rocket_engine/ops/gates.py +262 -0
- rocket_engine/ops/ports.py +76 -0
- rocket_engine/ops/queries.py +417 -0
- rocket_engine/ops/runs.py +553 -0
- rocket_engine/ops/submit.py +614 -0
- rocket_engine/project/README.md +27 -0
- rocket_engine/project/__init__.py +6 -0
- rocket_engine/project/canvas/__init__.py +55 -0
- rocket_engine/project/canvas/compile.py +109 -0
- rocket_engine/project/canvas/integrate.py +48 -0
- rocket_engine/project/canvas/schema.py +241 -0
- rocket_engine/project/canvas/templates.py +46 -0
- rocket_engine/project/canvas/validate.py +603 -0
- rocket_engine/project/edit/__init__.py +5 -0
- rocket_engine/project/edit/cas.py +87 -0
- rocket_engine/project/edit/transactions.py +282 -0
- rocket_engine/project/errors.py +16 -0
- rocket_engine/project/frontend13.py +685 -0
- rocket_engine/project/geometry/__init__.py +23 -0
- rocket_engine/project/geometry/ops.py +451 -0
- rocket_engine/project/geometry/snap.py +103 -0
- rocket_engine/project/ip/__init__.py +1 -0
- rocket_engine/project/ip/blocks.py +195 -0
- rocket_engine/project/ip/entry.schema.json +78 -0
- rocket_engine/project/ip/lifecycle.py +402 -0
- rocket_engine/project/ip/verbs.py +123 -0
- rocket_engine/project/layers/__init__.py +6 -0
- rocket_engine/project/layers/bindings.py +115 -0
- rocket_engine/project/layers/catalog.py +228 -0
- rocket_engine/project/layers/presets.py +43 -0
- rocket_engine/project/ledger/__init__.py +1 -0
- rocket_engine/project/ledger/docs.py +167 -0
- rocket_engine/project/ledger/entities.py +176 -0
- rocket_engine/project/ledger/events.py +29 -0
- rocket_engine/project/ledger/runs.py +257 -0
- rocket_engine/project/ledger/shared.py +174 -0
- rocket_engine/project/ledger/snapshots.py +385 -0
- rocket_engine/project/ledger/wires.py +552 -0
- rocket_engine/project/process/__init__.py +14 -0
- rocket_engine/project/process/model.py +193 -0
- rocket_engine/project/process/recipes.py +121 -0
- rocket_engine/project/schematic/__init__.py +1 -0
- rocket_engine/project/schematic/hierarchy.py +312 -0
- rocket_engine/project/schematic/instances.py +393 -0
- rocket_engine/project/schematic/sheets.py +198 -0
- rocket_engine/project/service.py +2941 -0
- rocket_engine/project/symbols/__init__.py +1 -0
- rocket_engine/project/symbols/cells.py +324 -0
- rocket_engine/project/symbols/linkage.py +356 -0
- rocket_engine/project/symbols/symbols.py +352 -0
- rocket_engine/project/templates/__init__.py +303 -0
- rocket_engine/project/templates/errors.py +25 -0
- rocket_engine/project/testbench/__init__.py +49 -0
- rocket_engine/project/testbench/blocks.py +619 -0
- rocket_engine/project/testbench/templates.py +134 -0
- rocket_engine/stores/README.md +12 -0
- rocket_engine/stores/__init__.py +6 -0
- rocket_engine/stores/ecs.py +1508 -0
- rocket_engine/stores/layers.py +1023 -0
- rocket_engine/stores/settings.py +1188 -0
- rocket_engine/stores/wireplace.py +1324 -0
- rocket_engine/workflows/README.md +18 -0
- rocket_engine/workflows/__init__.py +53 -0
- rocket_engine/workflows/capability_plan.py +102 -0
- rocket_engine/workflows/completion.py +228 -0
- rocket_engine/workflows/connectgraph.py +734 -0
- rocket_engine/workflows/connectgraph_emit.py +336 -0
- rocket_engine/workflows/gates.py +144 -0
- rocket_engine/workflows/photonic_wire.py +76 -0
- rocket_engine/workflows/stages.py +324 -0
- rocket_engine/workflows/viewsync.py +399 -0
- rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
- rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
- rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
- rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
- rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
"""Engine entity store (ENGINE-ECS): entities + components over docs/ledger.
|
|
2
|
+
|
|
3
|
+
One identity per thing. Every new engine surface invented its own —
|
|
4
|
+
doc paths for sources, refs for blocks, names for nets — so this
|
|
5
|
+
module ends it: engine-minted entity IDs, generic component CRUD
|
|
6
|
+
instead of N bespoke endpoints, uniform ``component_changed``
|
|
7
|
+
events instead of per-feature notification plumbing, and filter
|
|
8
|
+
queries instead of one-off getters. Wires terminate on stable pin
|
|
9
|
+
entities only this store can mint.
|
|
10
|
+
|
|
11
|
+
Storage projection (documented, no second identity system): an
|
|
12
|
+
entity with id ``<id>`` lives at exactly one doc path,
|
|
13
|
+
``entities/<id>.json``, holding ``{"entity", "kind", "components"}``
|
|
14
|
+
as JSON. The path derives from the id alone — ``entity_doc_path``
|
|
15
|
+
and ``entity_from_doc_path`` are inverses — so CRUD, events, and
|
|
16
|
+
queries cannot drift: ``read_doc("entities/<id>.json")`` shows the
|
|
17
|
+
same bytes ``get_component`` serves, and ``list_docs`` lists every
|
|
18
|
+
entity. Canonical addressing is ``entity.component`` (one dot; ids
|
|
19
|
+
and component names never contain dots, so parsing is unambiguous).
|
|
20
|
+
|
|
21
|
+
Engine-provisioned IDs only: ``create_entity`` mints ``ent-<12hex>``
|
|
22
|
+
ids and takes no id argument — callers can never supply, guess, or
|
|
23
|
+
collide with an id. Backfill derives read-only ``doc-<12hex>`` ids
|
|
24
|
+
(sha1 of the doc path) for current project docs, so existing files
|
|
25
|
+
appear in the world snapshot and ``doc`` filters without a single
|
|
26
|
+
write: backfill never advances the revision.
|
|
27
|
+
|
|
28
|
+
Events: ``create_entity`` appends ``entity_created``,
|
|
29
|
+
``set/attach/detach_component`` append ``component_changed`` with
|
|
30
|
+
``{entity, component, revision}`` (detaches add ``detached: True``),
|
|
31
|
+
``delete_entity`` appends ``entity_deleted``. Every mutation gates
|
|
32
|
+
on ``expected_revision``, honors idempotent ``request_id`` replay,
|
|
33
|
+
advances the revision once, and returns the CRUD approval-UI fields
|
|
34
|
+
(actor, diff_summary, revision_transition, rewind_handle).
|
|
35
|
+
|
|
36
|
+
World snapshot is miniplex-shaped: ``{"entities": [{"id", "kind",
|
|
37
|
+
"components"}]}`` sorted by id maps one-to-one onto miniplex
|
|
38
|
+
entities (``{id, ...components}``), so mirrors load it directly.
|
|
39
|
+
Filter queries ``components_of`` / ``entities_with`` replace
|
|
40
|
+
one-off getters (nets-of-block, pins-of-net, open-problems); the
|
|
41
|
+
``entities_with`` predicate is an exact-match mapping over
|
|
42
|
+
top-level component fields.
|
|
43
|
+
|
|
44
|
+
``EntityStore`` is the in-memory reference implementation the unit
|
|
45
|
+
tests run standalone; the wiring patch teaches ``ProjectService``
|
|
46
|
+
the same verbs over the real doc store + durable ledger, reusing
|
|
47
|
+
these pure helpers for validation, addressing, encoding, and
|
|
48
|
+
backfill. Stdlib + ``json`` only (leaf module, no new deps, no
|
|
49
|
+
package-internal imports — no import cycle).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import hashlib
|
|
53
|
+
import json
|
|
54
|
+
import re
|
|
55
|
+
import uuid
|
|
56
|
+
|
|
57
|
+
#: Doc prefix owning every entity record. Only ``ledger.yaml``,
|
|
58
|
+
#: ``events.yaml``, ``runs/``, and ``snapshots/`` are engine-owned
|
|
59
|
+
#: elsewhere, so ``entities/`` docs are plain documents: existing
|
|
60
|
+
#: ``list_docs`` / ``read_doc`` / ``write_doc`` keep working on them.
|
|
61
|
+
ENTITY_DOC_PREFIX = "entities/"
|
|
62
|
+
|
|
63
|
+
#: Entity records are JSON text (not YAML): one doc, one entity.
|
|
64
|
+
ENTITY_DOC_SUFFIX = ".json"
|
|
65
|
+
|
|
66
|
+
#: Engine ids (``ent-``) and backfill ids (``doc-``) share this shape:
|
|
67
|
+
#: lowercase, no dots — dots would make ``entity.component``
|
|
68
|
+
#: addresses ambiguous.
|
|
69
|
+
ENTITY_ID_PATTERN = r"^[a-z0-9][a-z0-9_-]*$"
|
|
70
|
+
_ENTITY_ID_RE = re.compile(ENTITY_ID_PATTERN)
|
|
71
|
+
|
|
72
|
+
#: Component names allow uppercase (``CellLink``-style) but no dots,
|
|
73
|
+
#: for the same addressing reason as entity ids.
|
|
74
|
+
COMPONENT_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]*$"
|
|
75
|
+
_COMPONENT_RE = re.compile(COMPONENT_PATTERN)
|
|
76
|
+
|
|
77
|
+
_KIND_RE = re.compile(r"^[A-Za-z0-9_.-]*$")
|
|
78
|
+
_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
|
|
79
|
+
_REVISION_RE = re.compile(r"^(.*?)(\d+)$")
|
|
80
|
+
|
|
81
|
+
#: Component name every backfilled doc carries (``{"path", "bytes"}``).
|
|
82
|
+
BACKFILL_COMPONENT = "doc"
|
|
83
|
+
|
|
84
|
+
#: Kind stamp on backfilled world-snapshot entries.
|
|
85
|
+
BACKFILL_KIND = "backfilled-doc"
|
|
86
|
+
|
|
87
|
+
#: One component may not exceed the doc-store write limit on its own.
|
|
88
|
+
MAX_COMPONENT_BYTES = 1_000_000
|
|
89
|
+
|
|
90
|
+
#: Component JSON nests no deeper than validated documents may.
|
|
91
|
+
MAX_NESTING_DEPTH = 100
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"BACKFILL_COMPONENT",
|
|
95
|
+
"BACKFILL_KIND",
|
|
96
|
+
"ENTITY_DOC_PREFIX",
|
|
97
|
+
"ENTITY_DOC_SUFFIX",
|
|
98
|
+
"ENTITY_ID_PATTERN",
|
|
99
|
+
"COMPONENT_PATTERN",
|
|
100
|
+
"MAX_COMPONENT_BYTES",
|
|
101
|
+
"MAX_NESTING_DEPTH",
|
|
102
|
+
"EcsError",
|
|
103
|
+
"validate_entity_id",
|
|
104
|
+
"validate_component_name",
|
|
105
|
+
"validate_kind",
|
|
106
|
+
"validate_component_data",
|
|
107
|
+
"validate_predicate",
|
|
108
|
+
"mint_entity_id",
|
|
109
|
+
"entity_doc_path",
|
|
110
|
+
"entity_from_doc_path",
|
|
111
|
+
"is_entity_doc",
|
|
112
|
+
"parse_address",
|
|
113
|
+
"format_address",
|
|
114
|
+
"encode_entity_doc",
|
|
115
|
+
"decode_entity_doc",
|
|
116
|
+
"backfill_id_for_doc",
|
|
117
|
+
"is_backfill_id",
|
|
118
|
+
"backfill_entity_for_doc",
|
|
119
|
+
"backfill_entities",
|
|
120
|
+
"match_predicate",
|
|
121
|
+
"filter_entities",
|
|
122
|
+
"build_world_snapshot",
|
|
123
|
+
"component_changed_data",
|
|
124
|
+
"EntityStore",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class EcsError(Exception):
|
|
129
|
+
"""Entity-store violation with a stable machine code.
|
|
130
|
+
|
|
131
|
+
Codes: invalid_entity, unknown_entity, backfill_readonly,
|
|
132
|
+
invalid_component, unknown_component, component_exists,
|
|
133
|
+
invalid_address, invalid_kind, invalid_data, invalid_predicate,
|
|
134
|
+
invalid_document, unknown_document, invalid_path,
|
|
135
|
+
revision_conflict, request_conflict.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
code: Stable machine-readable error code.
|
|
139
|
+
message: Human-readable detail.
|
|
140
|
+
fields: Itemized fields, when the error names any.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, code, message, fields=None):
|
|
144
|
+
super().__init__(message)
|
|
145
|
+
self.code = code
|
|
146
|
+
self.fields = fields or []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_entity_id(entity):
|
|
150
|
+
"""Accept one engine-shaped entity id.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
entity: Candidate id (``ent-…`` minted, ``doc-…`` backfilled).
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The same id, unchanged, when valid.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
EcsError: invalid_entity for non-text, empty, dotted,
|
|
160
|
+
or otherwise malformed ids.
|
|
161
|
+
"""
|
|
162
|
+
if not isinstance(entity, str) or _ENTITY_ID_RE.match(entity) is None:
|
|
163
|
+
raise EcsError("invalid_entity", f"Bad entity id: {entity!r}.")
|
|
164
|
+
return entity
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def validate_component_name(name):
|
|
168
|
+
"""Accept one component name.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
name: Candidate component name.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The same name, unchanged, when valid.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
EcsError: invalid_component for non-text, empty, or dotted
|
|
178
|
+
names (dots would break ``entity.component`` parsing).
|
|
179
|
+
"""
|
|
180
|
+
if not isinstance(name, str) or _COMPONENT_RE.match(name) is None:
|
|
181
|
+
raise EcsError(
|
|
182
|
+
"invalid_component", f"Bad component name: {name!r}."
|
|
183
|
+
)
|
|
184
|
+
return name
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_kind(kind):
|
|
188
|
+
"""Accept one entity kind label.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
kind: Candidate kind ("" means unclassified).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The same kind, unchanged, when valid.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
EcsError: invalid_kind for non-text or over-punctuated kinds.
|
|
198
|
+
"""
|
|
199
|
+
if not isinstance(kind, str) or _KIND_RE.match(kind) is None:
|
|
200
|
+
raise EcsError("invalid_kind", f"Bad entity kind: {kind!r}.")
|
|
201
|
+
return kind
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _check_depth(data, depth=0):
|
|
205
|
+
if depth > MAX_NESTING_DEPTH:
|
|
206
|
+
raise EcsError(
|
|
207
|
+
"invalid_data", "Component data nests deeper than allowed."
|
|
208
|
+
)
|
|
209
|
+
if isinstance(data, dict):
|
|
210
|
+
for value in data.values():
|
|
211
|
+
_check_depth(value, depth + 1)
|
|
212
|
+
elif isinstance(data, list):
|
|
213
|
+
for value in data:
|
|
214
|
+
_check_depth(value, depth + 1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_component_data(data):
|
|
218
|
+
"""Accept one JSON-serializable component payload.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
data: Candidate payload (any JSON shape).
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The same payload, unchanged, when valid.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
EcsError: invalid_data for non-serializable payloads,
|
|
228
|
+
non-finite scalars, over-deep nesting, or payloads past
|
|
229
|
+
the byte limit.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
text = json.dumps(data, sort_keys=True, allow_nan=False)
|
|
233
|
+
except (TypeError, ValueError) as exc:
|
|
234
|
+
raise EcsError(
|
|
235
|
+
"invalid_data", f"Component data must be JSON: {exc}."
|
|
236
|
+
) from exc
|
|
237
|
+
_check_depth(data)
|
|
238
|
+
if len(text.encode("utf-8")) > MAX_COMPONENT_BYTES:
|
|
239
|
+
raise EcsError(
|
|
240
|
+
"invalid_data", "Component data exceeds the size limit."
|
|
241
|
+
)
|
|
242
|
+
return data
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def validate_predicate(predicate):
|
|
246
|
+
"""Accept one entities_with predicate mapping.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
predicate: Candidate predicate, or None for no filtering.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The same predicate, unchanged, when valid.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
EcsError: invalid_predicate for a non-mapping predicate.
|
|
256
|
+
"""
|
|
257
|
+
if predicate is not None and not isinstance(predicate, dict):
|
|
258
|
+
raise EcsError(
|
|
259
|
+
"invalid_predicate",
|
|
260
|
+
f"Predicate must be a mapping, got {predicate!r}.",
|
|
261
|
+
)
|
|
262
|
+
return predicate
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def mint_entity_id(existing=()):
|
|
266
|
+
"""Mint one fresh engine entity id.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
existing: Container of taken ids the mint must avoid.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Fresh ``ent-<12hex>`` id outside ``existing``.
|
|
273
|
+
"""
|
|
274
|
+
while True:
|
|
275
|
+
candidate = f"ent-{uuid.uuid4().hex[:12]}"
|
|
276
|
+
if candidate not in existing:
|
|
277
|
+
return candidate
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def entity_doc_path(entity):
|
|
281
|
+
"""Derive the one doc path storing an entity.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
entity: Entity id to project.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Canonical ``entities/<id>.json`` path — the documented
|
|
288
|
+
storage projection, derived from the id alone.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
EcsError: invalid_entity for a malformed id.
|
|
292
|
+
"""
|
|
293
|
+
validate_entity_id(entity)
|
|
294
|
+
return f"{ENTITY_DOC_PREFIX}{entity}{ENTITY_DOC_SUFFIX}"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def entity_from_doc_path(path):
|
|
298
|
+
"""Recover the entity id a doc path stores, if any.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
path: Canonical doc path to inspect.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Entity id for ``entities/<id>.json`` (single level),
|
|
305
|
+
else None — never raises, so scans stay total.
|
|
306
|
+
"""
|
|
307
|
+
if not isinstance(path, str):
|
|
308
|
+
return None
|
|
309
|
+
if not path.startswith(ENTITY_DOC_PREFIX):
|
|
310
|
+
return None
|
|
311
|
+
if not path.endswith(ENTITY_DOC_SUFFIX):
|
|
312
|
+
return None
|
|
313
|
+
middle = path[len(ENTITY_DOC_PREFIX):-len(ENTITY_DOC_SUFFIX)]
|
|
314
|
+
if not middle or "/" in middle:
|
|
315
|
+
return None
|
|
316
|
+
return middle
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def is_entity_doc(path):
|
|
320
|
+
"""True when a path is an entity record (not a plain doc).
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
path: Canonical doc path to inspect.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True for single-level ``entities/*.json`` paths.
|
|
327
|
+
"""
|
|
328
|
+
return entity_from_doc_path(path) is not None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def parse_address(address):
|
|
332
|
+
"""Split one canonical ``entity.component`` address.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
address: Address text with exactly one dot.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
(entity, component) pair, both validated.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
EcsError: invalid_address for missing/extra dots or empty
|
|
342
|
+
parts; invalid_entity / invalid_component for malformed
|
|
343
|
+
parts.
|
|
344
|
+
"""
|
|
345
|
+
if not isinstance(address, str) or address.count(".") != 1:
|
|
346
|
+
raise EcsError(
|
|
347
|
+
"invalid_address",
|
|
348
|
+
f"Address must be entity.component, got {address!r}.",
|
|
349
|
+
)
|
|
350
|
+
entity, component = address.split(".")
|
|
351
|
+
if not entity or not component:
|
|
352
|
+
raise EcsError(
|
|
353
|
+
"invalid_address",
|
|
354
|
+
f"Address must be entity.component, got {address!r}.",
|
|
355
|
+
)
|
|
356
|
+
try:
|
|
357
|
+
validate_entity_id(entity)
|
|
358
|
+
except EcsError as exc:
|
|
359
|
+
raise EcsError("invalid_address", str(exc)) from exc
|
|
360
|
+
try:
|
|
361
|
+
validate_component_name(component)
|
|
362
|
+
except EcsError as exc:
|
|
363
|
+
raise EcsError("invalid_address", str(exc)) from exc
|
|
364
|
+
return entity, component
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def format_address(entity, component):
|
|
368
|
+
"""Render one canonical ``entity.component`` address.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
entity: Entity id part.
|
|
372
|
+
component: Component name part.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Canonical address text.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
EcsError: invalid_entity / invalid_component for malformed
|
|
379
|
+
parts.
|
|
380
|
+
"""
|
|
381
|
+
validate_entity_id(entity)
|
|
382
|
+
validate_component_name(component)
|
|
383
|
+
return f"{entity}.{component}"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def encode_entity_doc(entity, kind, components, entity_revision=1):
|
|
387
|
+
"""Render one entity record as canonical JSON text.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
entity: Entity id the record stores.
|
|
391
|
+
kind: Kind label carried beside the components.
|
|
392
|
+
components: Mapping of component name to JSON payload.
|
|
393
|
+
entity_revision: Per-entity revision (ENTITY-CAS): starts
|
|
394
|
+
at 1, bumps on every accepted entity mutation.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Deterministic JSON text (sorted keys): same record in,
|
|
398
|
+
same bytes out.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
EcsError: invalid_entity / invalid_kind / invalid_component
|
|
402
|
+
/ invalid_data for malformed parts.
|
|
403
|
+
"""
|
|
404
|
+
validate_entity_id(entity)
|
|
405
|
+
validate_kind(kind)
|
|
406
|
+
if not isinstance(components, dict):
|
|
407
|
+
raise EcsError(
|
|
408
|
+
"invalid_data", "Entity components must be a mapping."
|
|
409
|
+
)
|
|
410
|
+
for name, data in components.items():
|
|
411
|
+
validate_component_name(name)
|
|
412
|
+
validate_component_data(data)
|
|
413
|
+
if (
|
|
414
|
+
not isinstance(entity_revision, int)
|
|
415
|
+
or isinstance(entity_revision, bool)
|
|
416
|
+
or entity_revision < 1
|
|
417
|
+
):
|
|
418
|
+
raise EcsError(
|
|
419
|
+
"invalid_data", "Entity revision must be a positive int."
|
|
420
|
+
)
|
|
421
|
+
return (
|
|
422
|
+
json.dumps(
|
|
423
|
+
{
|
|
424
|
+
"components": components,
|
|
425
|
+
"entity": entity,
|
|
426
|
+
"entity_revision": entity_revision,
|
|
427
|
+
"kind": kind,
|
|
428
|
+
},
|
|
429
|
+
sort_keys=True,
|
|
430
|
+
indent=2,
|
|
431
|
+
)
|
|
432
|
+
+ "\n"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def decode_entity_doc(text, path="<entity>"):
|
|
437
|
+
"""Parse one entity record back into its parts.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
text: Entity doc text to parse.
|
|
441
|
+
path: Doc path named in errors.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Mapping with entity, kind, components, and
|
|
445
|
+
entity_revision (legacy records without one read as 1).
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
EcsError: invalid_document for unparsable text or a record
|
|
449
|
+
whose shape breaks the projection.
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
data = json.loads(text)
|
|
453
|
+
except (TypeError, ValueError) as exc:
|
|
454
|
+
raise EcsError(
|
|
455
|
+
"invalid_document",
|
|
456
|
+
f"Entity record does not parse: {path} ({exc}).",
|
|
457
|
+
) from exc
|
|
458
|
+
if not isinstance(data, dict):
|
|
459
|
+
raise EcsError(
|
|
460
|
+
"invalid_document",
|
|
461
|
+
f"Entity record must be a mapping: {path}.",
|
|
462
|
+
)
|
|
463
|
+
entity = data.get("entity")
|
|
464
|
+
kind = data.get("kind", "")
|
|
465
|
+
components = data.get("components")
|
|
466
|
+
try:
|
|
467
|
+
validate_entity_id(entity)
|
|
468
|
+
validate_kind(kind)
|
|
469
|
+
except EcsError as exc:
|
|
470
|
+
raise EcsError("invalid_document", f"{path}: {exc}.") from exc
|
|
471
|
+
if not isinstance(components, dict):
|
|
472
|
+
raise EcsError(
|
|
473
|
+
"invalid_document",
|
|
474
|
+
f"Entity record carries no components mapping: {path}.",
|
|
475
|
+
)
|
|
476
|
+
for name in components:
|
|
477
|
+
try:
|
|
478
|
+
validate_component_name(name)
|
|
479
|
+
except EcsError as exc:
|
|
480
|
+
raise EcsError(
|
|
481
|
+
"invalid_document", f"{path}: {exc}."
|
|
482
|
+
) from exc
|
|
483
|
+
entity_revision = data.get("entity_revision", 1)
|
|
484
|
+
if (
|
|
485
|
+
not isinstance(entity_revision, int)
|
|
486
|
+
or isinstance(entity_revision, bool)
|
|
487
|
+
or entity_revision < 1
|
|
488
|
+
):
|
|
489
|
+
raise EcsError(
|
|
490
|
+
"invalid_document",
|
|
491
|
+
f"Entity record carries a bad revision: {path}.",
|
|
492
|
+
)
|
|
493
|
+
return {
|
|
494
|
+
"entity": entity,
|
|
495
|
+
"kind": kind,
|
|
496
|
+
"components": components,
|
|
497
|
+
"entity_revision": entity_revision,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def backfill_id_for_doc(path):
|
|
502
|
+
"""Derive the stable read-only id backfilling one doc.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
path: Canonical doc path to project.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Deterministic ``doc-<12hex>`` id (sha1 of the path):
|
|
509
|
+
same path in, same id out, across restarts.
|
|
510
|
+
"""
|
|
511
|
+
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
|
|
512
|
+
return f"doc-{digest}"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def is_backfill_id(entity):
|
|
516
|
+
"""True when an id is a backfilled (read-only) doc id.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
entity: Entity id to inspect.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
True for ``doc-<12hex>`` ids.
|
|
523
|
+
"""
|
|
524
|
+
return (
|
|
525
|
+
isinstance(entity, str)
|
|
526
|
+
and entity.startswith("doc-")
|
|
527
|
+
and _HEX12_RE.match(entity[len("doc-"):]) is not None
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def backfill_entity_for_doc(path, size):
|
|
532
|
+
"""Project one plain doc as a read-only virtual entity.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
path: Canonical doc path to project.
|
|
536
|
+
size: Byte size reported on the ``doc`` component.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
Virtual entity record (id, backfilled-doc kind, and a
|
|
540
|
+
single ``doc`` component with path + bytes).
|
|
541
|
+
"""
|
|
542
|
+
return {
|
|
543
|
+
"id": backfill_id_for_doc(path),
|
|
544
|
+
"kind": BACKFILL_KIND,
|
|
545
|
+
"components": {
|
|
546
|
+
BACKFILL_COMPONENT: {"bytes": size, "path": path}
|
|
547
|
+
},
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def backfill_entities(entries):
|
|
552
|
+
"""Project plain docs as virtual entities (a pure read).
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
entries: Iterable of ``{"path", "bytes"}`` doc listings
|
|
556
|
+
(the ``list_docs`` shape).
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Virtual entity records for every non-entity doc, sorted
|
|
560
|
+
by path. Entity records are skipped — they already have
|
|
561
|
+
engine ids, so backfilling them would mint a second
|
|
562
|
+
identity. Never writes, never advances any revision.
|
|
563
|
+
"""
|
|
564
|
+
backfilled = []
|
|
565
|
+
for entry in entries:
|
|
566
|
+
path = entry["path"]
|
|
567
|
+
if is_entity_doc(path):
|
|
568
|
+
continue
|
|
569
|
+
backfilled.append(
|
|
570
|
+
backfill_entity_for_doc(path, entry.get("bytes", 0))
|
|
571
|
+
)
|
|
572
|
+
return sorted(backfilled, key=lambda record: record["id"])
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def match_predicate(data, predicate):
|
|
576
|
+
"""Test one component payload against an exact-match predicate.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
data: Component payload to test.
|
|
580
|
+
predicate: Exact-match mapping over top-level fields, or
|
|
581
|
+
None for no filtering.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
True when the predicate is None or every predicate field
|
|
585
|
+
equals the payload's; non-dict payloads never match a
|
|
586
|
+
mapping predicate.
|
|
587
|
+
|
|
588
|
+
Raises:
|
|
589
|
+
EcsError: invalid_predicate for a non-mapping predicate.
|
|
590
|
+
"""
|
|
591
|
+
validate_predicate(predicate)
|
|
592
|
+
if predicate is None:
|
|
593
|
+
return True
|
|
594
|
+
if not isinstance(data, dict):
|
|
595
|
+
return False
|
|
596
|
+
return all(
|
|
597
|
+
key in data and data[key] == value
|
|
598
|
+
for key, value in predicate.items()
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def filter_entities(records, component, predicate=None):
|
|
603
|
+
"""Filter entity records to holders of one component.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
records: Iterable of ``{"id", "kind", "components"}``
|
|
607
|
+
records (real or backfilled).
|
|
608
|
+
component: Component name holders must carry.
|
|
609
|
+
predicate: Exact-match mapping the component payload must
|
|
610
|
+
satisfy, or None for no filtering.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Sorted holder ids (every holder when the predicate is
|
|
614
|
+
None).
|
|
615
|
+
|
|
616
|
+
Raises:
|
|
617
|
+
EcsError: invalid_component for a malformed component
|
|
618
|
+
name; invalid_predicate for a non-mapping predicate.
|
|
619
|
+
"""
|
|
620
|
+
validate_component_name(component)
|
|
621
|
+
validate_predicate(predicate)
|
|
622
|
+
holders = [
|
|
623
|
+
record["id"]
|
|
624
|
+
for record in records
|
|
625
|
+
if component in record["components"]
|
|
626
|
+
and match_predicate(record["components"][component], predicate)
|
|
627
|
+
]
|
|
628
|
+
return sorted(holders)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def build_world_snapshot(project_id, current_revision, records):
|
|
632
|
+
"""Bundle entity records as a miniplex-shaped world snapshot.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
project_id: Project the snapshot mirrors.
|
|
636
|
+
current_revision: Head revision the snapshot mirrors.
|
|
637
|
+
records: Iterable of ``{"id", "kind", "components"}``
|
|
638
|
+
records (real plus, optionally, backfilled).
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
Snapshot mapping with project id, head revision, and the
|
|
642
|
+
entities sorted by id — each entry maps one-to-one onto a
|
|
643
|
+
miniplex entity (``{id, ...components}``), so mirrors load
|
|
644
|
+
it directly.
|
|
645
|
+
"""
|
|
646
|
+
ordered = sorted(records, key=lambda record: record["id"])
|
|
647
|
+
return {
|
|
648
|
+
"current_revision": current_revision,
|
|
649
|
+
"entities": [
|
|
650
|
+
{
|
|
651
|
+
"components": dict(record["components"]),
|
|
652
|
+
"id": record["id"],
|
|
653
|
+
"kind": record["kind"],
|
|
654
|
+
}
|
|
655
|
+
for record in ordered
|
|
656
|
+
],
|
|
657
|
+
"project_id": project_id,
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def component_changed_data(
|
|
662
|
+
entity, component, revision, detached=False, entity_revision=None
|
|
663
|
+
):
|
|
664
|
+
"""Build one component_changed event payload.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
entity: Entity whose component changed.
|
|
668
|
+
component: Component that changed.
|
|
669
|
+
revision: Head revision after the change.
|
|
670
|
+
detached: True when the change removed the component.
|
|
671
|
+
entity_revision: Per-entity revision after the change
|
|
672
|
+
(ENTITY-CAS); omitted when unknown so legacy exact
|
|
673
|
+
shapes keep passing.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Event data mapping with entity, component, revision (plus
|
|
677
|
+
detached True for removals, plus entity_revision when
|
|
678
|
+
supplied).
|
|
679
|
+
"""
|
|
680
|
+
data = {"component": component, "entity": entity, "revision": revision}
|
|
681
|
+
if detached:
|
|
682
|
+
data["detached"] = True
|
|
683
|
+
if entity_revision is not None:
|
|
684
|
+
data["entity_revision"] = entity_revision
|
|
685
|
+
return data
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _advance_revision(revision):
|
|
689
|
+
"""Next revision: trailing digits increment, else `-2` is suffixed."""
|
|
690
|
+
match = _REVISION_RE.match(revision or "")
|
|
691
|
+
if match is None:
|
|
692
|
+
return f"{revision}-2"
|
|
693
|
+
return f"{match.group(1)}{int(match.group(2)) + 1}"
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _frozen_identity(operation, arguments):
|
|
697
|
+
"""Canonical identity text for idempotent request comparison."""
|
|
698
|
+
return json.dumps(
|
|
699
|
+
{"arguments": arguments, "operation": operation},
|
|
700
|
+
sort_keys=True,
|
|
701
|
+
default=str,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# TODO: No need to handroll this. bro just get an ECS library
|
|
705
|
+
# Utilize the ESPER lib context and modify the context of esper to seet the default context for the runtime to world context
|
|
706
|
+
# WIth esper's ECS, a lot of the tools can just move to their own dedicated tools/ module outside of ports, for the simple reason that ports will be used for communicating with the engine
|
|
707
|
+
# While tools are tools the engine uses to power its services, and the result of said tools needs to propagate to all adapter ports to fan out the results, while port-specific formatting remains the responsibility of the adapter itself, it just needs to know what the tool sends back out; pydantic dataclass
|
|
708
|
+
class EntityStore:
|
|
709
|
+
"""In-memory entity store: the reference ECS implementation.
|
|
710
|
+
|
|
711
|
+
Projects entities over an in-memory doc map plus an ordered
|
|
712
|
+
event log with the ledger law: every accepted mutation gates on
|
|
713
|
+
``expected_revision``, honors idempotent ``request_id`` replay,
|
|
714
|
+
advances the revision once, and appends one actor-attributed
|
|
715
|
+
event. The wiring patch teaches ``ProjectService`` these same
|
|
716
|
+
verbs over the real doc store + durable ledger; response shapes
|
|
717
|
+
match field for field so unit and wiring tests assert one
|
|
718
|
+
contract.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
project_id: Project this store is bound to.
|
|
722
|
+
actor: Attribution recorded on every appended event.
|
|
723
|
+
current_revision: Starting head revision.
|
|
724
|
+
|
|
725
|
+
Caution: An omitted expected_revision defaults to the live head
|
|
726
|
+
(a test-seam leniency); the wired path follows
|
|
727
|
+
``ProjectService`` strictness instead — callers supply the
|
|
728
|
+
head they read (MCP fills it), and stale writers fail.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
def __init__(
|
|
732
|
+
self, project_id="ecs-demo", actor="test",
|
|
733
|
+
current_revision="p12",
|
|
734
|
+
):
|
|
735
|
+
self._project_id = project_id
|
|
736
|
+
self._actor = actor
|
|
737
|
+
self._current_revision = current_revision
|
|
738
|
+
self._docs = {}
|
|
739
|
+
self._events = []
|
|
740
|
+
self._requests = {}
|
|
741
|
+
|
|
742
|
+
def _cursor(self):
|
|
743
|
+
return self._events[-1]["seq"] if self._events else 0
|
|
744
|
+
|
|
745
|
+
def _append_event(self, event, request_id, data):
|
|
746
|
+
record = {
|
|
747
|
+
"actor": self._actor,
|
|
748
|
+
"data": data,
|
|
749
|
+
"event": event,
|
|
750
|
+
"project_id": self._project_id,
|
|
751
|
+
"request_id": request_id,
|
|
752
|
+
"seq": self._cursor() + 1,
|
|
753
|
+
}
|
|
754
|
+
self._events.append(record)
|
|
755
|
+
return record
|
|
756
|
+
|
|
757
|
+
def _check_replay(self, request_id, identity):
|
|
758
|
+
prev = self._requests.get(request_id)
|
|
759
|
+
if prev is None:
|
|
760
|
+
return None
|
|
761
|
+
prev_identity, prev_response = prev
|
|
762
|
+
if prev_identity != identity:
|
|
763
|
+
raise EcsError(
|
|
764
|
+
"request_conflict",
|
|
765
|
+
f"Request id reused with different input: {request_id}.",
|
|
766
|
+
)
|
|
767
|
+
return prev_response
|
|
768
|
+
|
|
769
|
+
def _gate_revision(self, expected_revision):
|
|
770
|
+
if expected_revision != self._current_revision:
|
|
771
|
+
raise EcsError(
|
|
772
|
+
"revision_conflict",
|
|
773
|
+
f"Expected revision {self._current_revision}, "
|
|
774
|
+
f"got {expected_revision}.",
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
def _refresh_expected(self, expected_revision):
|
|
778
|
+
if expected_revision is None:
|
|
779
|
+
return self._current_revision
|
|
780
|
+
return expected_revision
|
|
781
|
+
|
|
782
|
+
def _mutation_response(
|
|
783
|
+
self, request_id, previous, diff, record, extra=None
|
|
784
|
+
):
|
|
785
|
+
response = {
|
|
786
|
+
"actor": self._actor,
|
|
787
|
+
"current_revision": self._current_revision,
|
|
788
|
+
"diff_summary": diff,
|
|
789
|
+
"event_cursor": self._cursor(),
|
|
790
|
+
"events": [record],
|
|
791
|
+
"previous_revision": previous,
|
|
792
|
+
"project_id": self._project_id,
|
|
793
|
+
"request_id": request_id,
|
|
794
|
+
"revision_transition": {
|
|
795
|
+
"from": previous,
|
|
796
|
+
"to": self._current_revision,
|
|
797
|
+
},
|
|
798
|
+
"rewind_handle": {
|
|
799
|
+
"project_id": self._project_id,
|
|
800
|
+
"request_id": request_id,
|
|
801
|
+
"revision": previous,
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
response.update(extra or {})
|
|
805
|
+
return response
|
|
806
|
+
|
|
807
|
+
def _read_entity(self, entity):
|
|
808
|
+
validate_entity_id(entity)
|
|
809
|
+
if is_backfill_id(entity):
|
|
810
|
+
raise EcsError(
|
|
811
|
+
"backfill_readonly",
|
|
812
|
+
f"Backfilled entity is read-only: {entity}.",
|
|
813
|
+
)
|
|
814
|
+
path = entity_doc_path(entity)
|
|
815
|
+
text = self._docs.get(path)
|
|
816
|
+
if text is None:
|
|
817
|
+
raise EcsError("unknown_entity", f"No such entity: {entity}.")
|
|
818
|
+
decoded = decode_entity_doc(text, path)
|
|
819
|
+
if decoded["entity"] != entity:
|
|
820
|
+
raise EcsError(
|
|
821
|
+
"invalid_document",
|
|
822
|
+
f"Entity record id drift at {path}: stored "
|
|
823
|
+
f"{decoded['entity']!r} for {entity!r}.",
|
|
824
|
+
)
|
|
825
|
+
return path, decoded
|
|
826
|
+
|
|
827
|
+
def _doc_entries(self):
|
|
828
|
+
return [
|
|
829
|
+
{"bytes": len(text.encode("utf-8")), "path": path}
|
|
830
|
+
for path, text in self._docs.items()
|
|
831
|
+
]
|
|
832
|
+
|
|
833
|
+
def _entity_records(self):
|
|
834
|
+
records = []
|
|
835
|
+
for path in sorted(self._docs):
|
|
836
|
+
entity = entity_from_doc_path(path)
|
|
837
|
+
if entity is None:
|
|
838
|
+
continue
|
|
839
|
+
decoded = decode_entity_doc(self._docs[path], path)
|
|
840
|
+
records.append({
|
|
841
|
+
"components": decoded["components"],
|
|
842
|
+
"id": decoded["entity"],
|
|
843
|
+
"kind": decoded["kind"],
|
|
844
|
+
})
|
|
845
|
+
return records
|
|
846
|
+
|
|
847
|
+
def _backfill_records(self):
|
|
848
|
+
return backfill_entities(self._doc_entries())
|
|
849
|
+
|
|
850
|
+
def _resolve_backfill(self, entity):
|
|
851
|
+
for record in self._backfill_records():
|
|
852
|
+
if record["id"] == entity:
|
|
853
|
+
return record
|
|
854
|
+
raise EcsError("unknown_entity", f"No such entity: {entity}.")
|
|
855
|
+
|
|
856
|
+
def snapshot(self):
|
|
857
|
+
"""Read the store head: project, revision, and event cursor.
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
Snapshot mapping with project_id, current_revision,
|
|
861
|
+
and event_cursor.
|
|
862
|
+
"""
|
|
863
|
+
return {
|
|
864
|
+
"current_revision": self._current_revision,
|
|
865
|
+
"event_cursor": self._cursor(),
|
|
866
|
+
"project_id": self._project_id,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
def events_since(self, cursor):
|
|
870
|
+
"""Read logged events after a cursor, oldest first.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
cursor: Only events with a higher seq are returned.
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
Logged events after the cursor, seq-ordered.
|
|
877
|
+
"""
|
|
878
|
+
return [e for e in self._events if e["seq"] > cursor]
|
|
879
|
+
|
|
880
|
+
def write_doc(
|
|
881
|
+
self, path, content, request_id=None, expected_revision=None
|
|
882
|
+
):
|
|
883
|
+
"""Write one plain document: gate, store, advance, log.
|
|
884
|
+
|
|
885
|
+
The existing CRUD spelling, kept working alongside
|
|
886
|
+
entities — entity records written through here decode
|
|
887
|
+
through the same projection on read.
|
|
888
|
+
|
|
889
|
+
Args:
|
|
890
|
+
path: Document path to create or overwrite.
|
|
891
|
+
content: UTF-8 text to store.
|
|
892
|
+
request_id: Client envelope id for idempotent replay;
|
|
893
|
+
a fresh id when omitted.
|
|
894
|
+
expected_revision: Revision the writer read; the live
|
|
895
|
+
head when omitted.
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
Mutation mapping with path, revision transition, new
|
|
899
|
+
head, actor, diff summary, rewind handle, the
|
|
900
|
+
appended event, cursor, and request id.
|
|
901
|
+
|
|
902
|
+
Raises:
|
|
903
|
+
EcsError: request_conflict for a reused id with new
|
|
904
|
+
text; invalid_path for a bad path; invalid_command
|
|
905
|
+
for non-text content; revision_conflict for a
|
|
906
|
+
stale head.
|
|
907
|
+
"""
|
|
908
|
+
request_id = request_id or uuid.uuid4().hex
|
|
909
|
+
identity = _frozen_identity(
|
|
910
|
+
"write_doc", {"content": content, "path": path}
|
|
911
|
+
)
|
|
912
|
+
replay = self._check_replay(request_id, identity)
|
|
913
|
+
if replay is not None:
|
|
914
|
+
return replay
|
|
915
|
+
if (
|
|
916
|
+
not isinstance(path, str)
|
|
917
|
+
or not path
|
|
918
|
+
or path.startswith("/")
|
|
919
|
+
or ".." in path.split("/")
|
|
920
|
+
):
|
|
921
|
+
raise EcsError("invalid_path", f"Bad document path: {path!r}.")
|
|
922
|
+
if (
|
|
923
|
+
path in ("ledger.yaml", "events.yaml")
|
|
924
|
+
or path.startswith("runs/")
|
|
925
|
+
or path.startswith("snapshots/")
|
|
926
|
+
):
|
|
927
|
+
raise EcsError(
|
|
928
|
+
"invalid_path",
|
|
929
|
+
f"Engine-owned path is not a document: {path!r}.",
|
|
930
|
+
)
|
|
931
|
+
if not isinstance(content, str):
|
|
932
|
+
raise EcsError(
|
|
933
|
+
"invalid_command",
|
|
934
|
+
f"Write needs text content for document {path}.",
|
|
935
|
+
)
|
|
936
|
+
self._gate_revision(self._refresh_expected(expected_revision))
|
|
937
|
+
previous = self._current_revision
|
|
938
|
+
old = self._docs.get(path)
|
|
939
|
+
self._docs[path] = content
|
|
940
|
+
self._current_revision = _advance_revision(previous)
|
|
941
|
+
new_bytes = len(content.encode("utf-8"))
|
|
942
|
+
if old is None:
|
|
943
|
+
diff = f"created {path} ({new_bytes} bytes)"
|
|
944
|
+
else:
|
|
945
|
+
diff = (
|
|
946
|
+
f"updated {path} "
|
|
947
|
+
f"({len(old.encode('utf-8'))} -> {new_bytes} bytes)"
|
|
948
|
+
)
|
|
949
|
+
record = self._append_event(
|
|
950
|
+
"doc_written",
|
|
951
|
+
request_id,
|
|
952
|
+
{
|
|
953
|
+
"bytes": new_bytes,
|
|
954
|
+
"diff_summary": diff,
|
|
955
|
+
"operation": "write_doc",
|
|
956
|
+
"path": path,
|
|
957
|
+
"revision": self._current_revision,
|
|
958
|
+
},
|
|
959
|
+
)
|
|
960
|
+
response = self._mutation_response(
|
|
961
|
+
request_id, previous, diff, record, {"path": path}
|
|
962
|
+
)
|
|
963
|
+
self._requests[request_id] = (identity, response)
|
|
964
|
+
return response
|
|
965
|
+
|
|
966
|
+
def read_doc(self, path):
|
|
967
|
+
"""Read one document as text at the current head.
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
path: Document path to read.
|
|
971
|
+
|
|
972
|
+
Returns:
|
|
973
|
+
Mapping with project id, head revision, path,
|
|
974
|
+
content text, and byte size.
|
|
975
|
+
|
|
976
|
+
Raises:
|
|
977
|
+
EcsError: unknown_document for a missing document.
|
|
978
|
+
"""
|
|
979
|
+
content = self._docs.get(path)
|
|
980
|
+
if content is None:
|
|
981
|
+
raise EcsError("unknown_document", f"No such document: {path}.")
|
|
982
|
+
return {
|
|
983
|
+
"bytes": len(content.encode("utf-8")),
|
|
984
|
+
"content": content,
|
|
985
|
+
"current_revision": self._current_revision,
|
|
986
|
+
"path": path,
|
|
987
|
+
"project_id": self._project_id,
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
def list_docs(self, prefix=None):
|
|
991
|
+
"""List documents, optionally filtered by a path prefix.
|
|
992
|
+
|
|
993
|
+
Args:
|
|
994
|
+
prefix: Optional path prefix; empty matches everything.
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Mapping with project id, head revision, event cursor,
|
|
998
|
+
and sorted document entries (path + bytes).
|
|
999
|
+
"""
|
|
1000
|
+
want = prefix or ""
|
|
1001
|
+
documents = []
|
|
1002
|
+
for path in sorted(self._docs):
|
|
1003
|
+
if want and not (path == want or path.startswith(want + "/")):
|
|
1004
|
+
if not path.startswith(want):
|
|
1005
|
+
continue
|
|
1006
|
+
documents.append({
|
|
1007
|
+
"bytes": len(self._docs[path].encode("utf-8")),
|
|
1008
|
+
"path": path,
|
|
1009
|
+
})
|
|
1010
|
+
return {
|
|
1011
|
+
"current_revision": self._current_revision,
|
|
1012
|
+
"documents": documents,
|
|
1013
|
+
"event_cursor": self._cursor(),
|
|
1014
|
+
"project_id": self._project_id,
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
def create_entity(
|
|
1018
|
+
self, kind="", components=None, request_id=None,
|
|
1019
|
+
expected_revision=None,
|
|
1020
|
+
):
|
|
1021
|
+
"""Create one entity with an engine-minted id.
|
|
1022
|
+
|
|
1023
|
+
The id is minted, never supplied: there is no id argument,
|
|
1024
|
+
so callers cannot collide with, guess, or squat on engine
|
|
1025
|
+
identity. The record persists as its projected doc path.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
kind: Kind label carried beside the components.
|
|
1029
|
+
components: Optional initial component mapping.
|
|
1030
|
+
request_id: Client envelope id for idempotent replay;
|
|
1031
|
+
a fresh id when omitted.
|
|
1032
|
+
expected_revision: Revision the writer read; the live
|
|
1033
|
+
head when omitted.
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
Mutation mapping with the minted entity, kind, storage
|
|
1037
|
+
path, revision transition, new head, actor, diff
|
|
1038
|
+
summary, rewind handle, the entity_created event,
|
|
1039
|
+
cursor, request id, and initial component names.
|
|
1040
|
+
|
|
1041
|
+
Raises:
|
|
1042
|
+
EcsError: request_conflict for a reused id with new
|
|
1043
|
+
text; invalid_kind / invalid_component /
|
|
1044
|
+
invalid_data for malformed parts;
|
|
1045
|
+
revision_conflict for a stale head.
|
|
1046
|
+
"""
|
|
1047
|
+
request_id = request_id or uuid.uuid4().hex
|
|
1048
|
+
validate_kind(kind)
|
|
1049
|
+
initial = dict(components or {})
|
|
1050
|
+
for name, data in initial.items():
|
|
1051
|
+
validate_component_name(name)
|
|
1052
|
+
validate_component_data(data)
|
|
1053
|
+
identity = _frozen_identity(
|
|
1054
|
+
"create_entity", {"components": initial, "kind": kind}
|
|
1055
|
+
)
|
|
1056
|
+
replay = self._check_replay(request_id, identity)
|
|
1057
|
+
if replay is not None:
|
|
1058
|
+
return replay
|
|
1059
|
+
self._gate_revision(self._refresh_expected(expected_revision))
|
|
1060
|
+
previous = self._current_revision
|
|
1061
|
+
taken = {
|
|
1062
|
+
entity_from_doc_path(path) for path in self._docs
|
|
1063
|
+
} - {None}
|
|
1064
|
+
entity = mint_entity_id(existing=taken)
|
|
1065
|
+
path = entity_doc_path(entity)
|
|
1066
|
+
self._docs[path] = encode_entity_doc(entity, kind, initial)
|
|
1067
|
+
self._current_revision = _advance_revision(previous)
|
|
1068
|
+
diff = f"created entity {entity} ({path})"
|
|
1069
|
+
record = self._append_event(
|
|
1070
|
+
"entity_created",
|
|
1071
|
+
request_id,
|
|
1072
|
+
{
|
|
1073
|
+
"components": sorted(initial),
|
|
1074
|
+
"entity": entity,
|
|
1075
|
+
"kind": kind,
|
|
1076
|
+
"revision": self._current_revision,
|
|
1077
|
+
},
|
|
1078
|
+
)
|
|
1079
|
+
response = self._mutation_response(
|
|
1080
|
+
request_id, previous, diff, record,
|
|
1081
|
+
{
|
|
1082
|
+
"components": sorted(initial),
|
|
1083
|
+
"entity": entity,
|
|
1084
|
+
"kind": kind,
|
|
1085
|
+
"path": path,
|
|
1086
|
+
},
|
|
1087
|
+
)
|
|
1088
|
+
self._requests[request_id] = (identity, response)
|
|
1089
|
+
return response
|
|
1090
|
+
|
|
1091
|
+
def get_component(self, entity, component):
|
|
1092
|
+
"""Read one component payload at the current head.
|
|
1093
|
+
|
|
1094
|
+
Backfilled doc entities serve their projected ``doc``
|
|
1095
|
+
component here, so every world-snapshot entry stays
|
|
1096
|
+
addressable.
|
|
1097
|
+
|
|
1098
|
+
Args:
|
|
1099
|
+
entity: Entity id holding the component.
|
|
1100
|
+
component: Component name to read.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
Mapping with project id, head revision, entity,
|
|
1104
|
+
component, canonical address, storage path (the
|
|
1105
|
+
projected doc path, or the backfilled doc path), and
|
|
1106
|
+
the component data.
|
|
1107
|
+
|
|
1108
|
+
Raises:
|
|
1109
|
+
EcsError: invalid_entity / invalid_component for
|
|
1110
|
+
malformed parts; unknown_entity for a missing
|
|
1111
|
+
entity; unknown_component for a missing component;
|
|
1112
|
+
invalid_document for a corrupt entity record.
|
|
1113
|
+
"""
|
|
1114
|
+
validate_entity_id(entity)
|
|
1115
|
+
validate_component_name(component)
|
|
1116
|
+
if is_backfill_id(entity):
|
|
1117
|
+
record = self._resolve_backfill(entity)
|
|
1118
|
+
if component != BACKFILL_COMPONENT:
|
|
1119
|
+
raise EcsError(
|
|
1120
|
+
"unknown_component",
|
|
1121
|
+
f"No component {component!r} on {entity}.",
|
|
1122
|
+
)
|
|
1123
|
+
data = record["components"][BACKFILL_COMPONENT]
|
|
1124
|
+
return {
|
|
1125
|
+
"address": format_address(entity, component),
|
|
1126
|
+
"component": component,
|
|
1127
|
+
"current_revision": self._current_revision,
|
|
1128
|
+
"data": dict(data),
|
|
1129
|
+
"entity": entity,
|
|
1130
|
+
"path": data["path"],
|
|
1131
|
+
"project_id": self._project_id,
|
|
1132
|
+
}
|
|
1133
|
+
path, decoded = self._read_entity(entity)
|
|
1134
|
+
if component not in decoded["components"]:
|
|
1135
|
+
raise EcsError(
|
|
1136
|
+
"unknown_component",
|
|
1137
|
+
f"No component {component!r} on {entity}.",
|
|
1138
|
+
)
|
|
1139
|
+
return {
|
|
1140
|
+
"address": format_address(entity, component),
|
|
1141
|
+
"component": component,
|
|
1142
|
+
"current_revision": self._current_revision,
|
|
1143
|
+
"data": decoded["components"][component],
|
|
1144
|
+
"entity": entity,
|
|
1145
|
+
"path": path,
|
|
1146
|
+
"project_id": self._project_id,
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
def set_component(
|
|
1150
|
+
self, entity, component, data, request_id=None,
|
|
1151
|
+
expected_revision=None,
|
|
1152
|
+
):
|
|
1153
|
+
"""Upsert one component payload (create or overwrite).
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
entity: Entity id holding the component.
|
|
1157
|
+
component: Component name to write.
|
|
1158
|
+
data: JSON payload to store.
|
|
1159
|
+
request_id: Client envelope id for idempotent replay;
|
|
1160
|
+
a fresh id when omitted.
|
|
1161
|
+
expected_revision: Revision the writer read; the live
|
|
1162
|
+
head when omitted.
|
|
1163
|
+
|
|
1164
|
+
Returns:
|
|
1165
|
+
Mutation mapping with entity, component, canonical
|
|
1166
|
+
address, storage path, revision transition, new head,
|
|
1167
|
+
actor, diff summary, rewind handle, the
|
|
1168
|
+
component_changed event, cursor, and request id.
|
|
1169
|
+
|
|
1170
|
+
Raises:
|
|
1171
|
+
EcsError: request_conflict for a reused id with new
|
|
1172
|
+
text; invalid_entity / invalid_component /
|
|
1173
|
+
invalid_data for malformed parts; unknown_entity
|
|
1174
|
+
for a missing entity; backfill_readonly for a
|
|
1175
|
+
backfilled id; invalid_document for a corrupt
|
|
1176
|
+
entity record; revision_conflict for a stale head.
|
|
1177
|
+
"""
|
|
1178
|
+
request_id = request_id or uuid.uuid4().hex
|
|
1179
|
+
validate_entity_id(entity)
|
|
1180
|
+
validate_component_name(component)
|
|
1181
|
+
validate_component_data(data)
|
|
1182
|
+
identity = _frozen_identity(
|
|
1183
|
+
"set_component",
|
|
1184
|
+
{"component": component, "data": data, "entity": entity},
|
|
1185
|
+
)
|
|
1186
|
+
replay = self._check_replay(request_id, identity)
|
|
1187
|
+
if replay is not None:
|
|
1188
|
+
return replay
|
|
1189
|
+
path, decoded = self._read_entity(entity)
|
|
1190
|
+
self._gate_revision(self._refresh_expected(expected_revision))
|
|
1191
|
+
previous = self._current_revision
|
|
1192
|
+
existed = component in decoded["components"]
|
|
1193
|
+
merged = dict(decoded["components"])
|
|
1194
|
+
merged[component] = data
|
|
1195
|
+
self._docs[path] = encode_entity_doc(
|
|
1196
|
+
entity, decoded["kind"], merged
|
|
1197
|
+
)
|
|
1198
|
+
self._current_revision = _advance_revision(previous)
|
|
1199
|
+
verb = "updated" if existed else "set"
|
|
1200
|
+
diff = (
|
|
1201
|
+
f"{verb} {format_address(entity, component)} ({path})"
|
|
1202
|
+
)
|
|
1203
|
+
record = self._append_event(
|
|
1204
|
+
"component_changed",
|
|
1205
|
+
request_id,
|
|
1206
|
+
component_changed_data(
|
|
1207
|
+
entity, component, self._current_revision
|
|
1208
|
+
),
|
|
1209
|
+
)
|
|
1210
|
+
response = self._mutation_response(
|
|
1211
|
+
request_id, previous, diff, record,
|
|
1212
|
+
{
|
|
1213
|
+
"address": format_address(entity, component),
|
|
1214
|
+
"component": component,
|
|
1215
|
+
"entity": entity,
|
|
1216
|
+
"path": path,
|
|
1217
|
+
},
|
|
1218
|
+
)
|
|
1219
|
+
self._requests[request_id] = (identity, response)
|
|
1220
|
+
return response
|
|
1221
|
+
|
|
1222
|
+
def attach_component(
|
|
1223
|
+
self, entity, component, data, request_id=None,
|
|
1224
|
+
expected_revision=None,
|
|
1225
|
+
):
|
|
1226
|
+
"""Attach one new component (refuses overwrites).
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
entity: Entity id holding the component.
|
|
1230
|
+
component: Component name to attach.
|
|
1231
|
+
data: JSON payload to store.
|
|
1232
|
+
request_id: Client envelope id for idempotent replay;
|
|
1233
|
+
a fresh id when omitted.
|
|
1234
|
+
expected_revision: Revision the writer read; the live
|
|
1235
|
+
head when omitted.
|
|
1236
|
+
|
|
1237
|
+
Returns:
|
|
1238
|
+
Mutation mapping (see set_component) with the
|
|
1239
|
+
component_changed event.
|
|
1240
|
+
|
|
1241
|
+
Raises:
|
|
1242
|
+
EcsError: component_exists when the component already
|
|
1243
|
+
lives on the entity; otherwise like set_component.
|
|
1244
|
+
"""
|
|
1245
|
+
request_id = request_id or uuid.uuid4().hex
|
|
1246
|
+
validate_entity_id(entity)
|
|
1247
|
+
validate_component_name(component)
|
|
1248
|
+
validate_component_data(data)
|
|
1249
|
+
identity = _frozen_identity(
|
|
1250
|
+
"attach_component",
|
|
1251
|
+
{"component": component, "data": data, "entity": entity},
|
|
1252
|
+
)
|
|
1253
|
+
replay = self._check_replay(request_id, identity)
|
|
1254
|
+
if replay is not None:
|
|
1255
|
+
return replay
|
|
1256
|
+
path, decoded = self._read_entity(entity)
|
|
1257
|
+
if component in decoded["components"]:
|
|
1258
|
+
raise EcsError(
|
|
1259
|
+
"component_exists",
|
|
1260
|
+
f"Component already attached: "
|
|
1261
|
+
f"{format_address(entity, component)}.",
|
|
1262
|
+
)
|
|
1263
|
+
self._gate_revision(self._refresh_expected(expected_revision))
|
|
1264
|
+
previous = self._current_revision
|
|
1265
|
+
merged = dict(decoded["components"])
|
|
1266
|
+
merged[component] = data
|
|
1267
|
+
self._docs[path] = encode_entity_doc(
|
|
1268
|
+
entity, decoded["kind"], merged
|
|
1269
|
+
)
|
|
1270
|
+
self._current_revision = _advance_revision(previous)
|
|
1271
|
+
diff = (
|
|
1272
|
+
f"attached {format_address(entity, component)} ({path})"
|
|
1273
|
+
)
|
|
1274
|
+
record = self._append_event(
|
|
1275
|
+
"component_changed",
|
|
1276
|
+
request_id,
|
|
1277
|
+
component_changed_data(
|
|
1278
|
+
entity, component, self._current_revision
|
|
1279
|
+
),
|
|
1280
|
+
)
|
|
1281
|
+
response = self._mutation_response(
|
|
1282
|
+
request_id, previous, diff, record,
|
|
1283
|
+
{
|
|
1284
|
+
"address": format_address(entity, component),
|
|
1285
|
+
"component": component,
|
|
1286
|
+
"entity": entity,
|
|
1287
|
+
"path": path,
|
|
1288
|
+
},
|
|
1289
|
+
)
|
|
1290
|
+
self._requests[request_id] = (identity, response)
|
|
1291
|
+
return response
|
|
1292
|
+
|
|
1293
|
+
def detach_component(
|
|
1294
|
+
self, entity, component, request_id=None,
|
|
1295
|
+
expected_revision=None,
|
|
1296
|
+
):
|
|
1297
|
+
"""Detach one component from its entity.
|
|
1298
|
+
|
|
1299
|
+
Args:
|
|
1300
|
+
entity: Entity id holding the component.
|
|
1301
|
+
component: Component name to remove.
|
|
1302
|
+
request_id: Client envelope id for idempotent replay;
|
|
1303
|
+
a fresh id when omitted.
|
|
1304
|
+
expected_revision: Revision the writer read; the live
|
|
1305
|
+
head when omitted.
|
|
1306
|
+
|
|
1307
|
+
Returns:
|
|
1308
|
+
Mutation mapping (see set_component); the
|
|
1309
|
+
component_changed event carries detached True.
|
|
1310
|
+
|
|
1311
|
+
Raises:
|
|
1312
|
+
EcsError: unknown_component for a missing component;
|
|
1313
|
+
otherwise like set_component.
|
|
1314
|
+
"""
|
|
1315
|
+
request_id = request_id or uuid.uuid4().hex
|
|
1316
|
+
validate_entity_id(entity)
|
|
1317
|
+
validate_component_name(component)
|
|
1318
|
+
identity = _frozen_identity(
|
|
1319
|
+
"detach_component",
|
|
1320
|
+
{"component": component, "entity": entity},
|
|
1321
|
+
)
|
|
1322
|
+
replay = self._check_replay(request_id, identity)
|
|
1323
|
+
if replay is not None:
|
|
1324
|
+
return replay
|
|
1325
|
+
path, decoded = self._read_entity(entity)
|
|
1326
|
+
if component not in decoded["components"]:
|
|
1327
|
+
raise EcsError(
|
|
1328
|
+
"unknown_component",
|
|
1329
|
+
f"No component {component!r} on {entity}.",
|
|
1330
|
+
)
|
|
1331
|
+
self._gate_revision(self._refresh_expected(expected_revision))
|
|
1332
|
+
previous = self._current_revision
|
|
1333
|
+
merged = dict(decoded["components"])
|
|
1334
|
+
del merged[component]
|
|
1335
|
+
self._docs[path] = encode_entity_doc(
|
|
1336
|
+
entity, decoded["kind"], merged
|
|
1337
|
+
)
|
|
1338
|
+
self._current_revision = _advance_revision(previous)
|
|
1339
|
+
diff = (
|
|
1340
|
+
f"detached {format_address(entity, component)} ({path})"
|
|
1341
|
+
)
|
|
1342
|
+
record = self._append_event(
|
|
1343
|
+
"component_changed",
|
|
1344
|
+
request_id,
|
|
1345
|
+
component_changed_data(
|
|
1346
|
+
entity, component, self._current_revision,
|
|
1347
|
+
detached=True,
|
|
1348
|
+
),
|
|
1349
|
+
)
|
|
1350
|
+
response = self._mutation_response(
|
|
1351
|
+
request_id, previous, diff, record,
|
|
1352
|
+
{
|
|
1353
|
+
"address": format_address(entity, component),
|
|
1354
|
+
"component": component,
|
|
1355
|
+
"entity": entity,
|
|
1356
|
+
"path": path,
|
|
1357
|
+
},
|
|
1358
|
+
)
|
|
1359
|
+
self._requests[request_id] = (identity, response)
|
|
1360
|
+
return response
|
|
1361
|
+
|
|
1362
|
+
def delete_entity(
|
|
1363
|
+
self, entity, request_id=None, expected_revision=None
|
|
1364
|
+
):
|
|
1365
|
+
"""Delete one entity and its projected record.
|
|
1366
|
+
|
|
1367
|
+
Args:
|
|
1368
|
+
entity: Entity id to delete.
|
|
1369
|
+
request_id: Client envelope id for idempotent replay;
|
|
1370
|
+
a fresh id when omitted.
|
|
1371
|
+
expected_revision: Revision the writer read; the live
|
|
1372
|
+
head when omitted.
|
|
1373
|
+
|
|
1374
|
+
Returns:
|
|
1375
|
+
Mutation mapping with entity, storage path, revision
|
|
1376
|
+
transition, new head, actor, diff summary, rewind
|
|
1377
|
+
handle, the entity_deleted event, cursor, and request
|
|
1378
|
+
id.
|
|
1379
|
+
|
|
1380
|
+
Raises:
|
|
1381
|
+
EcsError: request_conflict for a reused id with new
|
|
1382
|
+
text; invalid_entity for a malformed id;
|
|
1383
|
+
unknown_entity for a missing entity;
|
|
1384
|
+
backfill_readonly for a backfilled id;
|
|
1385
|
+
invalid_document for a corrupt entity record;
|
|
1386
|
+
revision_conflict for a stale head.
|
|
1387
|
+
"""
|
|
1388
|
+
request_id = request_id or uuid.uuid4().hex
|
|
1389
|
+
validate_entity_id(entity)
|
|
1390
|
+
identity = _frozen_identity("delete_entity", {"entity": entity})
|
|
1391
|
+
replay = self._check_replay(request_id, identity)
|
|
1392
|
+
if replay is not None:
|
|
1393
|
+
return replay
|
|
1394
|
+
path, _decoded = self._read_entity(entity)
|
|
1395
|
+
self._gate_revision(self._refresh_expected(expected_revision))
|
|
1396
|
+
previous = self._current_revision
|
|
1397
|
+
del self._docs[path]
|
|
1398
|
+
self._current_revision = _advance_revision(previous)
|
|
1399
|
+
diff = f"deleted entity {entity} ({path})"
|
|
1400
|
+
record = self._append_event(
|
|
1401
|
+
"entity_deleted",
|
|
1402
|
+
request_id,
|
|
1403
|
+
{"entity": entity, "revision": self._current_revision},
|
|
1404
|
+
)
|
|
1405
|
+
response = self._mutation_response(
|
|
1406
|
+
request_id, previous, diff, record,
|
|
1407
|
+
{"entity": entity, "path": path},
|
|
1408
|
+
)
|
|
1409
|
+
self._requests[request_id] = (identity, response)
|
|
1410
|
+
return response
|
|
1411
|
+
|
|
1412
|
+
def components_of(self, entity):
|
|
1413
|
+
"""List the component names one entity carries.
|
|
1414
|
+
|
|
1415
|
+
Args:
|
|
1416
|
+
entity: Entity id to inspect (backfilled ids serve
|
|
1417
|
+
their projected ``doc`` component).
|
|
1418
|
+
|
|
1419
|
+
Returns:
|
|
1420
|
+
Mapping with project id, head revision, entity,
|
|
1421
|
+
storage path, and sorted component names.
|
|
1422
|
+
|
|
1423
|
+
Raises:
|
|
1424
|
+
EcsError: invalid_entity for a malformed id;
|
|
1425
|
+
unknown_entity for a missing entity;
|
|
1426
|
+
invalid_document for a corrupt entity record.
|
|
1427
|
+
"""
|
|
1428
|
+
validate_entity_id(entity)
|
|
1429
|
+
if is_backfill_id(entity):
|
|
1430
|
+
record = self._resolve_backfill(entity)
|
|
1431
|
+
return {
|
|
1432
|
+
"components": sorted(record["components"]),
|
|
1433
|
+
"current_revision": self._current_revision,
|
|
1434
|
+
"entity": entity,
|
|
1435
|
+
"path": record["components"][BACKFILL_COMPONENT]["path"],
|
|
1436
|
+
"project_id": self._project_id,
|
|
1437
|
+
}
|
|
1438
|
+
path, decoded = self._read_entity(entity)
|
|
1439
|
+
return {
|
|
1440
|
+
"components": sorted(decoded["components"]),
|
|
1441
|
+
"current_revision": self._current_revision,
|
|
1442
|
+
"entity": entity,
|
|
1443
|
+
"path": path,
|
|
1444
|
+
"project_id": self._project_id,
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
def entities_with(self, component, predicate=None):
|
|
1448
|
+
"""Filter entities to holders of one component.
|
|
1449
|
+
|
|
1450
|
+
Nets-of-block, pins-of-net, and open-problems become
|
|
1451
|
+
filters here, not endpoints; backfilled docs answer the
|
|
1452
|
+
``doc`` component, so current project files stay
|
|
1453
|
+
filterable without migration.
|
|
1454
|
+
|
|
1455
|
+
Args:
|
|
1456
|
+
component: Component name holders must carry.
|
|
1457
|
+
predicate: Exact-match mapping the component payload
|
|
1458
|
+
must satisfy, or None for no filtering.
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
Mapping with project id, head revision, component,
|
|
1462
|
+
predicate, and sorted holder ids.
|
|
1463
|
+
|
|
1464
|
+
Raises:
|
|
1465
|
+
EcsError: invalid_component for a malformed component
|
|
1466
|
+
name; invalid_predicate for a non-mapping
|
|
1467
|
+
predicate; invalid_document for a corrupt entity
|
|
1468
|
+
record.
|
|
1469
|
+
"""
|
|
1470
|
+
validate_component_name(component)
|
|
1471
|
+
validate_predicate(predicate)
|
|
1472
|
+
records = self._entity_records()
|
|
1473
|
+
if component == BACKFILL_COMPONENT:
|
|
1474
|
+
records.extend(self._backfill_records())
|
|
1475
|
+
return {
|
|
1476
|
+
"component": component,
|
|
1477
|
+
"current_revision": self._current_revision,
|
|
1478
|
+
"entities": filter_entities(records, component, predicate),
|
|
1479
|
+
"predicate": predicate,
|
|
1480
|
+
"project_id": self._project_id,
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
def world_snapshot(self, include_backfill=True):
|
|
1484
|
+
"""Bundle the miniplex-shaped world snapshot for mirrors.
|
|
1485
|
+
|
|
1486
|
+
A pure read: snapshotting never writes and never advances
|
|
1487
|
+
the revision, so backfill cannot break any revision.
|
|
1488
|
+
|
|
1489
|
+
Args:
|
|
1490
|
+
include_backfill: Project current plain docs as
|
|
1491
|
+
read-only virtual entities alongside engine
|
|
1492
|
+
entities; engine entities only when False.
|
|
1493
|
+
|
|
1494
|
+
Returns:
|
|
1495
|
+
Snapshot mapping with project id, head revision, and
|
|
1496
|
+
entities sorted by id (each with id, kind, and
|
|
1497
|
+
components).
|
|
1498
|
+
|
|
1499
|
+
Raises:
|
|
1500
|
+
EcsError: invalid_document for a corrupt entity
|
|
1501
|
+
record.
|
|
1502
|
+
"""
|
|
1503
|
+
records = self._entity_records()
|
|
1504
|
+
if include_backfill:
|
|
1505
|
+
records.extend(self._backfill_records())
|
|
1506
|
+
return build_world_snapshot(
|
|
1507
|
+
self._project_id, self._current_revision, records
|
|
1508
|
+
)
|