phronomy 0.26.0 → 0.27.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +4 -0
- data/VERIFY.sh +27 -27
- data/benchmark/bench_agent_invoke.rb +26 -22
- data/benchmark/bench_context_assembler.rb +4 -5
- data/docs/architecture/agent-chat-and-state-ownership.md +147 -0
- data/docs/architecture/agent-configuration-and-tool-binding.md +139 -0
- data/docs/architecture/agent-context.md +4 -2
- data/docs/architecture/agent-transition-ownership.md +89 -0
- data/docs/architecture/before-llm-input.md +6 -0
- data/docs/architecture/context-management.md +30 -0
- data/docs/architecture/context-preparation-steps.md +90 -0
- data/docs/architecture/entry-action-and-team-wording.md +72 -0
- data/docs/architecture/execution-metadata-and-values.md +80 -0
- data/docs/architecture/generator-verifier-ownership.md +111 -0
- data/docs/architecture/multi-agent-handoff.md +8 -2
- data/docs/architecture/persistence-refactoring-plan.md +50 -0
- data/docs/architecture/persistence.md +27 -4
- data/docs/architecture/refactoring-closure.md +334 -0
- data/docs/architecture/remaining-refactoring-plan.md +374 -0
- data/docs/architecture/rubyllm-2-token-ownership.md +82 -0
- data/docs/architecture/tool-schema-recording-gap.md +50 -0
- data/docs/architecture/tracing.md +1 -1
- data/docs/architecture/workflow-terminal-ownership-design.md +104 -0
- data/docs/architecture.md +209 -0
- data/docs/decisions/014-unified-persistence-durable-state.md +11 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +5 -1
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +13 -3
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +5 -1
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +5 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +5 -0
- data/docs/decisions/032-storage-backend-composition.md +80 -0
- data/docs/decisions/033-domain-persistence-ownership.md +79 -0
- data/docs/decisions/034-handoff-runner-coordination-ownership.md +76 -0
- data/docs/decisions/035-tool-executor-capability-ownership.md +68 -0
- data/docs/decisions/036-context-contract-ownership.md +86 -0
- data/docs/decisions/037-common-definition-ownership.md +62 -0
- data/docs/decisions/038-responsibility-based-source-layout.md +131 -0
- data/docs/decisions/039-runtime-configuration-lifecycle-ownership.md +74 -0
- data/docs/decisions/040-configuration-default-composition.md +94 -0
- data/docs/decisions/041-feature-owned-identity-registries.md +100 -0
- data/docs/decisions/042-feature-owned-execution-state.md +106 -0
- data/docs/decisions/043-storage-execution-constraint-notifications.md +75 -0
- data/docs/decisions/044-agent-default-and-one-shot-composition.md +106 -0
- data/docs/decisions/045-worker-input-restriction-ownership.md +100 -0
- data/docs/decisions/046-agent-responsibility-layout-and-shared-records.md +110 -0
- data/docs/decisions/047-recovered-execution-continuation-contract.md +116 -0
- data/docs/decisions/048-dispatch-preparation-worker-ownership.md +120 -0
- data/docs/decisions/049-initial-preparation-worker-ownership.md +111 -0
- data/docs/decisions/050-approval-resume-snapshot-and-commit-ownership.md +98 -0
- data/docs/decisions/051-execution-outcome-worker-ownership.md +151 -0
- data/docs/decisions/052-tool-invocation-restoration-ownership.md +92 -0
- data/docs/decisions/053-shared-state-coordination-ownership.md +71 -0
- data/docs/decisions/054-workflow-terminal-save-single-owner.md +76 -0
- data/docs/decisions/055-terminal-observer-failure-settlement.md +60 -0
- data/docs/decisions/056-workflow-terminal-policy-ownership.md +87 -0
- data/docs/decisions/057-storage-transaction-boundaries.md +73 -0
- data/docs/decisions/058-neutral-storage-primitives.md +78 -0
- data/docs/decisions/README.md +35 -6
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +6 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +6 -0
- data/docs/features.md +9 -3
- data/docs/getting-started.md +4 -4
- data/docs/migrations/0.15.md +5 -0
- data/docs/migrations/durable-semantic-coordination-v2.md +6 -0
- data/docs/migrations/handoff-runner-multi-agent.md +35 -0
- data/docs/migrations/neutral-storage-spi.md +43 -0
- data/docs/migrations/parallel-tool-chat-removal.md +49 -0
- data/docs/migrations/shared-state-multi-agent.md +44 -0
- data/docs/migrations/storage-backend-composition.md +117 -0
- data/docs/migrations/storage-transaction-boundaries.md +74 -0
- data/docs/persistence-backends.md +162 -204
- data/docs/runtime-and-concurrency.md +117 -32
- data/lib/phronomy/agent/api/agent.rb +17 -0
- data/lib/phronomy/agent/async_event_api.rb +2 -2
- data/lib/phronomy/agent/base.rb +51 -241
- data/lib/phronomy/{agent.rb → agent/composition/run_once.rb} +4 -13
- data/lib/phronomy/agent/context/capability/base.rb +67 -26
- data/lib/phronomy/agent/context/capability/tool_executor.rb +62 -0
- data/lib/phronomy/agent/{context_assembler.rb → context_assembly/context_assembler.rb} +143 -88
- data/lib/phronomy/agent/{context_importer.rb → context_assembly/context_importer.rb} +2 -2
- data/lib/phronomy/agent/{ruby_llm_materializer.rb → context_assembly/ruby_llm_materializer.rb} +4 -7
- data/lib/phronomy/agent/context_assembly/runtime_chat_builder.rb +36 -0
- data/lib/phronomy/agent/context_assembly/saved_context_reader.rb +53 -0
- data/lib/phronomy/agent/context_assembly/state_writer.rb +165 -0
- data/lib/phronomy/agent/context_assembly/token_budget_resolver.rb +22 -0
- data/lib/phronomy/agent/{context_plan.rb → context_contract/context_plan.rb} +1 -1
- data/lib/phronomy/agent/{context_policy_input.rb → context_contract/context_policy_input.rb} +6 -6
- data/lib/phronomy/agent/{llm_input_build_context.rb → context_contract/llm_input_build_context.rb} +1 -1
- data/lib/phronomy/agent/{llm_input_manifest.rb → context_contract/llm_input_manifest.rb} +20 -20
- data/lib/phronomy/agent/{llm_input_patch.rb → context_contract/llm_input_patch.rb} +2 -2
- data/lib/phronomy/agent/{agent_execution.rb → execution/agent_execution.rb} +8 -4
- data/lib/phronomy/agent/{agent_invocation.rb → execution/agent_invocation.rb} +8 -13
- data/lib/phronomy/agent/{agent_invocation_session_builder.rb → execution/agent_invocation_session_builder.rb} +19 -95
- data/lib/phronomy/agent/execution/approval_resume_commit.rb +108 -0
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +305 -0
- data/lib/phronomy/agent/{exact_execution.rb → execution/exact_execution.rb} +9 -10
- data/lib/phronomy/agent/{execution_cancellation.rb → execution/execution_cancellation.rb} +2 -3
- data/lib/phronomy/agent/execution/execution_coordinator.rb +1925 -0
- data/lib/phronomy/agent/execution/execution_failure.rb +30 -0
- data/lib/phronomy/agent/execution/execution_metadata.rb +53 -0
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +344 -0
- data/lib/phronomy/agent/execution/execution_registry.rb +459 -0
- data/lib/phronomy/agent/execution/execution_session_runner.rb +118 -0
- data/lib/phronomy/agent/execution/initial_preparation.rb +421 -0
- data/lib/phronomy/agent/execution/invocation_transitions.rb +86 -0
- data/lib/phronomy/agent/{phase_machine_builder.rb → execution/phase_machine_builder.rb} +20 -71
- data/lib/phronomy/agent/{provider_call_outcome.rb → execution/provider_call_outcome.rb} +9 -9
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +210 -0
- data/lib/phronomy/agent/{handoff_context.rb → handoff/handoff_context.rb} +2 -2
- data/lib/phronomy/agent/handoff/handoff_execution_coordinator.rb +15 -0
- data/lib/phronomy/agent/handoff/handoff_outcome_committer.rb +131 -0
- data/lib/phronomy/agent/{handoff_state.rb → handoff/handoff_state.rb} +1 -1
- data/lib/phronomy/agent/{journal_projection.rb → journal/journal_projection.rb} +4 -0
- data/lib/phronomy/agent/{journal_record.rb → journal/journal_record.rb} +3 -3
- data/lib/phronomy/agent/{llm_call_record.rb → journal/llm_call_record.rb} +2 -2
- data/lib/phronomy/agent/{agent_root.rb → lifecycle/agent_root.rb} +6 -2
- data/lib/phronomy/agent/lifecycle/default_persistence.rb +29 -0
- data/lib/phronomy/{engine/runtime/agent_ownership_registry.rb → agent/lifecycle/ownership_registry.rb} +24 -10
- data/lib/phronomy/{agent_already_exists_error.rb → agent/lifecycle_contract/agent_already_exists_error.rb} +2 -0
- data/lib/phronomy/{agent_busy_error.rb → agent/lifecycle_contract/agent_busy_error.rb} +2 -0
- data/lib/phronomy/{agent_purged_error.rb → agent/lifecycle_contract/agent_purged_error.rb} +2 -0
- data/lib/phronomy/agent/lifecycle_contract/handoff_error.rb +7 -0
- data/lib/phronomy/{stream_callback_error.rb → agent/lifecycle_contract/stream_callback_error.rb} +2 -0
- data/lib/phronomy/agent/persistence/agent_repository.rb +61 -0
- data/lib/phronomy/agent/persistence/codec.rb +358 -0
- data/lib/phronomy/agent/persistence/execution_repository.rb +108 -0
- data/lib/phronomy/agent/persistence/handoff_state_repository.rb +58 -0
- data/lib/phronomy/agent/persistence/journal_repository.rb +54 -0
- data/lib/phronomy/agent/persistence/queries.rb +61 -0
- data/lib/phronomy/agent/persistence/storage_schema.rb +24 -0
- data/lib/phronomy/agent/persistence/watermark.rb +27 -0
- data/lib/phronomy/agent/recovery/invocation_restorer.rb +132 -0
- data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/continuation.rb +21 -45
- data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/installation.rb +32 -37
- data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/resolution.rb +37 -40
- data/lib/phronomy/agent/{recovery_coordinator.rb → recovery/recovery_coordinator.rb} +6 -10
- data/lib/phronomy/agent/recovery/recovery_support.rb +227 -0
- data/lib/phronomy/agent/selection/candidate.rb +1 -1
- data/lib/phronomy/agent/{approval_evaluation_request.rb → tool_execution/approval_evaluation_request.rb} +1 -12
- data/lib/phronomy/agent/{tool_approval_request.rb → tool_execution/tool_approval_request.rb} +1 -10
- data/lib/phronomy/agent/tool_execution/tool_binding.rb +90 -0
- data/lib/phronomy/agent/{tool_call_intercepted.rb → tool_execution/tool_call_intercepted.rb} +2 -2
- data/lib/phronomy/agent/{tool_definition_set.rb → tool_execution/tool_definition_set.rb} +9 -4
- data/lib/phronomy/agent/{tool_invocation.rb → tool_execution/tool_invocation.rb} +69 -34
- data/lib/phronomy/common/configuration_error.rb +7 -0
- data/lib/phronomy/common/error.rb +5 -0
- data/lib/phronomy/{agent → common/values}/immutable.rb +9 -1
- data/lib/phronomy/common/values/serializable.rb +32 -0
- data/lib/phronomy/{configuration.rb → configuration/configuration.rb} +14 -5
- data/lib/phronomy/configuration/global_configuration.rb +26 -0
- data/lib/phronomy/content_store/storage_schema.rb +11 -0
- data/lib/phronomy/content_store/stored_contents.rb +43 -0
- data/lib/phronomy/engine/backpressure_error.rb +7 -0
- data/lib/phronomy/{blocking.rb → engine/blocking.rb} +1 -1
- data/lib/phronomy/engine/cancellation_error.rb +7 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +4 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +4 -0
- data/lib/phronomy/engine/concurrency/operation_binding.rb +1 -1
- data/lib/phronomy/engine/concurrency/worker_input_restricted.rb +14 -0
- data/lib/phronomy/engine/event_loop.rb +158 -624
- data/lib/phronomy/engine/event_loop_reentrancy_error.rb +8 -0
- data/lib/phronomy/{execution.rb → engine/execution.rb} +0 -7
- data/lib/phronomy/{execution_cancellation_error.rb → engine/execution_cancellation_error.rb} +2 -0
- data/lib/phronomy/engine/execution_receiver.rb +65 -0
- data/lib/phronomy/{execution_timeout_error.rb → engine/execution_timeout_error.rb} +2 -0
- data/lib/phronomy/engine/fsm_protocol.rb +14 -0
- data/lib/phronomy/engine/fsm_session.rb +35 -28
- data/lib/phronomy/{invalid_async_entry_action_error.rb → engine/invalid_async_entry_action_error.rb} +2 -0
- data/lib/phronomy/{invalid_async_transition_action_error.rb → engine/invalid_async_transition_action_error.rb} +2 -0
- data/lib/phronomy/{invalid_async_workflow_action_error.rb → engine/invalid_async_workflow_action_error.rb} +2 -0
- data/lib/phronomy/engine/pool_shutdown_error.rb +7 -0
- data/lib/phronomy/engine/recursion_limit_error.rb +7 -0
- data/lib/phronomy/engine/runtime.rb +80 -84
- data/lib/phronomy/engine/runtime_shutdown_error.rb +7 -0
- data/lib/phronomy/engine/runtime_shutdown_reentrancy_error.rb +7 -0
- data/lib/phronomy/engine/scheduler_reentrancy_error.rb +9 -0
- data/lib/phronomy/engine/task_result.rb +4 -0
- data/lib/phronomy/engine/timeout_error.rb +7 -0
- data/lib/phronomy/filter/contract/filter_block_error.rb +14 -0
- data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +89 -0
- data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +57 -0
- data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +112 -0
- data/lib/phronomy/generation/generator_verifier.rb +118 -0
- data/lib/phronomy/generation/low_confidence_error.rb +14 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +6 -7
- data/lib/phronomy/llm_contract/authentication_error.rb +7 -0
- data/lib/phronomy/{context_budget_exceeded_error.rb → llm_contract/context_budget_exceeded_error.rb} +2 -0
- data/lib/phronomy/llm_contract/context_length_error.rb +7 -0
- data/lib/phronomy/llm_contract/rate_limit_error.rb +7 -0
- data/lib/phronomy/{token_usage.rb → llm_contract/token_usage.rb} +2 -2
- data/lib/phronomy/llm_contract/transport_error.rb +7 -0
- data/lib/phronomy/multi_agent/admission_registry.rb +22 -2
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +5 -5
- data/lib/phronomy/{agent → multi_agent}/handoff_runner.rb +18 -17
- data/lib/phronomy/multi_agent/orchestrator.rb +2 -2
- data/lib/phronomy/multi_agent/persistence/codec.rb +55 -0
- data/lib/phronomy/multi_agent/persistence/queries.rb +30 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +108 -0
- data/lib/phronomy/multi_agent/persistence/team_repository.rb +61 -0
- data/lib/phronomy/{agent → multi_agent}/shared_state.rb +56 -39
- data/lib/phronomy/multi_agent/storage_contract/team_storage_schema.rb +15 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +21 -18
- data/lib/phronomy/multi_agent/team_execution.rb +1 -1
- data/lib/phronomy/{engine/runtime → multi_agent}/team_ownership_registry.rb +14 -4
- data/lib/phronomy/multi_agent/team_root.rb +1 -1
- data/lib/phronomy/output_parser/contract/parse_error.rb +7 -0
- data/lib/phronomy/persistence/api/persistence.rb +140 -0
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +19 -19
- data/lib/phronomy/persistence_composition/repositories.rb +77 -0
- data/lib/phronomy/persistence_composition/storage_schema.rb +24 -0
- data/lib/phronomy/{execution_rehydration_required_error.rb → recovery/execution_rehydration_required_error.rb} +2 -0
- data/lib/phronomy/{recovery.rb → recovery/recovery.rb} +1 -1
- data/lib/phronomy/runtime_composition/agent_defaults.rb +7 -0
- data/lib/phronomy/runtime_composition/configuration_defaults.rb +9 -0
- data/lib/phronomy/runtime_composition/global_runtime.rb +19 -0
- data/lib/phronomy/storage/backend.rb +101 -0
- data/lib/phronomy/storage/backends/in_memory.rb +157 -0
- data/lib/phronomy/storage/blob_conflict_error.rb +10 -0
- data/lib/phronomy/storage/blobs.rb +31 -0
- data/lib/phronomy/storage/condition.rb +25 -0
- data/lib/phronomy/storage/condition_failed_error.rb +16 -0
- data/lib/phronomy/storage/conflict_error.rb +9 -0
- data/lib/phronomy/{persistence → storage}/durable_record.rb +12 -12
- data/lib/phronomy/storage/entry.rb +33 -0
- data/lib/phronomy/storage/guard_ref.rb +13 -0
- data/lib/phronomy/storage/not_found_error.rb +9 -0
- data/lib/phronomy/storage/record_codec.rb +177 -0
- data/lib/phronomy/storage/records.rb +61 -0
- data/lib/phronomy/storage/resource.rb +126 -0
- data/lib/phronomy/storage/scope.rb +25 -0
- data/lib/phronomy/storage/serialization_error.rb +9 -0
- data/lib/phronomy/storage/streams.rb +44 -0
- data/lib/phronomy/storage/transaction_error.rb +10 -0
- data/lib/phronomy/storage/unique_constraint_error.rb +17 -0
- data/lib/phronomy/storage/unsupported_backend_error.rb +9 -0
- data/lib/phronomy/storage/validation.rb +53 -0
- data/lib/phronomy/storage/view.rb +131 -0
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +4 -4
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +10 -7
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +6 -6
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +6 -6
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +8 -8
- data/lib/phronomy/testing/persistence_contract/neutral_storage_primitives.rb +263 -0
- data/lib/phronomy/testing/persistence_contract/storage_transaction_boundaries.rb +123 -0
- data/lib/phronomy/testing/persistence_contract.rb +4 -0
- data/lib/phronomy/tool/contract/tool_error.rb +7 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/{workflow.rb → workflow/execution/workflow.rb} +8 -6
- data/lib/phronomy/{workflow_context.rb → workflow/execution/workflow_context.rb} +4 -0
- data/lib/phronomy/workflow/execution/workflow_context_ownership_error.rb +7 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +188 -0
- data/lib/phronomy/{workflow_runner.rb → workflow/execution/workflow_runner.rb} +122 -71
- data/lib/phronomy/workflow/execution/workflow_terminal_policy.rb +40 -0
- data/lib/phronomy/workflow/persistence/codec.rb +153 -0
- data/lib/phronomy/workflow/persistence/state_repository.rb +57 -0
- data/lib/phronomy/workflow/phase_machine_builder.rb +2 -2
- data/lib/phronomy/workflow/storage_contract/workflow_storage_schema.rb +9 -0
- data/lib/phronomy.rb +52 -94
- data/scripts/api_snapshot.rb +1 -1
- data/scripts/storage_spi_snapshot.rb +36 -0
- data/sig/phronomy/agent.rbs +0 -1
- data/sig/phronomy/execution_receiver.rbs +34 -0
- data/sig/phronomy/handoff.rbs +4 -2
- data/sig/phronomy/multi_agent.rbs +18 -0
- data/sig/phronomy/persistence.rbs +7 -91
- data/sig/phronomy/storage.rbs +174 -0
- data/sig/phronomy/tool.rbs +10 -1
- metadata +230 -99
- data/lib/phronomy/agent/execution_coordinator.rb +0 -3151
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +0 -143
- data/lib/phronomy/agent/recovery_support.rb +0 -504
- data/lib/phronomy/agent/token_budget_resolver.rb +0 -70
- data/lib/phronomy/agent/tool_executor.rb +0 -55
- data/lib/phronomy/generator_verifier.rb +0 -369
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +0 -8
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +0 -116
- data/lib/phronomy/persistence/durable_codec.rb +0 -706
- data/lib/phronomy/persistence/in_memory.rb +0 -690
- data/lib/phronomy/persistence/repository_facades.rb +0 -535
- data/lib/phronomy/persistence.rb +0 -276
- data/lib/phronomy/ruby_llm_patches.rb +0 -24
- data/lib/phronomy/workflow_recovery.rb +0 -123
- /data/lib/phronomy/agent/{context_candidate_resolver.rb → context_assembly/context_candidate_resolver.rb} +0 -0
- /data/lib/phronomy/agent/{context_policy_input_builder.rb → context_assembly/context_policy_input_builder.rb} +0 -0
- /data/lib/phronomy/agent/{context_plan_validator.rb → context_contract/context_plan_validator.rb} +0 -0
- /data/lib/phronomy/agent/{context_policy.rb → context_contract/context_policy.rb} +0 -0
- /data/lib/phronomy/agent/{llm_operation_result.rb → execution/llm_operation_result.rb} +0 -0
- /data/lib/phronomy/agent/{handoff.rb → handoff/handoff.rb} +0 -0
- /data/lib/phronomy/agent/{handoff_capability_factory.rb → handoff/handoff_capability_factory.rb} +0 -0
- /data/lib/phronomy/agent/{handoff_policy.rb → handoff/handoff_policy.rb} +0 -0
- /data/lib/phronomy/agent/{handoff_projection.rb → handoff/handoff_projection.rb} +0 -0
- /data/lib/phronomy/agent/{handoff_request.rb → handoff/handoff_request.rb} +0 -0
- /data/lib/phronomy/agent/{tool_invocation_session_builder.rb → tool_execution/tool_invocation_session_builder.rb} +0 -0
- /data/lib/phronomy/{canonical_json.rb → common/canonical_json.rb} +0 -0
- /data/lib/phronomy/{diagnostics.rb → engine/diagnostics.rb} +0 -0
- /data/lib/phronomy/{event.rb → engine/event.rb} +0 -0
- /data/lib/phronomy/{invocation_context.rb → engine/invocation_context.rb} +0 -0
- /data/lib/phronomy/{metrics.rb → engine/metrics.rb} +0 -0
- /data/lib/phronomy/{runnable.rb → engine/runnable.rb} +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# Neutral storage SPI. Domain resource declarations are supplied by composition.
|
|
6
|
+
# @api public
|
|
7
|
+
class Backend
|
|
8
|
+
REQUIRED_CAPABILITIES = {spi_version: 2, atomic_resources: true,
|
|
9
|
+
record_cas: true, stream_cas: true, conditional_unique: true,
|
|
10
|
+
guarded_checks: true, nested_savepoints: true}.freeze
|
|
11
|
+
attr_reader :resources, :view
|
|
12
|
+
|
|
13
|
+
# @api public
|
|
14
|
+
def initialize(resources:)
|
|
15
|
+
unless resources.is_a?(Array) && resources.all? { |resource| resource.is_a?(Resource) }
|
|
16
|
+
raise UnsupportedBackendError, "SPI 2 requires Resource declarations"
|
|
17
|
+
end
|
|
18
|
+
@resources = resources.to_h { |resource| [resource.id, resource] }.freeze
|
|
19
|
+
raise UnsupportedBackendError, "duplicate resource identity" unless @resources.length == resources.length
|
|
20
|
+
@resources.each_value do |resource|
|
|
21
|
+
next unless resource.guard
|
|
22
|
+
anchor = @resources[resource.guard.fetch(:resource)]
|
|
23
|
+
raise UnsupportedBackendError, "guard must name a registered records resource" unless anchor && anchor.kind == :records
|
|
24
|
+
end
|
|
25
|
+
@view = View.new(self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Reject the previous eight-repository SPI before constructing domain wrappers.
|
|
29
|
+
# @api public
|
|
30
|
+
def self.validate_capabilities!(backend)
|
|
31
|
+
capabilities = backend.respond_to?(:capabilities) ? backend.capabilities : {}
|
|
32
|
+
missing = REQUIRED_CAPABILITIES.reject { |key, value| capabilities.is_a?(Hash) && capabilities[key] == value }
|
|
33
|
+
unless missing.empty? && backend.respond_to?(:view) && backend.respond_to?(:transaction)
|
|
34
|
+
raise UnsupportedBackendError, "Persistence requires Storage SPI 2: #{missing.keys.join(", ")}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @api public
|
|
39
|
+
def capabilities
|
|
40
|
+
REQUIRED_CAPABILITIES.transform_values { |value| (value == true) ? false : value }.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# All declared resources participate in one atomic transaction domain.
|
|
44
|
+
# Record and stream revisions provide compare-and-swap conflict detection.
|
|
45
|
+
# This does not
|
|
46
|
+
# mean cross-process Workflow admission or distributed locking.
|
|
47
|
+
# Storage failures whose commit outcome is fundamentally unknown remain
|
|
48
|
+
# database failures; Phronomy does not claim exactly-once semantics.
|
|
49
|
+
# Explicit nesting uses savepoints. Non-local block exits raise and roll back.
|
|
50
|
+
# @api public
|
|
51
|
+
def transaction
|
|
52
|
+
current_view&.validate!
|
|
53
|
+
storage_transaction do |context|
|
|
54
|
+
scope = Scope.new
|
|
55
|
+
bound = View.new(self, scope: scope, context: context)
|
|
56
|
+
views = stack
|
|
57
|
+
views.push(bound)
|
|
58
|
+
completed = false
|
|
59
|
+
begin
|
|
60
|
+
result = yield bound
|
|
61
|
+
scope.check!
|
|
62
|
+
completed = true
|
|
63
|
+
result
|
|
64
|
+
ensure
|
|
65
|
+
scope.close!
|
|
66
|
+
views.pop
|
|
67
|
+
Thread.current.thread_variable_get(:phronomy_storage_scopes)&.delete(self) if views.empty?
|
|
68
|
+
if !completed && $!.nil?
|
|
69
|
+
raise TransactionError, "transaction block must complete normally; non-local exit rolled back"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @api private
|
|
76
|
+
def current_view
|
|
77
|
+
Thread.current.thread_variable_get(:phronomy_storage_scopes)&.dig(self)&.last.tap { |bound| bound&.validate! }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @api private
|
|
81
|
+
def execute(context, operation, resource, **arguments)
|
|
82
|
+
send(operation, context, resource, **arguments)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def stack
|
|
88
|
+
scopes = Thread.current.thread_variable_get(:phronomy_storage_scopes)
|
|
89
|
+
unless scopes
|
|
90
|
+
scopes = {}
|
|
91
|
+
Thread.current.thread_variable_set(:phronomy_storage_scopes, scopes)
|
|
92
|
+
end
|
|
93
|
+
scopes[self] ||= []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def storage_transaction
|
|
97
|
+
raise UnsupportedBackendError, "backend must implement an atomic storage transaction"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Storage
|
|
7
|
+
module Backends
|
|
8
|
+
# One Monitor and snapshot cover every declared resource and nested scope.
|
|
9
|
+
# @api public
|
|
10
|
+
class InMemory < Backend
|
|
11
|
+
# @api public
|
|
12
|
+
def initialize(resources: [])
|
|
13
|
+
super
|
|
14
|
+
@monitor = Monitor.new
|
|
15
|
+
@state = resources.to_h { |resource| [resource.id, {}] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @api public
|
|
19
|
+
def capabilities = REQUIRED_CAPABILITIES
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def storage_transaction
|
|
24
|
+
@monitor.synchronize do
|
|
25
|
+
snapshot = Marshal.load(Marshal.dump(@state))
|
|
26
|
+
completed = false
|
|
27
|
+
begin
|
|
28
|
+
result = yield @state
|
|
29
|
+
completed = true
|
|
30
|
+
result
|
|
31
|
+
ensure
|
|
32
|
+
@state.replace(snapshot) unless completed
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def bucket(state, resource) = state.fetch(resource.id)
|
|
38
|
+
|
|
39
|
+
def read_record(state, resource, key:)
|
|
40
|
+
value = bucket(state, resource)[key]
|
|
41
|
+
value && Entry::Record.new(**value.to_h)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def lock_guard(state, resource, key:)
|
|
45
|
+
raise NotFoundError, "guard record not found: #{resource.id}/#{key}" unless bucket(state, resource).key?(key)
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def guard_record(state, resource, key:, attributes: {}, creating: false)
|
|
50
|
+
guard = resource.guard
|
|
51
|
+
return unless guard
|
|
52
|
+
anchor = resources.fetch(guard.fetch(:resource))
|
|
53
|
+
return if creating && anchor.equal?(resource) && guard[:via] == :key
|
|
54
|
+
guard_key = [:key, :stream].include?(guard[:via]) ? key : attributes.fetch(guard[:via])
|
|
55
|
+
lock_guard(state, anchor, key: guard_key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_unique(state, resource, entry)
|
|
59
|
+
resource.unique.each do |constraint|
|
|
60
|
+
next if constraint[:fields].any? { |key| entry.attributes[key].nil? }
|
|
61
|
+
next unless constraint.fetch(:where).all? { |key, value| entry.attributes[key] == value }
|
|
62
|
+
conflict = bucket(state, resource).values.any? do |other|
|
|
63
|
+
other.key != entry.key && constraint[:where].all? { |k, val| other.attributes[k] == val } && constraint[:fields].all? { |k| other.attributes[k] == entry.attributes[k] }
|
|
64
|
+
end
|
|
65
|
+
raise UniqueConstraintError.new(resource: resource, constraint: constraint[:name]) if conflict
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def insert_record(state, resource, entry:)
|
|
70
|
+
raise ConflictError, "duplicate record identity" if bucket(state, resource).key?(entry.key)
|
|
71
|
+
guard_record(state, resource, key: entry.key, attributes: entry.attributes, creating: true)
|
|
72
|
+
check_unique(state, resource, entry)
|
|
73
|
+
bucket(state, resource)[entry.key] = entry
|
|
74
|
+
Entry::Record.new(**entry.to_h)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def replace_record(state, resource, entry:, expected_revision:, expected_attributes:)
|
|
78
|
+
current = bucket(state, resource)[entry.key] || raise(NotFoundError, "record not found: #{entry.key}")
|
|
79
|
+
guard_record(state, resource, key: entry.key, attributes: current.attributes)
|
|
80
|
+
raise ConflictError, "record revision conflict" unless current.revision == expected_revision
|
|
81
|
+
unless expected_attributes.all? { |key, value| current.attributes[key] == value } && resource.immutable_attributes.all? { |key| current.attributes[key] == entry.attributes[key] }
|
|
82
|
+
raise ConflictError, "record attribute precondition failed"
|
|
83
|
+
end
|
|
84
|
+
check_unique(state, resource, entry)
|
|
85
|
+
bucket(state, resource)[entry.key] = entry
|
|
86
|
+
Entry::Record.new(**entry.to_h)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def delete_record(state, resource, key:, expected_revision:)
|
|
90
|
+
current = bucket(state, resource)[key]
|
|
91
|
+
if !expected_revision.equal?(Records::UNCHECKED) && (!current || current.revision != expected_revision)
|
|
92
|
+
raise ConflictError, "record deletion revision conflict"
|
|
93
|
+
end
|
|
94
|
+
return nil unless current
|
|
95
|
+
guard_record(state, resource, key: key, attributes: current.attributes)
|
|
96
|
+
bucket(state, resource).delete(key)
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def scan_records(state, resource, equals:, after:, limit:)
|
|
101
|
+
entries = bucket(state, resource).values.select { |entry| (!after || entry.key.b > after.b) && equals.all? { |key, value| entry.attributes[key] == value } }.sort_by { |entry| entry.key.b }
|
|
102
|
+
entries = entries.first(limit) if limit
|
|
103
|
+
entries.map { |entry| Entry::Record.new(**entry.to_h) }.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def delete_matching_records(state, resource, equals:)
|
|
107
|
+
if resource.guard && ![:key, :stream].include?(resource.guard[:via])
|
|
108
|
+
raise ArgumentError, "deletion must constrain its guard attribute" unless equals.key?(resource.guard[:via])
|
|
109
|
+
guard_record(state, resource, key: "unused", attributes: equals)
|
|
110
|
+
end
|
|
111
|
+
scan_records(state, resource, equals: equals, after: nil, limit: nil).each do |entry|
|
|
112
|
+
delete_record(state, resource, key: entry.key, expected_revision: Records::UNCHECKED)
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def append_stream(state, resource, stream:, expected_head:, entries:)
|
|
118
|
+
guard_record(state, resource, key: stream)
|
|
119
|
+
stored = bucket(state, resource).fetch(stream, [])
|
|
120
|
+
raise ConflictError, "stream head conflict" unless stored.length == expected_head
|
|
121
|
+
raise ConflictError, "duplicate stream entry identity" unless (stored.map(&:id) & entries.map(&:id)).empty?
|
|
122
|
+
appended = entries.each_with_index.map { |entry, index| Entry::Stream.new(position: expected_head + index + 1, id: entry.id, record: entry.record) }
|
|
123
|
+
bucket(state, resource)[stream] = stored + appended unless appended.empty?
|
|
124
|
+
appended.map { |entry| Entry::Stream.new(**entry.to_h) }.freeze
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def read_stream(state, resource, stream:, after:, limit:)
|
|
128
|
+
selected = bucket(state, resource).fetch(stream, []).select { |entry| entry.position > after }
|
|
129
|
+
selected = selected.first(limit) if limit
|
|
130
|
+
selected.map { |entry| Entry::Stream.new(**entry.to_h) }.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def stream_head(state, resource, stream:) = bucket(state, resource).fetch(stream, []).length
|
|
134
|
+
|
|
135
|
+
def delete_stream(state, resource, stream:)
|
|
136
|
+
guard_record(state, resource, key: stream)
|
|
137
|
+
bucket(state, resource).delete(stream)
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def put_blob(state, resource, blob:)
|
|
142
|
+
stored = bucket(state, resource)[blob.key]
|
|
143
|
+
raise BlobConflictError, "blob bytes are immutable" if stored && stored.bytes != blob.bytes
|
|
144
|
+
stored ||= bucket(state, resource)[blob.key] = blob
|
|
145
|
+
Entry::Blob.new(**stored.to_h)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fetch_blob(state, resource, key:)
|
|
149
|
+
stored = bucket(state, resource)[key] || raise(NotFoundError, "blob not found: #{key}")
|
|
150
|
+
Entry::Blob.new(**stored.to_h)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def blob_exists(state, resource, key:) = bucket(state, resource).key?(key)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# Immutable byte values sharing the same transaction domain as other resources.
|
|
6
|
+
# @api public
|
|
7
|
+
class Blobs
|
|
8
|
+
attr_reader :resource
|
|
9
|
+
def initialize(view, resource) = (@view, @resource = view, resource)
|
|
10
|
+
|
|
11
|
+
# @api public
|
|
12
|
+
def put_if_absent(key:, bytes:, attributes:)
|
|
13
|
+
@view.validate!
|
|
14
|
+
blob = Entry::Blob.new(key: key, bytes: bytes, attributes: resource.validate_attributes(attributes))
|
|
15
|
+
@view.perform(:put_blob, resource, blob: blob)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @api public
|
|
19
|
+
def fetch(key)
|
|
20
|
+
@view.validate!
|
|
21
|
+
@view.perform(:fetch_blob, resource, key: Validation.key(key))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @api public
|
|
25
|
+
def exist?(key)
|
|
26
|
+
@view.validate!
|
|
27
|
+
@view.perform(:blob_exists, resource, key: Validation.key(key))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# A deliberately closed set of conditions; no payload predicates or callbacks.
|
|
6
|
+
# @api public
|
|
7
|
+
module Condition
|
|
8
|
+
RevisionIs = Data.define(:resource, :key, :expected) do
|
|
9
|
+
def initialize(resource:, key:, expected:)
|
|
10
|
+
super(resource: Resource.normalize_reference(resource), key: Validation.key(key), expected: Validation.revision(expected))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
StreamHeadIs = Data.define(:resource, :stream, :expected) do
|
|
14
|
+
def initialize(resource:, stream:, expected:)
|
|
15
|
+
super(resource: Resource.normalize_reference(resource), stream: Validation.key(stream), expected: Validation.revision(expected))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
NoRows = Data.define(:resource, :index, :equals) do
|
|
19
|
+
def initialize(resource:, index:, equals:)
|
|
20
|
+
super(resource: Resource.normalize_reference(resource), index: index, equals: Validation.immutable(equals))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# A neutral storage contract failure.
|
|
6
|
+
# @api public
|
|
7
|
+
class ConditionFailedError < ConflictError
|
|
8
|
+
attr_reader :condition
|
|
9
|
+
# @api public
|
|
10
|
+
def initialize(condition)
|
|
11
|
+
@condition = condition
|
|
12
|
+
super("storage condition failed: #{condition.class.name}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
|
|
4
|
+
module Storage
|
|
5
5
|
# Immutable logical record exchanged across the Persistence Backend SPI.
|
|
6
6
|
#
|
|
7
7
|
# Phronomy owns +record_type+, +format_version+, and the semantic meaning of
|
|
@@ -28,20 +28,20 @@ module Phronomy
|
|
|
28
28
|
# @api public
|
|
29
29
|
def initialize(record_type: MISSING, format_version: MISSING, payload: MISSING)
|
|
30
30
|
if record_type.equal?(MISSING)
|
|
31
|
-
raise Phronomy::
|
|
31
|
+
raise Phronomy::Storage::SerializationError, "durable record_type is missing"
|
|
32
32
|
end
|
|
33
33
|
if format_version.equal?(MISSING)
|
|
34
|
-
raise Phronomy::
|
|
34
|
+
raise Phronomy::Storage::SerializationError, "durable format_version is missing"
|
|
35
35
|
end
|
|
36
36
|
if payload.equal?(MISSING)
|
|
37
|
-
raise Phronomy::
|
|
37
|
+
raise Phronomy::Storage::SerializationError, "durable payload is missing"
|
|
38
38
|
end
|
|
39
39
|
unless record_type.is_a?(String)
|
|
40
|
-
raise Phronomy::
|
|
40
|
+
raise Phronomy::Storage::SerializationError,
|
|
41
41
|
"durable record_type must be a String"
|
|
42
42
|
end
|
|
43
43
|
unless format_version.is_a?(String)
|
|
44
|
-
raise Phronomy::
|
|
44
|
+
raise Phronomy::Storage::SerializationError,
|
|
45
45
|
"durable format_version must be a String"
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -51,10 +51,10 @@ module Phronomy
|
|
|
51
51
|
validate_payload!(payload)
|
|
52
52
|
@payload = immutable_copy(payload)
|
|
53
53
|
freeze
|
|
54
|
-
rescue Phronomy::
|
|
54
|
+
rescue Phronomy::Storage::SerializationError
|
|
55
55
|
raise
|
|
56
56
|
rescue => error
|
|
57
|
-
raise Phronomy::
|
|
57
|
+
raise Phronomy::Storage::SerializationError, error.message
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
# Returns an independent immutable record with the same logical value.
|
|
@@ -75,26 +75,26 @@ module Phronomy
|
|
|
75
75
|
|
|
76
76
|
def validate_header!
|
|
77
77
|
if record_type.empty?
|
|
78
|
-
raise Phronomy::
|
|
78
|
+
raise Phronomy::Storage::SerializationError,
|
|
79
79
|
"durable record_type must not be empty"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
return if FORMAT_VERSION_PATTERN.match?(format_version)
|
|
83
83
|
|
|
84
|
-
raise Phronomy::
|
|
84
|
+
raise Phronomy::Storage::SerializationError,
|
|
85
85
|
"invalid durable format_version: #{format_version.inspect}"
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def validate_payload!(payload)
|
|
89
89
|
unless payload.is_a?(Hash)
|
|
90
|
-
raise Phronomy::
|
|
90
|
+
raise Phronomy::Storage::SerializationError,
|
|
91
91
|
"durable payload must be a Hash"
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
Phronomy::CanonicalJSON.dump(payload)
|
|
95
95
|
true
|
|
96
96
|
rescue ArgumentError => error
|
|
97
|
-
raise Phronomy::
|
|
97
|
+
raise Phronomy::Storage::SerializationError,
|
|
98
98
|
"durable payload is not canonical JSON compatible: #{error.message}"
|
|
99
99
|
end
|
|
100
100
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# Values crossing the driver boundary never expose mutable storage state.
|
|
6
|
+
# @api public
|
|
7
|
+
module Entry
|
|
8
|
+
Record = Data.define(:key, :revision, :attributes, :record) do
|
|
9
|
+
def initialize(key:, revision:, attributes:, record:)
|
|
10
|
+
super(key: Validation.key(key), revision: Validation.revision(revision),
|
|
11
|
+
attributes: Validation.attributes(attributes), record: Validation.record(record))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
Append = Data.define(:id, :record) do
|
|
15
|
+
def initialize(id:, record:)
|
|
16
|
+
super(id: Validation.key(id), record: Validation.record(record))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
Stream = Data.define(:position, :id, :record) do
|
|
20
|
+
def initialize(position:, id:, record:)
|
|
21
|
+
super(position: Validation.revision(position), id: Validation.key(id), record: Validation.record(record))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
Blob = Data.define(:key, :bytes, :attributes) do
|
|
25
|
+
def initialize(key:, bytes:, attributes:)
|
|
26
|
+
raise ArgumentError, "blob bytes must be a String" unless bytes.is_a?(String)
|
|
27
|
+
super(key: Validation.key(key), bytes: bytes.dup.force_encoding(Encoding::BINARY).freeze,
|
|
28
|
+
attributes: Validation.attributes(attributes))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# A stable existing record used as a transaction lock anchor.
|
|
6
|
+
# @api public
|
|
7
|
+
GuardRef = Data.define(:resource, :key) do
|
|
8
|
+
def initialize(resource:, key:)
|
|
9
|
+
super(resource: Resource.normalize_reference(resource), key: Validation.key(key))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Storage
|
|
5
|
+
# Shared record-envelope and scalar validation; domain schemas live with their domains.
|
|
6
|
+
# @api private
|
|
7
|
+
module RecordCodec
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def current_payload!(record, record_type:, format_version:, keys:, label:)
|
|
11
|
+
unless record.is_a?(Phronomy::Storage::DurableRecord)
|
|
12
|
+
raise Phronomy::Storage::SerializationError,
|
|
13
|
+
"backend returned #{record.class}; expected Storage::DurableRecord"
|
|
14
|
+
end
|
|
15
|
+
unless record.record_type == record_type
|
|
16
|
+
raise Phronomy::Storage::SerializationError,
|
|
17
|
+
"durable record type mismatch: expected #{record_type.inspect}, " \
|
|
18
|
+
"got #{record.record_type.inspect}"
|
|
19
|
+
end
|
|
20
|
+
unless record.format_version == format_version
|
|
21
|
+
raise Phronomy::Storage::SerializationError,
|
|
22
|
+
"unsupported #{record_type} format version: #{record.format_version.inspect}; " \
|
|
23
|
+
"current version is #{format_version.inspect}"
|
|
24
|
+
end
|
|
25
|
+
validate_exact_keys!(record.payload, keys, label: label)
|
|
26
|
+
record.payload
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_allowed_keys!(hash, required_keys:, optional_keys:, label:)
|
|
30
|
+
unless hash.is_a?(Hash)
|
|
31
|
+
raise Phronomy::Storage::SerializationError, "#{label} must be a Hash"
|
|
32
|
+
end
|
|
33
|
+
unless hash.keys.all? { |key| key.is_a?(String) }
|
|
34
|
+
raise Phronomy::Storage::SerializationError,
|
|
35
|
+
"#{label} keys must all be String"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
actual = hash.keys.sort
|
|
39
|
+
missing = required_keys.sort - actual
|
|
40
|
+
unknown = actual - (required_keys + optional_keys).sort
|
|
41
|
+
return hash if missing.empty? && unknown.empty?
|
|
42
|
+
|
|
43
|
+
details = []
|
|
44
|
+
details << "missing=#{missing.inspect}" unless missing.empty?
|
|
45
|
+
details << "unknown=#{unknown.inspect}" unless unknown.empty?
|
|
46
|
+
raise Phronomy::Storage::SerializationError,
|
|
47
|
+
"#{label} schema mismatch (#{details.join(", ")})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_exact_keys!(hash, expected_keys, label:)
|
|
51
|
+
unless hash.is_a?(Hash)
|
|
52
|
+
raise Phronomy::Storage::SerializationError, "#{label} must be a Hash"
|
|
53
|
+
end
|
|
54
|
+
unless hash.keys.all? { |key| key.is_a?(String) }
|
|
55
|
+
raise Phronomy::Storage::SerializationError,
|
|
56
|
+
"#{label} keys must all be String"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
actual = hash.keys.sort
|
|
60
|
+
expected = expected_keys.sort
|
|
61
|
+
return hash if actual == expected
|
|
62
|
+
|
|
63
|
+
missing = expected - actual
|
|
64
|
+
unknown = actual - expected
|
|
65
|
+
details = []
|
|
66
|
+
details << "missing=#{missing.inspect}" unless missing.empty?
|
|
67
|
+
details << "unknown=#{unknown.inspect}" unless unknown.empty?
|
|
68
|
+
raise Phronomy::Storage::SerializationError,
|
|
69
|
+
"#{label} schema mismatch (#{details.join(", ")})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def top_level_string_keys(value, label:)
|
|
73
|
+
unless value.is_a?(Hash)
|
|
74
|
+
raise Phronomy::Storage::SerializationError, "#{label} must be a Hash"
|
|
75
|
+
end
|
|
76
|
+
value.each_with_object({}) do |(key, child), result|
|
|
77
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
78
|
+
raise Phronomy::Storage::SerializationError,
|
|
79
|
+
"#{label} key must be String or Symbol, got #{key.class}"
|
|
80
|
+
end
|
|
81
|
+
string_key = key.to_s
|
|
82
|
+
if result.key?(string_key)
|
|
83
|
+
raise Phronomy::Storage::SerializationError,
|
|
84
|
+
"#{label} contains duplicate key after normalization: #{string_key.inspect}"
|
|
85
|
+
end
|
|
86
|
+
result[string_key] = child
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def require_nonempty_string!(hash, key, label:)
|
|
91
|
+
value = hash.fetch(key)
|
|
92
|
+
return value if value.is_a?(String) && !value.empty?
|
|
93
|
+
|
|
94
|
+
raise Phronomy::Storage::SerializationError,
|
|
95
|
+
"#{label} #{key} must be a non-empty String"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def require_optional_string!(hash, key, label:)
|
|
99
|
+
value = hash.fetch(key)
|
|
100
|
+
return value if value.nil? || value.is_a?(String)
|
|
101
|
+
|
|
102
|
+
raise Phronomy::Storage::SerializationError,
|
|
103
|
+
"#{label} #{key} must be a String or nil"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def require_integer!(hash, key, label:)
|
|
107
|
+
value = hash.fetch(key)
|
|
108
|
+
return value if value.is_a?(Integer)
|
|
109
|
+
|
|
110
|
+
raise Phronomy::Storage::SerializationError,
|
|
111
|
+
"#{label} #{key} must be an Integer"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def require_positive_integer!(hash, key, label:)
|
|
115
|
+
value = hash.fetch(key)
|
|
116
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
117
|
+
|
|
118
|
+
raise Phronomy::Storage::SerializationError,
|
|
119
|
+
"#{label} #{key} must be a positive Integer"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def require_nonnegative_integer!(hash, key, label:)
|
|
123
|
+
value = hash.fetch(key)
|
|
124
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
125
|
+
|
|
126
|
+
raise Phronomy::Storage::SerializationError,
|
|
127
|
+
"#{label} #{key} must be a non-negative Integer"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def require_boolean!(hash, key, label:)
|
|
131
|
+
value = hash.fetch(key)
|
|
132
|
+
return value if boolean?(value)
|
|
133
|
+
|
|
134
|
+
raise Phronomy::Storage::SerializationError,
|
|
135
|
+
"#{label} #{key} must be true or false"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def require_enum_string!(hash, key, allowed, label:)
|
|
139
|
+
value = hash.fetch(key)
|
|
140
|
+
return value if value.is_a?(String) && allowed.include?(value)
|
|
141
|
+
|
|
142
|
+
raise Phronomy::Storage::SerializationError,
|
|
143
|
+
"#{label} #{key} must be one of #{allowed.inspect}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def require_canonical_hash!(hash, key, label:)
|
|
147
|
+
value = hash.fetch(key)
|
|
148
|
+
unless value.is_a?(Hash)
|
|
149
|
+
raise Phronomy::Storage::SerializationError,
|
|
150
|
+
"#{label} #{key} must be a Hash"
|
|
151
|
+
end
|
|
152
|
+
Phronomy::CanonicalJSON.dump(value)
|
|
153
|
+
value
|
|
154
|
+
rescue ArgumentError => error
|
|
155
|
+
raise Phronomy::Storage::SerializationError,
|
|
156
|
+
"#{label} #{key} is not canonical JSON compatible: #{error.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def boolean?(value)
|
|
160
|
+
value.equal?(true) || value.equal?(false)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_record(record_type, format_version, payload)
|
|
164
|
+
Phronomy::Storage::DurableRecord.new(
|
|
165
|
+
record_type: record_type,
|
|
166
|
+
format_version: format_version,
|
|
167
|
+
payload: payload
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def serialization_error(prefix, error)
|
|
172
|
+
raise Phronomy::Storage::SerializationError,
|
|
173
|
+
"#{prefix}: #{error.class}: #{error.message}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|