familia 2.0.0.pre19 → 2.0.0.pre22
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/.github/workflows/claude-code-review.yml +4 -9
- data/.github/workflows/code-smells.yml +64 -3
- data/.pre-commit-config.yaml +8 -6
- data/.reek.yml +10 -9
- data/.rubocop.yml +4 -0
- data/.talismanrc +5 -1
- data/CHANGELOG.rst +220 -112
- data/CLAUDE.md +28 -1
- data/Gemfile +1 -1
- data/Gemfile.lock +20 -17
- data/bin/try +16 -0
- data/bin/tryouts +16 -0
- data/docs/1106-participates_in-bidirectional-solution.md +129 -0
- data/docs/guides/encryption.md +486 -0
- data/docs/guides/feature-encrypted-fields.md +123 -7
- data/docs/guides/feature-expiration.md +161 -117
- data/docs/guides/feature-external-identifiers.md +415 -443
- data/docs/guides/feature-object-identifiers.md +400 -269
- data/docs/guides/feature-quantization.md +120 -6
- data/docs/guides/feature-relationships-indexing.md +318 -0
- data/docs/guides/feature-relationships-methods.md +146 -604
- data/docs/guides/feature-relationships-participation.md +263 -0
- data/docs/guides/feature-relationships.md +118 -136
- data/docs/guides/feature-system-devs.md +176 -693
- data/docs/guides/feature-system.md +119 -6
- data/docs/guides/feature-transient-fields.md +81 -0
- data/docs/guides/field-system.md +778 -0
- data/docs/guides/index.md +32 -15
- data/docs/guides/logging.md +187 -0
- data/docs/guides/optimized-loading.md +674 -0
- data/docs/guides/thread-safety-monitoring.md +61 -0
- data/docs/guides/{time-utilities.md → time-literals.md} +12 -12
- data/docs/migrating/v2.0.0-pre22.md +241 -0
- data/docs/overview.md +7 -9
- data/docs/reference/api-technical.md +267 -320
- data/examples/autoloader/mega_customer/features/deprecated_fields.rb +2 -0
- data/examples/autoloader/mega_customer/safe_dump_fields.rb +2 -0
- data/examples/autoloader/mega_customer.rb +2 -0
- data/examples/datatype_standalone.rb +4 -3
- data/examples/encrypted_fields.rb +2 -1
- data/examples/json_usage_patterns.rb +2 -0
- data/examples/relationships.rb +3 -0
- data/examples/safe_dump.rb +2 -1
- data/examples/sampling_demo.rb +53 -0
- data/examples/single_connection_transaction_confusions.rb +2 -1
- data/familia.gemspec +2 -1
- data/lib/familia/base.rb +2 -0
- data/lib/familia/connection/behavior.rb +2 -0
- data/lib/familia/connection/handlers.rb +2 -0
- data/lib/familia/connection/individual_command_proxy.rb +2 -0
- data/lib/familia/connection/middleware.rb +34 -24
- data/lib/familia/connection/operation_core.rb +3 -2
- data/lib/familia/connection/operations.rb +2 -0
- data/lib/familia/connection/pipelined_core.rb +3 -3
- data/lib/familia/connection/transaction_core.rb +69 -2
- data/lib/familia/connection.rb +18 -3
- data/lib/familia/data_type/class_methods.rb +3 -1
- data/lib/familia/data_type/connection.rb +2 -0
- data/lib/familia/data_type/database_commands.rb +2 -0
- data/lib/familia/data_type/serialization.rb +79 -52
- data/lib/familia/data_type/settings.rb +2 -0
- data/lib/familia/data_type/types/counter.rb +2 -0
- data/lib/familia/data_type/types/hashkey.rb +7 -5
- data/lib/familia/data_type/types/listkey.rb +2 -0
- data/lib/familia/data_type/types/lock.rb +2 -0
- data/lib/familia/data_type/types/sorted_set.rb +7 -10
- data/lib/familia/data_type/types/stringkey.rb +24 -0
- data/lib/familia/data_type/types/unsorted_set.rb +2 -0
- data/lib/familia/data_type.rb +2 -0
- data/lib/familia/encryption/encrypted_data.rb +4 -2
- data/lib/familia/encryption/manager.rb +2 -0
- data/lib/familia/encryption/provider.rb +2 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +2 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +2 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +2 -0
- data/lib/familia/encryption/registry.rb +2 -0
- data/lib/familia/encryption/request_cache.rb +2 -0
- data/lib/familia/encryption.rb +9 -2
- data/lib/familia/errors.rb +2 -0
- data/lib/familia/features/autoloader.rb +2 -0
- data/lib/familia/features/encrypted_fields/concealed_string.rb +2 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +4 -0
- data/lib/familia/features/encrypted_fields.rb +2 -2
- data/lib/familia/features/expiration/extensions.rb +3 -1
- data/lib/familia/features/expiration.rb +12 -4
- data/lib/familia/features/external_identifier.rb +62 -7
- data/lib/familia/features/object_identifier.rb +49 -0
- data/lib/familia/features/quantization.rb +3 -1
- data/lib/familia/features/relationships/README.md +3 -1
- data/lib/familia/features/relationships/collection_operations.rb +2 -0
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +138 -9
- data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +479 -0
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +97 -21
- data/lib/familia/features/relationships/indexing.rb +3 -0
- data/lib/familia/features/relationships/indexing_relationship.rb +3 -1
- data/lib/familia/features/relationships/participation/participant_methods.rb +131 -14
- data/lib/familia/features/relationships/participation/rebuild_strategies.md +41 -0
- data/lib/familia/features/relationships/participation/target_methods.rb +6 -6
- data/lib/familia/features/relationships/participation.rb +155 -69
- data/lib/familia/features/relationships/participation_membership.rb +69 -0
- data/lib/familia/features/relationships/participation_relationship.rb +34 -6
- data/lib/familia/features/relationships/score_encoding.rb +2 -0
- data/lib/familia/features/relationships.rb +5 -3
- data/lib/familia/features/safe_dump.rb +2 -0
- data/lib/familia/features/transient_fields/redacted_string.rb +2 -0
- data/lib/familia/features/transient_fields/single_use_redacted_string.rb +2 -0
- data/lib/familia/features/transient_fields/transient_field_type.rb +5 -3
- data/lib/familia/features/transient_fields.rb +2 -0
- data/lib/familia/features.rb +2 -0
- data/lib/familia/field_type.rb +3 -1
- data/lib/familia/horreum/connection.rb +17 -1
- data/lib/familia/horreum/database_commands.rb +8 -1
- data/lib/familia/horreum/definition.rb +16 -6
- data/lib/familia/horreum/management.rb +353 -52
- data/lib/familia/horreum/persistence.rb +179 -108
- data/lib/familia/horreum/related_fields.rb +2 -0
- data/lib/familia/horreum/serialization.rb +23 -4
- data/lib/familia/horreum/settings.rb +2 -0
- data/lib/familia/horreum/utils.rb +2 -0
- data/lib/familia/horreum.rb +15 -1
- data/lib/familia/identifier_extractor.rb +3 -1
- data/lib/familia/instrumentation.rb +156 -0
- data/lib/familia/json_serializer.rb +2 -0
- data/lib/familia/logging.rb +92 -32
- data/lib/familia/refinements/dear_json.rb +2 -0
- data/lib/familia/refinements/stylize_words.rb +2 -14
- data/lib/familia/refinements/time_literals.rb +2 -0
- data/lib/familia/refinements.rb +2 -0
- data/lib/familia/secure_identifier.rb +10 -2
- data/lib/familia/settings.rb +2 -0
- data/lib/familia/thread_safety/instrumented_mutex.rb +166 -0
- data/lib/familia/thread_safety/monitor.rb +328 -0
- data/lib/familia/utils.rb +13 -0
- data/lib/familia/verifiable_identifier.rb +3 -1
- data/lib/familia/version.rb +3 -1
- data/lib/familia.rb +31 -4
- data/lib/middleware/database_command_counter.rb +152 -0
- data/lib/middleware/database_logger.rb +295 -170
- data/lib/multi_result.rb +61 -31
- data/try/edge_cases/empty_identifiers_try.rb +2 -0
- data/try/edge_cases/hash_symbolization_try.rb +2 -0
- data/try/edge_cases/json_serialization_try.rb +2 -0
- data/try/edge_cases/legacy_data_detection/deserialization_edge_cases_try.rb +4 -0
- data/try/edge_cases/race_conditions_try.rb +4 -0
- data/try/edge_cases/reserved_keywords_try.rb +4 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/edge_cases/ttl_side_effects_try.rb +4 -0
- data/try/features/count_any_edge_cases_try.rb +486 -0
- data/try/features/count_any_methods_try.rb +197 -0
- data/try/features/encrypted_fields/aad_protection_try.rb +4 -0
- data/try/features/encrypted_fields/concealed_string_core_try.rb +4 -0
- data/try/features/encrypted_fields/context_isolation_try.rb +4 -0
- data/try/features/encrypted_fields/encrypted_fields_core_try.rb +33 -0
- data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +4 -0
- data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +4 -0
- data/try/features/encrypted_fields/encrypted_fields_security_try.rb +4 -0
- data/try/features/encrypted_fields/error_conditions_try.rb +4 -0
- data/try/features/encrypted_fields/fresh_key_derivation_try.rb +4 -0
- data/try/features/encrypted_fields/fresh_key_try.rb +4 -0
- data/try/features/encrypted_fields/key_rotation_try.rb +4 -0
- data/try/features/encrypted_fields/memory_security_try.rb +4 -0
- data/try/features/encrypted_fields/missing_current_key_version_try.rb +4 -0
- data/try/features/encrypted_fields/nonce_uniqueness_try.rb +4 -0
- data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +4 -0
- data/try/features/encrypted_fields/thread_safety_try.rb +4 -0
- data/try/features/encrypted_fields/universal_serialization_safety_try.rb +4 -0
- data/try/features/encryption/config_persistence_try.rb +4 -0
- data/try/features/encryption/core_try.rb +4 -0
- data/try/features/encryption/instance_variable_scope_try.rb +4 -0
- data/try/features/encryption/module_loading_try.rb +4 -0
- data/try/features/encryption/providers/aes_gcm_provider_try.rb +4 -0
- data/try/features/encryption/providers/xchacha20_poly1305_provider_try.rb +4 -0
- data/try/features/encryption/roundtrip_validation_try.rb +4 -0
- data/try/features/encryption/secure_memory_handling_try.rb +4 -0
- data/try/features/expiration/expiration_try.rb +4 -0
- data/try/features/external_identifier/external_identifier_try.rb +305 -8
- data/try/features/feature_dependencies_try.rb +2 -0
- data/try/features/feature_improvements_try.rb +2 -0
- data/try/features/field_groups_try.rb +2 -0
- data/try/features/object_identifier/object_identifier_integration_try.rb +12 -9
- data/try/features/object_identifier/object_identifier_try.rb +140 -0
- data/try/features/quantization/quantization_try.rb +4 -0
- data/try/features/real_feature_integration_try.rb +2 -0
- data/try/features/relationships/indexing_commands_verification_try.rb +2 -0
- data/try/features/relationships/indexing_rebuild_try.rb +606 -0
- data/try/features/relationships/indexing_try.rb +2 -0
- data/try/features/relationships/participation_bidirectional_try.rb +242 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +4 -0
- data/try/features/relationships/participation_commands_verification_try.rb +2 -0
- data/try/features/relationships/participation_performance_improvements_try.rb +11 -9
- data/try/features/relationships/participation_reverse_index_try.rb +15 -13
- data/try/features/relationships/participation_target_class_resolution_try.rb +209 -0
- data/try/features/relationships/participation_unresolved_target_try.rb +109 -0
- data/try/features/relationships/relationships_api_changes_try.rb +2 -0
- data/try/features/relationships/relationships_edge_cases_try.rb +4 -0
- data/try/features/relationships/relationships_performance_minimal_try.rb +4 -0
- data/try/features/relationships/relationships_performance_simple_try.rb +4 -0
- data/try/features/relationships/relationships_performance_try.rb +4 -0
- data/try/features/relationships/relationships_performance_working_try.rb +4 -0
- data/try/features/relationships/relationships_try.rb +6 -4
- data/try/features/safe_dump/safe_dump_advanced_try.rb +4 -0
- data/try/features/safe_dump/safe_dump_try.rb +4 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/refresh_reset_try.rb +3 -0
- data/try/features/transient_fields/simple_refresh_test.rb +3 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/features/transient_fields/transient_fields_core_try.rb +4 -0
- data/try/features/transient_fields/transient_fields_integration_try.rb +4 -0
- data/try/integration/connection/fiber_context_preservation_try.rb +4 -0
- data/try/integration/connection/handler_constraints_try.rb +4 -0
- data/try/integration/connection/isolated_dbclient_try.rb +4 -0
- data/try/integration/connection/middleware_reconnect_try.rb +2 -0
- data/try/integration/connection/operation_mode_guards_try.rb +4 -0
- data/try/integration/connection/pipeline_fallback_integration_try.rb +3 -0
- data/try/integration/connection/pools_try.rb +4 -0
- data/try/integration/connection/responsibility_chain_tracking_try.rb +4 -0
- data/try/integration/connection/transaction_fallback_integration_try.rb +4 -0
- data/try/integration/connection/transaction_mode_permissive_try.rb +4 -0
- data/try/integration/connection/transaction_mode_strict_try.rb +4 -0
- data/try/integration/connection/transaction_mode_warn_try.rb +4 -0
- data/try/integration/connection/transaction_modes_try.rb +4 -0
- data/try/integration/conventional_inheritance_try.rb +4 -0
- data/try/integration/create_method_try.rb +4 -0
- data/try/integration/cross_component_try.rb +4 -0
- data/try/integration/data_types/datatype_pipelines_try.rb +9 -3
- data/try/integration/data_types/datatype_transactions_try.rb +17 -7
- data/try/integration/database_consistency_try.rb +4 -0
- data/try/integration/familia_extended_try.rb +4 -0
- data/try/integration/familia_members_methods_try.rb +4 -0
- data/try/integration/models/customer_safe_dump_try.rb +4 -0
- data/try/integration/models/customer_try.rb +7 -3
- data/try/integration/models/datatype_base_try.rb +4 -0
- data/try/integration/models/familia_object_try.rb +4 -0
- data/try/integration/persistence_operations_try.rb +4 -0
- data/try/integration/relationships_persistence_round_trip_try.rb +17 -14
- data/try/integration/save_methods_consistency_try.rb +241 -0
- data/try/integration/scenarios_try.rb +4 -0
- data/try/integration/secure_identifier_try.rb +4 -0
- data/try/integration/transaction_safety_core_try.rb +176 -0
- data/try/integration/transaction_safety_workflow_try.rb +291 -0
- data/try/integration/verifiable_identifier_try.rb +4 -0
- data/try/investigation/pipeline_routing/README.md +228 -0
- data/try/performance/benchmarks_try.rb +4 -0
- data/try/performance/transaction_safety_benchmark_try.rb +238 -0
- data/try/support/benchmarks/deserialization_benchmark.rb +3 -1
- data/try/support/benchmarks/deserialization_correctness_test.rb +3 -1
- data/try/support/debugging/cache_behavior_tracer.rb +4 -0
- data/try/support/debugging/debug_aad_process.rb +3 -0
- data/try/support/debugging/debug_concealed_internal.rb +3 -0
- data/try/support/debugging/debug_concealed_reveal.rb +3 -0
- data/try/support/debugging/debug_context_aad.rb +3 -0
- data/try/support/debugging/debug_context_simple.rb +3 -0
- data/try/support/debugging/debug_cross_context.rb +3 -0
- data/try/support/debugging/debug_database_load.rb +3 -0
- data/try/support/debugging/debug_encrypted_json_check.rb +3 -0
- data/try/support/debugging/debug_encrypted_json_step_by_step.rb +3 -0
- data/try/support/debugging/debug_exists_lifecycle.rb +3 -0
- data/try/support/debugging/debug_field_decrypt.rb +3 -0
- data/try/support/debugging/debug_fresh_cross_context.rb +3 -0
- data/try/support/debugging/debug_load_path.rb +3 -0
- data/try/support/debugging/debug_method_definition.rb +3 -0
- data/try/support/debugging/debug_method_resolution.rb +3 -0
- data/try/support/debugging/debug_minimal.rb +3 -0
- data/try/support/debugging/debug_provider.rb +3 -0
- data/try/support/debugging/debug_secure_behavior.rb +3 -0
- data/try/support/debugging/debug_string_class.rb +3 -0
- data/try/support/debugging/debug_test.rb +3 -0
- data/try/support/debugging/debug_test_design.rb +3 -0
- data/try/support/debugging/encryption_method_tracer.rb +4 -0
- data/try/support/debugging/provider_diagnostics.rb +4 -0
- data/try/support/helpers/test_cleanup.rb +4 -0
- data/try/support/helpers/test_helpers.rb +5 -0
- data/try/support/memory/memory_basic_test.rb +4 -0
- data/try/support/memory/memory_detailed_test.rb +4 -0
- data/try/support/memory/memory_search_for_string.rb +4 -0
- data/try/support/memory/test_actual_redactedstring_protection.rb +4 -0
- data/try/support/prototypes/atomic_saves_v1_context_proxy.rb +4 -0
- data/try/support/prototypes/atomic_saves_v2_connection_switching.rb +4 -0
- data/try/support/prototypes/atomic_saves_v3_connection_pool.rb +4 -0
- data/try/support/prototypes/atomic_saves_v4.rb +4 -0
- data/try/support/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +4 -0
- data/try/support/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -0
- data/try/support/prototypes/pooling/configurable_stress_test.rb +4 -0
- data/try/support/prototypes/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -0
- data/try/support/prototypes/pooling/lib/connection_pool_metrics.rb +4 -0
- data/try/support/prototypes/pooling/lib/connection_pool_stress_test.rb +4 -0
- data/try/support/prototypes/pooling/lib/connection_pool_threading_models.rb +4 -0
- data/try/support/prototypes/pooling/lib/visualize_stress_results.rb +4 -2
- data/try/support/prototypes/pooling/pool_siege.rb +4 -2
- data/try/support/prototypes/pooling/run_stress_tests.rb +4 -2
- data/try/thread_safety/README.md +496 -0
- data/try/thread_safety/class_connection_chain_race_try.rb +265 -0
- data/try/thread_safety/connection_chain_race_try.rb +148 -0
- data/try/thread_safety/encryption_manager_cache_race_try.rb +166 -0
- data/try/thread_safety/feature_registry_race_try.rb +226 -0
- data/try/thread_safety/fiber_pipeline_isolation_try.rb +235 -0
- data/try/thread_safety/fiber_transaction_isolation_try.rb +208 -0
- data/try/thread_safety/field_registration_race_try.rb +222 -0
- data/try/thread_safety/logger_initialization_race_try.rb +170 -0
- data/try/thread_safety/middleware_registration_race_try.rb +154 -0
- data/try/thread_safety/module_config_race_try.rb +175 -0
- data/try/thread_safety/secure_identifier_cache_race_try.rb +226 -0
- data/try/unit/core/autoloader_try.rb +4 -0
- data/try/unit/core/base_enhancements_try.rb +4 -0
- data/try/unit/core/connection_try.rb +4 -0
- data/try/unit/core/errors_try.rb +4 -0
- data/try/unit/core/extensions_try.rb +4 -0
- data/try/unit/core/familia_logger_try.rb +2 -0
- data/try/unit/core/familia_try.rb +4 -0
- data/try/unit/core/middleware_sampling_try.rb +335 -0
- data/try/unit/core/middleware_test_helpers_bug_try.rb +58 -0
- data/try/unit/core/middleware_thread_safety_try.rb +245 -0
- data/try/unit/core/middleware_try.rb +4 -0
- data/try/unit/core/settings_try.rb +4 -0
- data/try/unit/core/time_utils_try.rb +4 -0
- data/try/unit/core/tools_try.rb +4 -0
- data/try/unit/core/utils_try.rb +37 -0
- data/try/unit/data_types/boolean_try.rb +39 -22
- data/try/unit/data_types/counter_try.rb +4 -0
- data/try/unit/data_types/datatype_base_try.rb +4 -0
- data/try/unit/data_types/hash_try.rb +6 -2
- data/try/unit/data_types/list_try.rb +4 -0
- data/try/unit/data_types/lock_try.rb +4 -0
- data/try/unit/data_types/serialization_try.rb +386 -0
- data/try/unit/data_types/sorted_set_try.rb +4 -0
- data/try/unit/data_types/sorted_set_zadd_options_try.rb +4 -0
- data/try/unit/data_types/string_try.rb +4 -0
- data/try/unit/data_types/unsortedset_try.rb +4 -0
- data/try/unit/familia_resolve_class_try.rb +116 -0
- data/try/unit/horreum/auto_indexing_on_save_try.rb +5 -1
- data/try/unit/horreum/automatic_index_validation_try.rb +2 -0
- data/try/unit/horreum/base_try.rb +4 -0
- data/try/unit/horreum/class_methods_try.rb +4 -0
- data/try/unit/horreum/commands_try.rb +4 -0
- data/try/unit/horreum/defensive_initialization_try.rb +4 -0
- data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +6 -1
- data/try/unit/horreum/enhanced_conflict_handling_try.rb +4 -0
- data/try/unit/horreum/field_categories_try.rb +4 -0
- data/try/unit/horreum/field_definition_try.rb +4 -0
- data/try/unit/horreum/initialization_try.rb +4 -0
- data/try/unit/horreum/json_type_preservation_try.rb +2 -0
- data/try/unit/horreum/optimized_loading_try.rb +156 -0
- data/try/unit/horreum/relations_try.rb +4 -0
- data/try/unit/horreum/serialization_persistent_fields_try.rb +4 -0
- data/try/unit/horreum/serialization_try.rb +4 -0
- data/try/unit/horreum/settings_try.rb +4 -0
- data/try/unit/horreum/unique_index_edge_cases_try.rb +4 -0
- data/try/unit/horreum/unique_index_guard_validation_try.rb +2 -0
- data/try/unit/middleware/database_command_counter_methods_try.rb +139 -0
- data/try/unit/middleware/database_logger_methods_try.rb +251 -0
- data/try/unit/refinements/dear_json_array_methods_try.rb +4 -0
- data/try/unit/refinements/dear_json_hash_methods_try.rb +4 -0
- data/try/unit/refinements/time_literals_numeric_methods_try.rb +4 -0
- data/try/unit/refinements/time_literals_string_methods_try.rb +4 -0
- data/try/unit/thread_safety_monitor_try.rb +149 -0
- metadata +69 -17
- data/.github/workflows/code-quality.yml +0 -138
- data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +0 -91
- data/changelog.d/20251011_203905_delano_next.rst +0 -30
- data/changelog.d/20251011_212633_delano_next.rst +0 -13
- data/changelog.d/20251011_221253_delano_next.rst +0 -26
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +0 -210
- data/docs/archive/FAMILIA_TECHNICAL.md +0 -823
- data/docs/archive/FAMILIA_UPDATE.md +0 -226
- data/docs/archive/README.md +0 -64
- data/docs/archive/api-reference.md +0 -333
- data/docs/guides/core-field-system.md +0 -806
- data/docs/guides/implementation.md +0 -276
- data/docs/guides/security-model.md +0 -183
|
@@ -2,636 +2,608 @@
|
|
|
2
2
|
|
|
3
3
|
> **💡 Quick Reference**
|
|
4
4
|
>
|
|
5
|
-
>
|
|
5
|
+
> Generate deterministic, public-facing identifiers from internal objid:
|
|
6
6
|
> ```ruby
|
|
7
|
-
> class
|
|
7
|
+
> class User < Familia::Horreum
|
|
8
|
+
> feature :object_identifier
|
|
8
9
|
> feature :external_identifier
|
|
9
|
-
> field :
|
|
10
|
+
> field :email, :name
|
|
10
11
|
> end
|
|
11
12
|
> ```
|
|
12
13
|
|
|
13
14
|
## Overview
|
|
14
15
|
|
|
15
|
-
The External Identifier feature provides
|
|
16
|
+
The External Identifier feature provides deterministic, public-facing identifiers that are securely derived from internal `objid` values. These shorter, URL-safe identifiers are perfect for APIs, public URLs, and external integrations where you want to hide internal implementation details while maintaining deterministic lookups.
|
|
16
17
|
|
|
17
18
|
## Why Use External Identifiers?
|
|
18
19
|
|
|
19
|
-
**
|
|
20
|
+
**Security Through Obscurity**: Hide internal UUID structure and potential timestamp information from public interfaces.
|
|
20
21
|
|
|
21
|
-
**
|
|
22
|
+
**URL-Friendly**: Generate compact, base36-encoded identifiers safe for use in URLs and APIs.
|
|
22
23
|
|
|
23
|
-
**
|
|
24
|
+
**Deterministic Generation**: Same `objid` always produces the same `extid` for reliable lookups.
|
|
24
25
|
|
|
25
|
-
**
|
|
26
|
+
**Bidirectional Mapping**: Automatic lookup tables enable finding objects by external ID.
|
|
26
27
|
|
|
27
|
-
**
|
|
28
|
+
**Customizable Format**: Configure prefix and format patterns to match your naming conventions.
|
|
28
29
|
|
|
29
|
-
##
|
|
30
|
+
## Dependencies
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
External identifiers require the Object Identifier feature:
|
|
32
33
|
|
|
33
34
|
```ruby
|
|
34
|
-
class
|
|
35
|
+
class MyModel < Familia::Horreum
|
|
36
|
+
feature :object_identifier # Required first
|
|
37
|
+
feature :external_identifier # Then enable external IDs
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Basic Usage
|
|
42
|
+
|
|
43
|
+
### Default External Identifier
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class User < Familia::Horreum
|
|
47
|
+
feature :object_identifier
|
|
35
48
|
feature :external_identifier
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
field :internal_id, :external_id, :name, :email, :sync_status
|
|
50
|
+
field :email, :name
|
|
39
51
|
end
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
email: "contact@acme.com"
|
|
47
|
-
)
|
|
48
|
-
customer.save # Automatically creates bidirectional mapping
|
|
53
|
+
user = User.new(email: 'alice@example.com', name: 'Alice')
|
|
54
|
+
user.save
|
|
55
|
+
|
|
56
|
+
# Internal identifier (long, detailed)
|
|
57
|
+
user.objid # => "01234567-89ab-cdef-1234-567890abcdef"
|
|
49
58
|
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
# External identifier (short, public-safe, 29 chars total: 4 prefix + 25 ID)
|
|
60
|
+
user.extid # => "ext_abc123def456ghi789jkl012" (deterministic from objid)
|
|
61
|
+
|
|
62
|
+
# Always produces the same extid from the same objid
|
|
63
|
+
user2 = User.new(objid: user.objid, email: 'alice@example.com')
|
|
64
|
+
user2.extid # => "ext_abc123def456ghi789jkl012" (identical)
|
|
53
65
|
```
|
|
54
66
|
|
|
55
|
-
###
|
|
67
|
+
### Finding by External ID
|
|
56
68
|
|
|
57
69
|
```ruby
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
# Create and save user
|
|
71
|
+
user = User.new(email: 'bob@example.com')
|
|
72
|
+
user.save
|
|
73
|
+
puts user.extid # => "ext_xyz789abc123def456ghi123"
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
# Find by external identifier
|
|
76
|
+
found_user = User.find_by_extid('ext_xyz789abc123def456ghi123')
|
|
77
|
+
found_user.email # => "bob@example.com"
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
# Returns nil if not found
|
|
80
|
+
missing = User.find_by_extid('ext_nonexistent') # => nil
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Long-form Methods
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Aliases for clarity
|
|
87
|
+
user.external_identifier # Same as user.extid
|
|
88
|
+
user.external_identifier = 'new' # Same as user.extid = 'new'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Custom Format Templates
|
|
92
|
+
|
|
93
|
+
### Custom Prefix
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Customer < Familia::Horreum
|
|
97
|
+
feature :object_identifier
|
|
98
|
+
feature :external_identifier, format: 'cust_%{id}'
|
|
99
|
+
|
|
100
|
+
field :company_name, :email
|
|
69
101
|
end
|
|
70
102
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
103
|
+
customer = Customer.new(company_name: 'Acme Corp')
|
|
104
|
+
customer.save
|
|
105
|
+
customer.extid # => "cust_abc123def456ghi789jkl012" (30 chars: 5 prefix + 25 ID)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Hyphen Separator
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class APIKey < Familia::Horreum
|
|
112
|
+
feature :object_identifier
|
|
113
|
+
feature :external_identifier, format: 'api-%{id}'
|
|
77
114
|
|
|
78
|
-
|
|
79
|
-
legacy_user.save
|
|
80
|
-
legacy_user.mark_migration_completed
|
|
115
|
+
field :name, :permissions
|
|
81
116
|
end
|
|
117
|
+
|
|
118
|
+
key = APIKey.new(name: 'Production API Key')
|
|
119
|
+
key.save
|
|
120
|
+
key.extid # => "api-abc123def456ghi789jkl012" (29 chars: 4 prefix + 25 ID)
|
|
82
121
|
```
|
|
83
122
|
|
|
84
|
-
|
|
123
|
+
### Version Prefix
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class Resource < Familia::Horreum
|
|
127
|
+
feature :object_identifier
|
|
128
|
+
feature :external_identifier, format: 'v2/%{id}'
|
|
129
|
+
|
|
130
|
+
field :resource_type, :data
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
resource = Resource.new(resource_type: 'document')
|
|
134
|
+
resource.save
|
|
135
|
+
resource.extid # => "v2/abc123def456ghi789jkl012" (28 chars: 3 prefix + 25 ID)
|
|
136
|
+
```
|
|
85
137
|
|
|
86
|
-
###
|
|
138
|
+
### No Prefix
|
|
87
139
|
|
|
88
140
|
```ruby
|
|
89
|
-
class
|
|
90
|
-
feature :
|
|
91
|
-
|
|
92
|
-
source_system: "CustomerAPI",
|
|
93
|
-
bidirectional: true # Default
|
|
141
|
+
class SimpleModel < Familia::Horreum
|
|
142
|
+
feature :object_identifier
|
|
143
|
+
feature :external_identifier, format: '%{id}'
|
|
94
144
|
|
|
95
|
-
field :
|
|
145
|
+
field :data
|
|
96
146
|
end
|
|
147
|
+
|
|
148
|
+
model = SimpleModel.new(data: 'test')
|
|
149
|
+
model.save
|
|
150
|
+
model.extid # => "abc123def456ghi789jkl012" (25 chars: just the ID)
|
|
97
151
|
```
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
- `validation_pattern`: Regex pattern for external ID validation
|
|
101
|
-
- `source_system`: Name of the external system (for logging/debugging)
|
|
102
|
-
- `bidirectional`: Enable bidirectional mapping (default: true)
|
|
103
|
-
- `prefix`: Optional prefix for mapping keys
|
|
153
|
+
## Security Model
|
|
104
154
|
|
|
105
|
-
###
|
|
155
|
+
### Deterministic but Obscured
|
|
106
156
|
|
|
107
|
-
|
|
108
|
-
class StrictExternalUser < Familia::Horreum
|
|
109
|
-
feature :external_identifier,
|
|
110
|
-
validation_pattern: /^user_[a-z0-9]{8,16}$/,
|
|
111
|
-
source_system: "AuthService"
|
|
157
|
+
External identifiers use cryptographic techniques to obscure the relationship between `objid` and `extid`:
|
|
112
158
|
|
|
113
|
-
|
|
159
|
+
1. **SHA-256 Seeding**: The `objid` is hashed to create a uniform seed
|
|
160
|
+
2. **PRNG Function**: A pseudorandom number generator acts as a deterministic transformation
|
|
161
|
+
3. **Base36 Encoding**: Result is encoded as URL-safe, compact string
|
|
114
162
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
This ensures:
|
|
164
|
+
- Same `objid` always produces same `extid` (deterministic)
|
|
165
|
+
- No discernible mathematical correlation between `objid` and `extid` (secure)
|
|
166
|
+
- Cannot reverse-engineer `objid` from `extid` (one-way)
|
|
118
167
|
|
|
119
|
-
|
|
120
|
-
blacklisted_ids = ["user_test", "user_admin", "user_system"]
|
|
121
|
-
return false if blacklisted_ids.include?(external_id)
|
|
168
|
+
### Generated Format Details
|
|
122
169
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
170
|
+
The ID portion (after any prefix) is always:
|
|
171
|
+
- **Length**: Exactly 25 characters
|
|
172
|
+
- **Characters**: Lowercase alphanumeric only (`0-9a-z`)
|
|
173
|
+
- **Encoding**: Base36 representation of 128-bit random data
|
|
174
|
+
- **Pattern**: `/[0-9a-z]{25}/`
|
|
127
175
|
|
|
128
|
-
|
|
176
|
+
### Provenance Validation
|
|
129
177
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
External identifiers can only be derived from `objid` values with known provenance:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
# ✅ Valid - objid from ObjectIdentifier feature
|
|
182
|
+
user = User.new # objid generated by UUID v7
|
|
183
|
+
user.extid # => Works fine
|
|
184
|
+
|
|
185
|
+
# ❌ Invalid - objid of unknown origin
|
|
186
|
+
user = User.new
|
|
187
|
+
user.instance_variable_set(:@objid, 'unknown-source-id')
|
|
188
|
+
user.extid # => ExternalIdentifierError: objid provenance unknown
|
|
134
189
|
```
|
|
135
190
|
|
|
136
|
-
##
|
|
191
|
+
## Automatic Lookup Management
|
|
137
192
|
|
|
138
193
|
### Bidirectional Mapping
|
|
139
194
|
|
|
140
|
-
|
|
195
|
+
The feature automatically maintains lookup tables:
|
|
141
196
|
|
|
142
197
|
```ruby
|
|
143
198
|
class Product < Familia::Horreum
|
|
199
|
+
feature :object_identifier
|
|
144
200
|
feature :external_identifier
|
|
145
|
-
|
|
201
|
+
|
|
202
|
+
field :name, :price
|
|
146
203
|
end
|
|
147
204
|
|
|
148
|
-
product = Product.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
205
|
+
product = Product.new(name: 'Widget', price: 29.99)
|
|
206
|
+
product.save
|
|
207
|
+
|
|
208
|
+
# Lookup table is automatically updated
|
|
209
|
+
Product.extid_lookup.class # => Familia::DataType::HashKey
|
|
210
|
+
Product.extid_lookup.length # => 1
|
|
211
|
+
|
|
212
|
+
# Mapping: extid -> objid
|
|
213
|
+
extid = product.extid
|
|
214
|
+
Product.extid_lookup[extid] # => product.objid
|
|
215
|
+
```
|
|
153
216
|
|
|
154
|
-
|
|
155
|
-
# external_id_mapping["SKU-ABC-789"] = "familia_prod_123"
|
|
156
|
-
# internal_id_mapping["familia_prod_123"] = "SKU-ABC-789"
|
|
217
|
+
### Cleanup on Destroy
|
|
157
218
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
by_internal = Product.load("familia_prod_123")
|
|
219
|
+
```ruby
|
|
220
|
+
product.destroy!
|
|
161
221
|
|
|
162
|
-
#
|
|
163
|
-
|
|
222
|
+
# Lookup entry is automatically cleaned up
|
|
223
|
+
Product.extid_lookup[extid] # => nil
|
|
164
224
|
```
|
|
165
225
|
|
|
166
|
-
|
|
226
|
+
## Implementation Details
|
|
167
227
|
|
|
168
|
-
|
|
228
|
+
### Lazy Generation
|
|
229
|
+
|
|
230
|
+
External identifiers are generated lazily when first accessed:
|
|
169
231
|
|
|
170
232
|
```ruby
|
|
171
|
-
|
|
172
|
-
def self.import_external_users(external_data_array)
|
|
173
|
-
external_ids = external_data_array.map { |data| data['external_id'] }
|
|
233
|
+
user = User.new(email: 'test@example.com')
|
|
174
234
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
existing_external_ids = existing_users.compact.map(&:external_id)
|
|
235
|
+
# extid is not generated until accessed
|
|
236
|
+
user.instance_variable_get(:@extid) # => nil
|
|
178
237
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
existing_external_ids.include?(data['external_id'])
|
|
182
|
-
end
|
|
238
|
+
# First access triggers generation
|
|
239
|
+
user.extid # => "ext_abc123..." (generated from objid)
|
|
183
240
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
internal_id: SecureRandom.uuid,
|
|
188
|
-
external_id: data['external_id'],
|
|
189
|
-
name: data['name'],
|
|
190
|
-
email: data['email']
|
|
191
|
-
)
|
|
192
|
-
end
|
|
241
|
+
# Subsequent access returns cached value
|
|
242
|
+
user.extid # => "ext_abc123..." (same value)
|
|
243
|
+
```
|
|
193
244
|
|
|
194
|
-
|
|
195
|
-
ExternalUser.transaction do |redis|
|
|
196
|
-
new_users.each(&:save)
|
|
197
|
-
end
|
|
245
|
+
### Preservation During Initialization
|
|
198
246
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
247
|
+
Values provided during initialization are preserved:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# Loading from database with existing extid
|
|
251
|
+
user = User.new(
|
|
252
|
+
objid: existing_objid,
|
|
253
|
+
extid: existing_extid,
|
|
254
|
+
email: 'test@example.com'
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Existing extid is preserved, not regenerated
|
|
258
|
+
user.extid # => existing_extid (not derived)
|
|
202
259
|
```
|
|
203
260
|
|
|
204
|
-
|
|
261
|
+
### Database Persistence
|
|
205
262
|
|
|
206
|
-
|
|
263
|
+
External identifiers are automatically stored in the object's hash:
|
|
207
264
|
|
|
208
265
|
```ruby
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
user = User.new(email: 'test@example.com')
|
|
267
|
+
user.save
|
|
211
268
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
mark_sync_pending
|
|
216
|
-
|
|
217
|
-
begin
|
|
218
|
-
# Simulate external API call
|
|
219
|
-
response = ExternalAPI.update_resource(external_id, data: self.data)
|
|
220
|
-
|
|
221
|
-
if response.success?
|
|
222
|
-
mark_sync_completed
|
|
223
|
-
self.last_sync_at = Familia.now.to_i
|
|
224
|
-
save
|
|
225
|
-
else
|
|
226
|
-
mark_sync_failed(response.error_message)
|
|
227
|
-
end
|
|
228
|
-
rescue => e
|
|
229
|
-
mark_sync_failed(e.message)
|
|
230
|
-
raise
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def sync_from_external!
|
|
235
|
-
mark_sync_pending
|
|
236
|
-
|
|
237
|
-
begin
|
|
238
|
-
external_data = ExternalAPI.get_resource(external_id)
|
|
239
|
-
self.data = external_data['data']
|
|
240
|
-
mark_sync_completed
|
|
241
|
-
save
|
|
242
|
-
rescue => e
|
|
243
|
-
mark_sync_failed(e.message)
|
|
244
|
-
raise
|
|
245
|
-
end
|
|
246
|
-
end
|
|
269
|
+
# extid is stored alongside other fields
|
|
270
|
+
user.to_h # => { objid: "...", extid: "ext_abc123...", email: "..." }
|
|
271
|
+
```
|
|
247
272
|
|
|
248
|
-
|
|
249
|
-
sync_status != 'completed' ||
|
|
250
|
-
(last_sync_at && (Familia.now.to_i - last_sync_at) > 1.hour)
|
|
251
|
-
end
|
|
252
|
-
end
|
|
273
|
+
## Error Handling
|
|
253
274
|
|
|
254
|
-
|
|
255
|
-
resource = SyncableResource.find_by_external_id("ext_123")
|
|
275
|
+
### Missing ObjectIdentifier Feature
|
|
256
276
|
|
|
257
|
-
|
|
258
|
-
|
|
277
|
+
```ruby
|
|
278
|
+
class BadModel < Familia::Horreum
|
|
279
|
+
feature :external_identifier # Missing :object_identifier dependency
|
|
259
280
|
end
|
|
260
281
|
|
|
261
|
-
|
|
282
|
+
# => Error during class definition
|
|
262
283
|
```
|
|
263
284
|
|
|
264
|
-
###
|
|
265
|
-
|
|
266
|
-
The external identifier feature provides these built-in status methods:
|
|
285
|
+
### Unknown Provenance
|
|
267
286
|
|
|
268
287
|
```ruby
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
object.mark_sync_failed(error_message)
|
|
288
|
+
user = User.new
|
|
289
|
+
# Manually set objid with unknown provenance
|
|
290
|
+
user.instance_variable_set(:@objid, 'manual-id')
|
|
273
291
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
292
|
+
user.extid
|
|
293
|
+
# => ExternalIdentifierError: Cannot derive external identifier: objid provenance unknown
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Invalid Objid Format
|
|
278
297
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
298
|
+
```ruby
|
|
299
|
+
# For custom generators, objid must be hexadecimal
|
|
300
|
+
user.instance_variable_set(:@objid, 'not-hex-format!')
|
|
301
|
+
user.extid
|
|
302
|
+
# => ExternalIdentifierError: Cannot normalize objid from custom generator
|
|
282
303
|
```
|
|
283
304
|
|
|
284
|
-
##
|
|
305
|
+
## Testing Strategies
|
|
285
306
|
|
|
286
|
-
###
|
|
307
|
+
### Basic Functionality
|
|
287
308
|
|
|
288
309
|
```ruby
|
|
289
|
-
class
|
|
290
|
-
def
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
resource.save
|
|
299
|
-
else
|
|
300
|
-
# Create new resource from webhook
|
|
301
|
-
resource = ExternalResource.create(
|
|
302
|
-
internal_id: SecureRandom.uuid,
|
|
303
|
-
external_id: external_id,
|
|
304
|
-
data: webhook_data['data']
|
|
305
|
-
)
|
|
306
|
-
resource.mark_sync_completed
|
|
307
|
-
end
|
|
310
|
+
class ExternalIdentifierTest < Minitest::Test
|
|
311
|
+
def setup
|
|
312
|
+
@user = User.new(email: 'test@example.com')
|
|
313
|
+
@user.save
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def test_deterministic_generation
|
|
317
|
+
extid1 = @user.extid
|
|
318
|
+
extid2 = @user.extid
|
|
308
319
|
|
|
309
|
-
|
|
320
|
+
assert_equal extid1, extid2
|
|
310
321
|
end
|
|
311
|
-
end
|
|
312
322
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
323
|
+
def test_same_objid_produces_same_extid
|
|
324
|
+
user2 = User.new(objid: @user.objid, email: 'other@example.com')
|
|
325
|
+
|
|
326
|
+
assert_equal @user.extid, user2.extid
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def test_find_by_extid
|
|
330
|
+
found_user = User.find_by_extid(@user.extid)
|
|
331
|
+
|
|
332
|
+
assert_equal @user.objid, found_user.objid
|
|
333
|
+
assert_equal @user.email, found_user.email
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def test_format_validation
|
|
337
|
+
# Default format: 'ext_' prefix + 25 character base36 ID
|
|
338
|
+
assert_match(/\Aext_[0-9a-z]{25}\z/, @user.extid)
|
|
339
|
+
end
|
|
318
340
|
end
|
|
319
341
|
```
|
|
320
342
|
|
|
321
|
-
###
|
|
343
|
+
### Custom Format Testing
|
|
322
344
|
|
|
323
345
|
```ruby
|
|
324
|
-
class
|
|
325
|
-
def
|
|
326
|
-
|
|
327
|
-
|
|
346
|
+
class CustomFormatTest < Minitest::Test
|
|
347
|
+
def test_custom_prefix
|
|
348
|
+
customer = Customer.new(company_name: 'Test Corp')
|
|
349
|
+
customer.save
|
|
328
350
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
next if existing
|
|
351
|
+
# Custom format: 'cust_' prefix + 25 character base36 ID
|
|
352
|
+
assert_match(/\Acust_[0-9a-z]{25}\z/, customer.extid)
|
|
353
|
+
end
|
|
333
354
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
external_id: legacy_row[:customer_id].to_s,
|
|
338
|
-
name: legacy_row[:company_name],
|
|
339
|
-
email: legacy_row[:email],
|
|
340
|
-
created_at: legacy_row[:created_at].to_i
|
|
341
|
-
)
|
|
355
|
+
def test_no_prefix_format
|
|
356
|
+
model = SimpleModel.new(data: 'test')
|
|
357
|
+
model.save
|
|
342
358
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
359
|
+
# No prefix format: just 25 character base36 ID
|
|
360
|
+
assert_match(/\A[0-9a-z]{25}\z/, model.extid)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def test_hyphen_separator
|
|
364
|
+
key = APIKey.new(name: 'Test Key')
|
|
365
|
+
key.save
|
|
366
|
+
|
|
367
|
+
# Hyphen format: 'api-' prefix + 25 character base36 ID
|
|
368
|
+
assert_match(/\Aapi-[0-9a-z]{25}\z/, key.extid)
|
|
351
369
|
end
|
|
352
370
|
end
|
|
353
371
|
```
|
|
354
372
|
|
|
355
|
-
###
|
|
373
|
+
### Security Testing
|
|
356
374
|
|
|
357
375
|
```ruby
|
|
358
|
-
class
|
|
359
|
-
|
|
376
|
+
class SecurityTest < Minitest::Test
|
|
377
|
+
def test_no_correlation_between_objid_and_extid
|
|
378
|
+
users = 10.times.map do
|
|
379
|
+
User.new(email: "user#{rand(1000)}@example.com").tap(&:save)
|
|
380
|
+
end
|
|
360
381
|
|
|
361
|
-
|
|
382
|
+
objids = users.map(&:objid).sort
|
|
383
|
+
extids = users.map(&:extid).sort
|
|
362
384
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
385
|
+
# Sorting should not preserve any correlation
|
|
386
|
+
# (This is a statistical test - might rarely fail by chance)
|
|
387
|
+
correlations = objids.zip(extids).count { |objid, extid|
|
|
388
|
+
objid[0..2] == extid[-3..-1] # Check if patterns match
|
|
389
|
+
}
|
|
367
390
|
|
|
368
|
-
|
|
369
|
-
@billing_mapping ||= ExternalIdMapping.new(self, :billing_id, "Billing_System")
|
|
391
|
+
assert correlations < 3, "Too many correlations detected: #{correlations}"
|
|
370
392
|
end
|
|
371
393
|
|
|
372
|
-
def
|
|
373
|
-
|
|
374
|
-
|
|
394
|
+
def test_cannot_reverse_engineer_objid
|
|
395
|
+
user = User.new(email: 'test@example.com')
|
|
396
|
+
user.save
|
|
375
397
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
end
|
|
398
|
+
# Should not be able to derive objid from extid
|
|
399
|
+
# This test ensures no obvious mathematical relationship
|
|
400
|
+
refute_match user.objid[0..8], user.extid
|
|
380
401
|
end
|
|
381
402
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
@id_field = id_field
|
|
386
|
-
@system_name = system_name
|
|
387
|
-
end
|
|
403
|
+
def test_base36_format_consistency
|
|
404
|
+
user = User.new(email: 'test@example.com')
|
|
405
|
+
user.save
|
|
388
406
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
when "CRM_System"
|
|
395
|
-
CRMApi.update_contact(external_id, @resource.to_crm_format)
|
|
396
|
-
when "Billing_System"
|
|
397
|
-
BillingApi.update_customer(external_id, @resource.to_billing_format)
|
|
398
|
-
when "Support_System"
|
|
399
|
-
SupportApi.update_user(external_id, @resource.to_support_format)
|
|
400
|
-
end
|
|
401
|
-
end
|
|
407
|
+
extid_suffix = user.extid.sub(/^ext_/, '')
|
|
408
|
+
|
|
409
|
+
# Must be exactly 25 characters of base36
|
|
410
|
+
assert_equal 25, extid_suffix.length
|
|
411
|
+
assert_match(/\A[0-9a-z]{25}\z/, extid_suffix)
|
|
402
412
|
end
|
|
403
413
|
end
|
|
404
414
|
```
|
|
405
415
|
|
|
406
416
|
## Performance Considerations
|
|
407
417
|
|
|
408
|
-
###
|
|
418
|
+
### Lookup Table Size
|
|
409
419
|
|
|
410
|
-
|
|
411
|
-
# Instead of individual lookups
|
|
412
|
-
external_ids = ["ext_1", "ext_2", "ext_3"]
|
|
413
|
-
users = external_ids.map { |id| User.find_by_external_id(id) }
|
|
420
|
+
Each class with external identifiers maintains its own lookup table:
|
|
414
421
|
|
|
415
|
-
|
|
416
|
-
|
|
422
|
+
```ruby
|
|
423
|
+
# Monitor lookup table growth
|
|
424
|
+
puts User.extid_lookup.length # Number of extid mappings
|
|
425
|
+
puts Customer.extid_lookup.length # Separate table per class
|
|
417
426
|
```
|
|
418
427
|
|
|
419
|
-
###
|
|
428
|
+
### Batch Operations
|
|
429
|
+
|
|
430
|
+
For bulk operations, consider the lookup table overhead:
|
|
420
431
|
|
|
421
432
|
```ruby
|
|
422
|
-
|
|
423
|
-
|
|
433
|
+
# Each save updates the lookup table
|
|
434
|
+
1000.times do |i|
|
|
435
|
+
User.new(email: "user#{i}@example.com").save
|
|
436
|
+
end
|
|
424
437
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
438
|
+
# Consider batch cleanup if needed
|
|
439
|
+
# User.extid_lookup.clear if rebuilding from scratch
|
|
440
|
+
```
|
|
428
441
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
end
|
|
442
|
+
## Integration Patterns
|
|
443
|
+
|
|
444
|
+
### API Controllers
|
|
433
445
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
446
|
+
```ruby
|
|
447
|
+
class UsersController < ApplicationController
|
|
448
|
+
# Use external IDs in URLs
|
|
449
|
+
def show
|
|
450
|
+
@user = User.find_by_extid(params[:id])
|
|
451
|
+
|
|
452
|
+
if @user.nil?
|
|
453
|
+
render json: { error: 'User not found' }, status: 404
|
|
454
|
+
else
|
|
455
|
+
render json: user_json(@user)
|
|
438
456
|
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
private
|
|
439
460
|
|
|
440
|
-
|
|
461
|
+
def user_json(user)
|
|
462
|
+
{
|
|
463
|
+
id: user.extid, # Use extid in API responses
|
|
464
|
+
email: user.email,
|
|
465
|
+
name: user.name
|
|
466
|
+
}
|
|
441
467
|
end
|
|
442
468
|
end
|
|
443
469
|
```
|
|
444
470
|
|
|
445
|
-
###
|
|
471
|
+
### URL Generation
|
|
446
472
|
|
|
447
473
|
```ruby
|
|
448
|
-
|
|
449
|
-
|
|
474
|
+
# In views/helpers
|
|
475
|
+
def user_path(user)
|
|
476
|
+
"/users/#{user.extid}" # Short, clean URLs
|
|
477
|
+
end
|
|
450
478
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
score: ->(obj) { obj.last_sync_at&.to_i || 0 }
|
|
458
|
-
|
|
459
|
-
def self.pending_sync_resources(limit: 100)
|
|
460
|
-
# Query resources that need syncing, ordered by oldest first
|
|
461
|
-
pending_sync_resources.range(0, limit - 1).map { |id| load(id) }.compact
|
|
462
|
-
end
|
|
479
|
+
# Instead of:
|
|
480
|
+
# "/users/01234567-89ab-cdef-1234-567890abcdef"
|
|
481
|
+
#
|
|
482
|
+
# Generate:
|
|
483
|
+
# "/users/ext_abc123def456ghi789jkl012"
|
|
484
|
+
```
|
|
463
485
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
486
|
+
### External System Integration
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
class WebhookHandler
|
|
490
|
+
def handle_external_callback(payload)
|
|
491
|
+
external_id = payload['user_id'] # From external system
|
|
492
|
+
|
|
493
|
+
user = User.find_by_extid(external_id)
|
|
494
|
+
return unless user
|
|
495
|
+
|
|
496
|
+
# Process callback for identified user
|
|
497
|
+
process_user_event(user, payload)
|
|
476
498
|
end
|
|
477
499
|
end
|
|
478
500
|
```
|
|
479
501
|
|
|
480
|
-
##
|
|
502
|
+
## Best Practices
|
|
481
503
|
|
|
482
|
-
###
|
|
504
|
+
### Use in Public APIs
|
|
483
505
|
|
|
484
506
|
```ruby
|
|
485
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
class ExternalUserTest < Minitest::Test
|
|
489
|
-
def test_bidirectional_mapping
|
|
490
|
-
user = ExternalUser.create(
|
|
491
|
-
internal_id: "test_123",
|
|
492
|
-
external_id: "ext_456",
|
|
493
|
-
name: "Test User"
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
# Test lookup by external ID
|
|
497
|
-
found_by_external = ExternalUser.find_by_external_id("ext_456")
|
|
498
|
-
assert_equal user.internal_id, found_by_external.internal_id
|
|
507
|
+
# ✅ Good - external IDs in public API
|
|
508
|
+
GET /api/users/ext_abc123def456ghi789jkl012
|
|
499
509
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
end
|
|
510
|
+
# ❌ Avoid - internal UUIDs in public API
|
|
511
|
+
GET /api/users/01234567-89ab-cdef-1234-567890abcdef
|
|
512
|
+
```
|
|
504
513
|
|
|
505
|
-
|
|
506
|
-
user = ExternalUser.create(
|
|
507
|
-
internal_id: "test_123",
|
|
508
|
-
external_id: "ext_456",
|
|
509
|
-
name: "Test User"
|
|
510
|
-
)
|
|
511
|
-
|
|
512
|
-
# Test status transitions
|
|
513
|
-
user.mark_sync_pending
|
|
514
|
-
assert user.sync_pending?
|
|
515
|
-
refute user.sync_completed?
|
|
516
|
-
|
|
517
|
-
user.mark_sync_completed
|
|
518
|
-
assert user.sync_completed?
|
|
519
|
-
refute user.sync_pending?
|
|
520
|
-
|
|
521
|
-
user.mark_sync_failed("Network error")
|
|
522
|
-
assert user.sync_failed?
|
|
523
|
-
assert_equal "Network error", user.sync_error
|
|
524
|
-
end
|
|
514
|
+
### Consistent Format Across Models
|
|
525
515
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
516
|
+
```ruby
|
|
517
|
+
class User < Familia::Horreum
|
|
518
|
+
feature :object_identifier
|
|
519
|
+
feature :external_identifier, format: 'user_%{id}'
|
|
520
|
+
end
|
|
531
521
|
|
|
532
|
-
|
|
522
|
+
class Order < Familia::Horreum
|
|
523
|
+
feature :object_identifier
|
|
524
|
+
feature :external_identifier, format: 'order_%{id}'
|
|
525
|
+
end
|
|
533
526
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
527
|
+
class Product < Familia::Horreum
|
|
528
|
+
feature :object_identifier
|
|
529
|
+
feature :external_identifier, format: 'prod_%{id}'
|
|
537
530
|
end
|
|
538
531
|
```
|
|
539
532
|
|
|
540
|
-
###
|
|
533
|
+
### Error Handling in Controllers
|
|
541
534
|
|
|
542
535
|
```ruby
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
.to_return(
|
|
549
|
-
status: 200,
|
|
550
|
-
body: { data: "mocked_data", updated_at: Time.now.iso8601 }.to_json
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
stub_request(:post, /external-api\.com\/resource/)
|
|
554
|
-
.to_return(
|
|
555
|
-
status: 201,
|
|
556
|
-
body: { id: "ext_#{rand(1000)}", status: "created" }.to_json
|
|
557
|
-
)
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
def self.setup_error_mocks
|
|
561
|
-
# Mock API errors for testing error handling
|
|
562
|
-
stub_request(:get, /external-api\.com\/resource\/ext_error/)
|
|
563
|
-
.to_return(status: 500, body: "Internal Server Error")
|
|
564
|
-
end
|
|
536
|
+
def find_user_by_external_id(extid)
|
|
537
|
+
User.find_by_extid(extid) || raise(ActiveRecord::RecordNotFound)
|
|
538
|
+
rescue Familia::ExternalIdentifierError => e
|
|
539
|
+
Rails.logger.warn "Invalid external ID format for '#{extid}': #{e.message}"
|
|
540
|
+
raise(ActiveRecord::RecordNotFound)
|
|
565
541
|
end
|
|
566
542
|
```
|
|
567
543
|
|
|
568
544
|
## Troubleshooting
|
|
569
545
|
|
|
570
|
-
###
|
|
546
|
+
### Extid Returns Nil
|
|
547
|
+
|
|
548
|
+
Check that object has valid objid and required features:
|
|
571
549
|
|
|
572
|
-
**External ID Not Found**
|
|
573
550
|
```ruby
|
|
574
|
-
|
|
575
|
-
puts ExternalUser.external_id_mapping.hgetall
|
|
576
|
-
# Shows all external_id -> internal_id mappings
|
|
551
|
+
user = User.new(email: 'test@example.com')
|
|
577
552
|
|
|
578
|
-
#
|
|
579
|
-
|
|
580
|
-
|
|
553
|
+
# ❌ No objid yet - extid will be nil
|
|
554
|
+
user.extid # => nil
|
|
555
|
+
|
|
556
|
+
# ✅ Save first to generate objid
|
|
557
|
+
user.save
|
|
558
|
+
user.extid # => "ext_abc123..."
|
|
581
559
|
```
|
|
582
560
|
|
|
583
|
-
|
|
584
|
-
```ruby
|
|
585
|
-
# Check sync status for all objects of a type
|
|
586
|
-
ExternalUser.all.each do |user|
|
|
587
|
-
puts "#{user.external_id}: #{user.sync_status} (#{user.sync_error})"
|
|
588
|
-
end
|
|
561
|
+
### Lookup Not Working
|
|
589
562
|
|
|
590
|
-
|
|
591
|
-
ExternalUser.all.select(&:sync_failed?).each(&:clear_sync_error)
|
|
592
|
-
```
|
|
563
|
+
Ensure object was saved to populate lookup table:
|
|
593
564
|
|
|
594
|
-
**Validation Failures**
|
|
595
565
|
```ruby
|
|
596
|
-
user =
|
|
566
|
+
user = User.new(email: 'test@example.com')
|
|
567
|
+
extid = user.extid # Generates extid but doesn't save lookup
|
|
597
568
|
|
|
598
|
-
|
|
599
|
-
puts "Validation failed for: #{user.external_id}"
|
|
600
|
-
puts "Expected pattern: #{ExternalUser.validation_pattern}"
|
|
601
|
-
end
|
|
602
|
-
```
|
|
569
|
+
User.find_by_extid(extid) # => nil (lookup not saved)
|
|
603
570
|
|
|
604
|
-
|
|
571
|
+
user.save # Saves lookup mapping
|
|
572
|
+
User.find_by_extid(extid) # => user (now works)
|
|
573
|
+
```
|
|
605
574
|
|
|
606
|
-
|
|
607
|
-
# Monitor external ID lookup performance
|
|
608
|
-
def benchmark_external_lookups(external_ids)
|
|
609
|
-
require 'benchmark'
|
|
575
|
+
### Format Not Applied
|
|
610
576
|
|
|
611
|
-
|
|
612
|
-
x.report("Individual lookups:") do
|
|
613
|
-
external_ids.each { |id| ExternalUser.find_by_external_id(id) }
|
|
614
|
-
end
|
|
577
|
+
Check feature options syntax:
|
|
615
578
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
579
|
+
```ruby
|
|
580
|
+
# ❌ Wrong - options after comma
|
|
581
|
+
class User < Familia::Horreum
|
|
582
|
+
feature :object_identifier
|
|
583
|
+
feature :external_identifier, { format: 'user_%{id}' }
|
|
620
584
|
end
|
|
621
585
|
|
|
622
|
-
#
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
puts "#{key}: #{size} mappings"
|
|
586
|
+
# ✅ Correct - options as keyword arguments
|
|
587
|
+
class User < Familia::Horreum
|
|
588
|
+
feature :object_identifier
|
|
589
|
+
feature :external_identifier, format: 'user_%{id}'
|
|
627
590
|
end
|
|
628
591
|
```
|
|
629
592
|
|
|
630
|
-
|
|
593
|
+
### Invalid Format Regex
|
|
594
|
+
|
|
595
|
+
When testing external ID format, use exact character counts:
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# ❌ Wrong - uses + quantifier
|
|
599
|
+
extid.match(/\Aext_[0-9a-z]+\z/)
|
|
600
|
+
|
|
601
|
+
# ✅ Correct - uses exact count {25}
|
|
602
|
+
extid.match(/\Aext_[0-9a-z]{25}\z/)
|
|
603
|
+
```
|
|
631
604
|
|
|
632
605
|
## See Also
|
|
633
606
|
|
|
634
|
-
-
|
|
635
|
-
-
|
|
636
|
-
-
|
|
637
|
-
- **[Implementation Guide](implementation.md)** - Advanced configuration and migration patterns
|
|
607
|
+
- [Object Identifiers](feature-object-identifiers.md) - Required dependency for external identifiers
|
|
608
|
+
- [Feature System](feature-system.md) - Understanding Familia's feature architecture
|
|
609
|
+
- [Relationships](feature-relationships.md) - Using external IDs with relationships
|