From f625af5c5ab11be9325f1715e33b08bf1b26e5c5 Mon Sep 17 00:00:00 2001 From: "Alex H. Raber" Date: Thu, 6 Aug 2026 01:23:46 -0700 Subject: [PATCH 1/3] feat: make dactyl a thin application read write driver --- .decapod/governance/claims.json | 2 +- .decapod/governance/plan.json | 28 + .decapod/governance/trajectory.json | 177 +++ .decapod/governance/validation.json | 158 +-- .decapod/managed/Dockerfile.decapod | 4 +- .decapod/managed/specs/.manifest.json | 50 +- .decapod/managed/specs/ARCHITECTURE.md | 36 +- .decapod/managed/specs/INTENT.md | 16 +- .decapod/managed/specs/INTERFACES.md | 32 +- .decapod/managed/specs/OPERATIONS.md | 4 +- .decapod/managed/specs/README.md | 4 +- .decapod/managed/specs/SECURITY.md | 4 +- .decapod/managed/specs/SEMANTICS.md | 4 +- .decapod/managed/specs/VALIDATION.md | 4 +- .github/release.toml | 10 +- AGENTS.md | 4 +- CHANGELOG.md | 6 + CLAUDE.md | 4 +- CODEX.md | 4 +- Cargo.lock | 107 +- Cargo.toml | 9 +- GEMINI.md | 4 +- README.md | 169 +-- dactyl-db-macros/Cargo.toml | 16 - dactyl-db-macros/src/lib.rs | 51 - examples/readme_example.rs | 39 +- src/__private.rs | 27 - src/adapter/mod.rs | 38 +- src/adapter/neon/mod.rs | 235 +--- src/adapter/sqlite/mod.rs | 491 ++++--- src/error.rs | 59 +- src/lib.rs | 425 +------ src/query/dialect.rs | 93 -- src/query/lexer.rs | 278 ---- src/query/mod.rs | 367 ------ src/rows.rs | 4 +- tests/backend_contract.rs | 145 +-- tests/conformance.rs | 1625 ++---------------------- tests/release_configuration.rs | 89 +- 39 files changed, 988 insertions(+), 3834 deletions(-) create mode 100644 .decapod/governance/plan.json create mode 100644 .decapod/governance/trajectory.json delete mode 100644 dactyl-db-macros/Cargo.toml delete mode 100644 dactyl-db-macros/src/lib.rs delete mode 100644 src/__private.rs delete mode 100644 src/query/dialect.rs delete mode 100644 src/query/lexer.rs delete mode 100644 src/query/mod.rs diff --git a/.decapod/governance/claims.json b/.decapod/governance/claims.json index 7c047d3..9d0642d 100644 --- a/.decapod/governance/claims.json +++ b/.decapod/governance/claims.json @@ -5,7 +5,7 @@ "ledger_id": "project-research-claims", "title": "Project Falsifiable Research Claims", "purpose": "Provide a schema-valid starting point for repository-owned, evidence-bounded claims.", - "authority": {"owner":"Project maintainers","source_issue":"https://github.com/DecapodLabs/decapod/issues/1025","status":"active","created_at":"2026-07-25","updated_at":"2026-07-25","change_policy":"Replace or extend this template through issue-scoped review while preserving baseline, condition, failure modes, measurements, and proof references. Dactyl now exposes a backend-neutral, connection-scoped datastore contract that covers Decapod SQLite operations without exposing rusqlite types; proof is the adapter contract and runtime analysis test suite."}, + "authority": {"owner":"Project maintainers","source_issue":"https://github.com/DecapodLabs/decapod/issues/1025","status":"active","created_at":"2026-07-25","updated_at":"2026-07-25","change_policy":"Replace or extend this template through issue-scoped review while preserving baseline, condition, failure modes, measurements, and proof references. Dactyl now exposes a backend-neutral, connection-scoped datastore contract that covers Decapod SQLite operations without exposing rusqlite types; proof is the adapter contract and runtime analysis test suite. Issue #47 narrows dactyl-db to a thin SQLite and Vercel Neon application read/write driver; proof is the congruent adapter contract and passing Rust checks."}, "scope": {"product":"project","repository":"repository-owner/repository-name","in_scope":["repository capabilities with explicit evidence"],"out_of_scope":["unmeasured production performance","security guarantees without repository evidence"]}, "methodology": {"falsifiability_standard":"Every claim names a baseline, an observable condition, a failure mode, measurable evidence, and a proof gate.","evidence_statuses":["not_started","instrumented","partial","supported","falsified","superseded"],"measurement_rules":[{"id":"proof-reference","name":"Proof reference","requirement":"A passing claim must identify the artifact or command that supports it.","unit":"proof_reference","evidence_source":"repository-native governance artifacts"}]}, "claims": [{ diff --git a/.decapod/governance/plan.json b/.decapod/governance/plan.json new file mode 100644 index 0000000..0a395f7 --- /dev/null +++ b/.decapod/governance/plan.json @@ -0,0 +1,28 @@ +{ + "schema_version": "1.0.0", + "title": "Thin Dactyl SQLite and Neon application driver", + "intent": "Ship one congruent read(sql, params) and write(sql, params) facade for read/write-heavy applications over local SQLite and Vercel Neon. Use private adapters, typed values, normalized rows, affected counts, and typed errors; exclude schema administration, transactions, query analysis or rewriting, retries, analytics, and business intelligence.", + "state": "APPROVED", + "todo_ids": [ + "code_01kzb14pzzgekyfs" + ], + "proof_hooks": [ + "cargo fmt --all -- --check", + "cargo clippy --all-targets --all-features -- -D warnings", + "cargo test --all-features", + "cargo test" + ], + "unknowns": [], + "human_questions": [], + "stop_conditions": [ + "Stop if the SQLite and Neon read/write contracts cannot remain congruent without adding backend administration or query intelligence." + ], + "unresolved_contradictions": [], + "deferred_questions": [], + "constraints": { + "forbidden_paths": [], + "file_touch_budget": null + }, + "phases": [], + "updated_at": "1786003599Z" +} \ No newline at end of file diff --git a/.decapod/governance/trajectory.json b/.decapod/governance/trajectory.json new file mode 100644 index 0000000..99d14be --- /dev/null +++ b/.decapod/governance/trajectory.json @@ -0,0 +1,177 @@ +{ + "schema_version": "1.1.0", + "run_id": "validation_01KZB2S1AFQ708TDRS6BAR17TH", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "task_id": "code_01kzb14pzzgekyfs", + "original_intent": "Produce proof artifacts for a repository validation completion.", + "derived_intent": "Run the bounded validation gates and bind the successful receipt to a trajectory.", + "destination": "validation completion", + "current_phase": "validation", + "next_transitions": [ + "publish" + ], + "active_boundaries": [ + "Repository validation and tracked proof artifacts" + ], + "repo_scope": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "inspected_files": [], + "modified_files": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "declared_commands": [ + "decapod validate" + ], + "tool_calls": [], + "checks": [ + { + "name": "decapod validate", + "status": "passed" + } + ], + "evidence": [ + "validation epoch ve_df73bc67cd796539 completed with zero failures", + "validation epoch ve_f4d27fb540d3293f completed with zero failures" + ], + "shortcut_risk_signals": [], + "unresolved_assumptions": [], + "proof_status": "passed", + "verdicts": { + "intent_alignment": "supported", + "boundary_discipline": "supported", + "shortcut_risk": "supported", + "completion_proof": "supported" + }, + "artifact_hash": "sha256:d977cc6ed5f7674ab4b1a685a0227391eaa712d0d53723aa9c4f29dc057d7217", + "custody": { + "schema_version": "1.0.0", + "intents": { + "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH": { + "id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "raw_intent": "Produce proof artifacts for a repository validation completion.", + "refined_intent": "Run the bounded validation gates and bind the successful receipt to a trajectory.", + "acceptance_criteria": [], + "constraints": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "assumptions": [], + "boundaries": [ + "Repository validation and tracked proof artifacts" + ], + "out_of_scope": [], + "proof_requirements": [], + "status": "refined", + "created_sequence": 1, + "updated_sequence": 3 + } + }, + "events": [ + { + "sequence": 2, + "event_id": "event:2", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "kind": "created" + }, + { + "sequence": 3, + "event_id": "event:3", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "kind": "refined" + }, + { + "sequence": 4, + "event_id": "event:4", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "kind": "trajectory_step_recorded", + "detail": "trajectory:validation_01KZB2S1AFQ708TDRS6BAR17TH" + }, + { + "sequence": 5, + "event_id": "event:5", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "kind": "trajectory_step_recorded", + "detail": "trajectory:validation_01KZB2S1AFQ708TDRS6BAR17TH" + }, + { + "sequence": 6, + "event_id": "event:6", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "kind": "trajectory_step_recorded", + "detail": "trajectory:validation_01KZB2S1AFQ708TDRS6BAR17TH" + } + ], + "trajectories": { + "validation_01KZB2S1AFQ708TDRS6BAR17TH": { + "id": "validation_01KZB2S1AFQ708TDRS6BAR17TH", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "evidence_only": true, + "steps": [ + { + "sequence": 4, + "action": "validation", + "command": "decapod validate", + "scope": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "observations": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json", + "validation epoch ve_f4d27fb540d3293f completed with zero failures" + ], + "proof_refs": [ + "decapod validate" + ], + "validation_findings": [], + "custody_event_id": "event:4" + }, + { + "sequence": 5, + "action": "validation", + "command": "decapod validate", + "scope": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "observations": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json", + "validation epoch ve_f4d27fb540d3293f completed with zero failures" + ], + "proof_refs": [ + "decapod validate" + ], + "validation_findings": [], + "custody_event_id": "event:5" + }, + { + "sequence": 6, + "action": "validation", + "command": "decapod validate", + "scope": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "observations": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json", + "validation epoch ve_df73bc67cd796539 completed with zero failures" + ], + "proof_refs": [ + "decapod validate" + ], + "validation_findings": [], + "custody_event_id": "event:6" + } + ] + } + }, + "summaries": {}, + "knowledge_candidates": [], + "next_sequence": 6 + } +} \ No newline at end of file diff --git a/.decapod/governance/validation.json b/.decapod/governance/validation.json index abca609..58e7919 100644 --- a/.decapod/governance/validation.json +++ b/.decapod/governance/validation.json @@ -1,39 +1,39 @@ { "schema_version": "1.0.0", "kind": "validation_receipt", - "decapod_release": "0.96.12", - "git_revision": "580a5aaec89ad813fb11e32e048177d741a39b0b", - "repo_signal_fingerprint": "63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc", - "trajectory_run_id": "validation_01KZAYYE0NTB477TH3QDXD6A7T", - "trajectory_artifact_hash": "sha256:17ee1f26ee3b339e9a6a90969a34e9b42339ef0668ef31e7f9db34ae722d4e02", + "decapod_release": "0.96.13", + "git_revision": "71e468e720e32cdc0a349837b6d47d6ac0ee62b3", + "repo_signal_fingerprint": "4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b", + "trajectory_run_id": "validation_01KZB2S1AFQ708TDRS6BAR17TH", + "trajectory_artifact_hash": "sha256:d977cc6ed5f7674ab4b1a685a0227391eaa712d0d53723aa9c4f29dc057d7217", "validation_epoch": { "schema_version": "1.0.0", - "epoch_id": "ve_e25cedff425dfddd", - "evaluator_identity": "decapod-validate@0.96.12", - "evaluator_set_hash": "sha256:dd414dbfd4f554dbc455d934ac3a561e5c98b650a2f1f7d0e2fd57b5dcd95f92", - "constitution_version": "embedded-docs@0.96.12", - "constitution_hash": "sha256:249abb8b691ce95a13fa8889de3790691fcd6636689824f888c70ce948660fcf", + "epoch_id": "ve_df73bc67cd796539", + "evaluator_identity": "decapod-validate@0.96.13", + "evaluator_set_hash": "sha256:4a16020b96803c0ed3b56e9cdfb47118dd7cdc28570bcc3456a58ed22259630b", + "constitution_version": "embedded-docs@0.96.13", + "constitution_hash": "sha256:d237918359437c5afe4ec10006ee2c9d84c5b6031300ade1cb6d805910aa0743", "validation_profile": "default", "validation_profile_hash": "sha256:37a8eec1ce19687d132fe29051dca629d164e2c4958ba141d5f4133a33f0688f", "proof_rubric": "decapod-validate/current-proof-v1", "proof_rubric_hash": "sha256:99e8ef25629da391e9b479db9fff52b82f340224f7a69d8a88d2992ae8e90466", - "generated_specs_manifest_hash": "sha256:6e9a7aac76c0361206944dd6aaba7fa4349bd93b97ad7283578d194151285ac5", - "generated_specs_fingerprint": "63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc", + "generated_specs_manifest_hash": "sha256:96f2b4d2f6c82f89929f6516e9d578351edef2db6298b4fe8f78cfefa004fa94", + "generated_specs_fingerprint": "4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b", "material_hashes": { - "constitution:core/DECAPOD": "sha256:249abb8b691ce95a13fa8889de3790691fcd6636689824f888c70ce948660fcf", - "generated_spec:.decapod/managed/specs/ARCHITECTURE.md": "sha256:483014c702d4442115b06dfd2c16b1b48b9237904851f981a703190c580e1959", - "generated_spec:.decapod/managed/specs/INTENT.md": "sha256:49e8b2ad967605902eaf72291029e1083e35d751f6366ed2d19e47a9be5f37ab", - "generated_spec:.decapod/managed/specs/INTERFACES.md": "sha256:f4177ebfcd2d7b3403fe1ba9d5db8d31b831555611625ca36d46b78e80c5fd39", - "generated_spec:.decapod/managed/specs/OPERATIONS.md": "sha256:4c2cd2f1144541fa47ca872109c810fc63154b586a542aea7388322ecff0d464", - "generated_spec:.decapod/managed/specs/README.md": "sha256:7173252f323422d4c4a1c289ad295f729df138ec33a5cb3a697afe01be8c2f82", - "generated_spec:.decapod/managed/specs/SECURITY.md": "sha256:d3f0fb9a3013408bbb25cb65d1d52d58ab0c2bed86195a8492823046c759bc35", - "generated_spec:.decapod/managed/specs/SEMANTICS.md": "sha256:19623d87678f4b027569b9d552c0b967c1f217bfc09e954e302f009a180c0532", - "generated_spec:.decapod/managed/specs/VALIDATION.md": "sha256:5083d74ff26e85d562fe361955a4b073a6aae788b4cb4411bc2b94ab87f0066d", - "generated_specs_fingerprint": "63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc", - "generated_specs_manifest": "sha256:6e9a7aac76c0361206944dd6aaba7fa4349bd93b97ad7283578d194151285ac5", - "living_spec_material:.decapod/managed/specs/ARCHITECTURE.md": "sha256:b62abf9ce898fabcdc2acd155edba52899ae718fce627f53d3437eb03dc78fd2", - "living_spec_material:.decapod/managed/specs/INTENT.md": "sha256:a55effecf42035e1b1dab51394ad79b2942e92fd860f29560329e60a621e9329", - "living_spec_material:.decapod/managed/specs/INTERFACES.md": "sha256:f100bb0d918dd432643316dbdd3256e52f90031b90f317856a382905df248c06", + "constitution:core/DECAPOD": "sha256:d237918359437c5afe4ec10006ee2c9d84c5b6031300ade1cb6d805910aa0743", + "generated_spec:.decapod/managed/specs/ARCHITECTURE.md": "sha256:f70c87e742793aa0f349b67342137a6750352af6379a7b695e7940d976ef08b0", + "generated_spec:.decapod/managed/specs/INTENT.md": "sha256:a90aeb0710175b3bbe69bc770e7db65f8ed4ec0b116799c6c47bd0ed4ff1eee7", + "generated_spec:.decapod/managed/specs/INTERFACES.md": "sha256:d04cc7ed81ec4ba1c553aa4c852c877a07f9b847adc7b2a36ff2c0053d1f5631", + "generated_spec:.decapod/managed/specs/OPERATIONS.md": "sha256:6d633c0827c56a2858ea513623e3ed98e56fb5c247c0a76be641a49aa7a2a5a8", + "generated_spec:.decapod/managed/specs/README.md": "sha256:72dd9cfb0e7bf8dda2e653d13c31ca6bfec96ececa93506ac1140b39091473f5", + "generated_spec:.decapod/managed/specs/SECURITY.md": "sha256:ba37454c51710c0337541c8eea122b1f388362532bcc17777ccd8971b4d9b2c5", + "generated_spec:.decapod/managed/specs/SEMANTICS.md": "sha256:af42f73fc08993e1eae302fe684f98479cce86065d09219627eb94ea63bde000", + "generated_spec:.decapod/managed/specs/VALIDATION.md": "sha256:4cfd8753c1fb4309947fef143e1b17d74d0093a9355533b9f639163014f263ad", + "generated_specs_fingerprint": "4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b", + "generated_specs_manifest": "sha256:96f2b4d2f6c82f89929f6516e9d578351edef2db6298b4fe8f78cfefa004fa94", + "living_spec_material:.decapod/managed/specs/ARCHITECTURE.md": "sha256:acfcb3fcbd4b93e1778de9c6a888026dd378c38d7058604ebde5224e117ced21", + "living_spec_material:.decapod/managed/specs/INTENT.md": "sha256:2089553b54194803f4142cc3b2ee47e6cea483b4a2a8b802483effbd71297940", + "living_spec_material:.decapod/managed/specs/INTERFACES.md": "sha256:e354ac61505a7131b3db4201b7dde1df5542f5ddfb1a749dba0663520c7ae731", "living_spec_material:.decapod/managed/specs/OPERATIONS.md": "sha256:856fa267f2551713c165dd8585dda3fdace7b082e1ac0b213852791e5fea0867", "living_spec_material:.decapod/managed/specs/README.md": "sha256:6957cf384fae78bcc966182ee914b88230c0cbaea909b7b406c53bece8bbfe6c", "living_spec_material:.decapod/managed/specs/SECURITY.md": "sha256:f62572f09e2e66696dff653b9c4f208d9fb32c19bf07c249a625e34de4abc80c", @@ -44,70 +44,75 @@ } }, "status": "ok", - "pass_count": 183, + "pass_count": 182, "fail_count": 0, - "warn_count": 3, - "elapsed_ms": 1081, + "warn_count": 4, + "elapsed_ms": 1271, "drift_findings": [], "temporary_artifacts_cleaned": 0, "failures": [], "warnings": [ + "Failed to prune stale Decapod images: Validation error: Failed to list container images: Failed to obtain podman configuration: set sticky bit on: chmod /run/user/1000/libpod: read-only file system", "Risk map missing (run `decapod riskmap init`)", "Watcher audit trail missing (run `decapod govern watcher run`)", - "Workspace branch 'agent/unknown/code-01kzayjt0kbd71xf' has unpushed commits or does not exist on origin. Run `git push origin agent/unknown/code-01kzayjt0kbd71xf`." + "Workspace branch 'agent/unknown/code-01kzb14pzzgekyfs' has unpushed commits or does not exist on origin. Run `git push origin agent/unknown/code-01kzb14pzzgekyfs`." ], "gate_timings": [ { "name": "validate_machine_contract", - "elapsed_ms": 792 + "elapsed_ms": 501 }, { "name": "validate_markdown_primitives_roundtrip_gate", - "elapsed_ms": 76 + "elapsed_ms": 262 + }, + { + "name": "validate_federation_gates", + "elapsed_ms": 92 }, { "name": "validate_project_specs_docs", - "elapsed_ms": 37 + "elapsed_ms": 89 }, { - "name": "validate_schema_determinism", - "elapsed_ms": 34 + "name": "validate_stale_workspaces", + "elapsed_ms": 63 + }, + { + "name": "validate_control_plane_contract", + "elapsed_ms": 42 }, { "name": "validate_obligations", - "elapsed_ms": 31 + "elapsed_ms": 41 }, { "name": "validate_git_workspace_context", - "elapsed_ms": 13 + "elapsed_ms": 40 }, { - "name": "validate_archive_integrity", - "elapsed_ms": 10 + "name": "validate_schema_determinism", + "elapsed_ms": 33 }, { - "name": "validate_control_plane_contract", - "elapsed_ms": 5 + "name": "validate_archive_integrity", + "elapsed_ms": 10 }, { "name": "validate_git_protected_branch", - "elapsed_ms": 5 + "elapsed_ms": 6 }, { "name": "validate_git_push_pr_gate", "elapsed_ms": 5 }, { - "name": "validate_federation_gates", + "name": "validate_lineage_hard_gate", "elapsed_ms": 4 }, { "name": "validate_legacy_jsonl_retired", - "elapsed_ms": 3 - }, - { - "name": "validate_lineage_hard_gate", - "elapsed_ms": 2 + "elapsed_ms": 4 }, { "name": "validate_override_authority", @@ -115,26 +120,26 @@ }, { "name": "validate_knowledge_integrity", - "elapsed_ms": 1 + "elapsed_ms": 2 }, { - "name": "validate_stale_workspaces", - "elapsed_ms": 1 + "name": "validate_gatekeeper_gate", + "elapsed_ms": 2 }, { - "name": "validate_gatekeeper_gate", + "name": "validate_generated_artifact_whitelist", "elapsed_ms": 1 }, { - "name": "validate_repomap_determinism", + "name": "validate_database_schema_versions", "elapsed_ms": 1 }, { - "name": "validate_generated_artifact_whitelist", + "name": "validate_repomap_determinism", "elapsed_ms": 1 }, { - "name": "validate_database_schema_versions", + "name": "validate_watcher_audit", "elapsed_ms": 1 }, { @@ -142,23 +147,23 @@ "elapsed_ms": 1 }, { - "name": "validate_watcher_audit", + "name": "validate_lcm_rebuild_gate", "elapsed_ms": 1 }, { "name": "validate_knowledge_promotions_if_present", - "elapsed_ms": 0 + "elapsed_ms": 1 }, { "name": "validate_health_cache_integrity", - "elapsed_ms": 0 + "elapsed_ms": 1 }, { - "name": "validate_lcm_rebuild_gate", + "name": "validate_plan_governed_execution_gate", "elapsed_ms": 0 }, { - "name": "validate_plan_governed_execution_gate", + "name": "validate_policy_integrity", "elapsed_ms": 0 }, { @@ -170,7 +175,7 @@ "elapsed_ms": 0 }, { - "name": "validate_policy_integrity", + "name": "validate_entrypoint_invariants", "elapsed_ms": 0 }, { @@ -178,33 +183,29 @@ "elapsed_ms": 0 }, { - "name": "validate_heartbeat_invocation_gate", + "name": "validate_canon_mutation", "elapsed_ms": 0 }, { - "name": "validate_entrypoint_invariants", + "name": "validate_watcher_purity", "elapsed_ms": 0 }, { - "name": "validate_repo_map", + "name": "validate_heartbeat_invocation_gate", "elapsed_ms": 0 }, { - "name": "validate_canon_mutation", + "name": "validate_risk_map_violations", "elapsed_ms": 0 }, { - "name": "validate_watcher_purity", + "name": "validate_repo_map", "elapsed_ms": 0 }, { "name": "validate_project_config_toml", "elapsed_ms": 0 }, - { - "name": "validate_risk_map_violations", - "elapsed_ms": 0 - }, { "name": "validate_trajectory_artifacts_if_present", "elapsed_ms": 0 @@ -238,11 +239,11 @@ "elapsed_ms": 0 }, { - "name": "validate_context_capsules_if_present", + "name": "validate_embedded_self_contained", "elapsed_ms": 0 }, { - "name": "validate_embedded_self_contained", + "name": "validate_state_commit_gate", "elapsed_ms": 0 }, { @@ -250,19 +251,19 @@ "elapsed_ms": 0 }, { - "name": "validate_risk_map", + "name": "validate_root_dockerfile_seed_detection", "elapsed_ms": 0 }, { - "name": "validate_state_commit_gate", + "name": "validate_risk_map", "elapsed_ms": 0 }, { - "name": "validate_root_dockerfile_seed_detection", + "name": "validate_recursive_improvement_passes_if_present", "elapsed_ms": 0 }, { - "name": "validate_recursive_improvement_passes_if_present", + "name": "validate_context_capsules_if_present", "elapsed_ms": 0 }, { @@ -283,14 +284,15 @@ "result": "review", "confidence": "medium", "reasons": [ + "Failed to prune stale Decapod images: Validation error: Failed to list container images: Failed to obtain podman configuration: set sticky bit on: chmod /run/user/1000/libpod: read-only file system", "Risk map missing (run `decapod riskmap init`)", "Watcher audit trail missing (run `decapod govern watcher run`)", - "Workspace branch 'agent/unknown/code-01kzayjt0kbd71xf' has unpushed commits or does not exist on origin. Run `git push origin agent/unknown/code-01kzayjt0kbd71xf`." + "Workspace branch 'agent/unknown/code-01kzb14pzzgekyfs' has unpushed commits or does not exist on origin. Run `git push origin agent/unknown/code-01kzb14pzzgekyfs`." ], "recommendations": [ "Review the reported validation warnings before relying on the CI result.", "Use `decapod validate -v --format json` to inspect the affected gate output." ] }, - "receipt_hash": "sha256:8f43113563fbec5dbb3154e49fa9663ec0cddd9c987ed6f606fa839571ed29e0" + "receipt_hash": "sha256:8c5edb99eb1a9ddf1c00f6a32e3eacab793a285742846cec91e53b6865cb4138" } \ No newline at end of file diff --git a/.decapod/managed/Dockerfile.decapod b/.decapod/managed/Dockerfile.decapod index c4835b9..7fc3bb7 100644 --- a/.decapod/managed/Dockerfile.decapod +++ b/.decapod/managed/Dockerfile.decapod @@ -2,9 +2,9 @@ # Path: .decapod/managed/Dockerfile.decapod # Managed seed: Decapod maintains the image/version header; agents may # mutate project-specific packages and commands in workspace branches. -ARG DECAPOD_IMAGE=ghcr.io/decapodlabs/decapod:v0.96.12-debian +ARG DECAPOD_IMAGE=ghcr.io/decapodlabs/decapod:v0.96.13-debian FROM $DECAPOD_IMAGE -ARG DECAPOD_VERSION=0.96.12 +ARG DECAPOD_VERSION=0.96.13 ARG DECAPOD_WORKSPACE_PATH=unknown ARG DECAPOD_USE_LOCAL_BINARY=0 LABEL org.opencontainers.image.base.name="$DECAPOD_IMAGE" diff --git a/.decapod/managed/specs/.manifest.json b/.decapod/managed/specs/.manifest.json index 59393f6..312a30e 100644 --- a/.decapod/managed/specs/.manifest.json +++ b/.decapod/managed/specs/.manifest.json @@ -1,8 +1,8 @@ { "schema_version": "1.1.0", "template_version": "scaffold-v3", - "generated_at": "1786000965Z", - "repo_signal_fingerprint": "63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc", + "generated_at": "1786003699Z", + "repo_signal_fingerprint": "4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b", "declared_capabilities": [ "authentication", "background-processing", @@ -16,81 +16,81 @@ ], "capability_definition_version": "1.0.0", "config_input_hash": "a6166e839fea76b2d179d7302dbcadd9466b845153bb513ab57d8a44e51cc78c", - "spec_input_hash": "0fcd346ed42d89d771423c1fc7aed7dfaf4970b920f54297645b01c6359f82e5", - "decapod_release": "0.96.12", + "spec_input_hash": "ade7684a24ed69fb8290828d957dab5ff67b5057cd68f7637860587c15a8121f", + "decapod_release": "0.96.13", "entrypoints": [ { "path": "AGENTS.md", - "template_hash": "836abbc29d545152ff0979d09fa1250709b0ae259d86f042f2e68a7170f38e4f", - "content_hash": "836abbc29d545152ff0979d09fa1250709b0ae259d86f042f2e68a7170f38e4f", - "fingerprint": "3d13c64cb6f3a64f17e298d5da5f873a25bb47d45c99b7321b484a9e4931efab" + "template_hash": "5b0d0c6aa621997ab1abc2f1757f74b5d1318d0d3044dbf72447a100195e4563", + "content_hash": "5b0d0c6aa621997ab1abc2f1757f74b5d1318d0d3044dbf72447a100195e4563", + "fingerprint": "50db2bf5158fc58156a220d72f23925c0e3ea50a9a112a2218d027e9318f9ed2" }, { "path": "CLAUDE.md", - "template_hash": "05cb0f2e7674f9b8f23634168e81892c752e23b514a9b774c04ac7e5a37580d6", - "content_hash": "05cb0f2e7674f9b8f23634168e81892c752e23b514a9b774c04ac7e5a37580d6", - "fingerprint": "9416888ecd8449e499584c6274b346bca8de530f85015f5cd7f19213ab3d04ae" + "template_hash": "926a90a19ade9c74cf67f3c53c2ad23862f9ec88f0c331c8d2609fa00b6ddf67", + "content_hash": "926a90a19ade9c74cf67f3c53c2ad23862f9ec88f0c331c8d2609fa00b6ddf67", + "fingerprint": "7a20ea61bd8d271acbcd121804fe591217c2d93485807c08bbb490991a0a055c" }, { "path": "GEMINI.md", - "template_hash": "f72b2dd9adcedda65559f3fb0f16fdd8f47da3ebf1086ba2f85655d302e74beb", - "content_hash": "f72b2dd9adcedda65559f3fb0f16fdd8f47da3ebf1086ba2f85655d302e74beb", - "fingerprint": "d4e416f98c074e30fed738975f35af416294eda36bf0c6acf43b328bffff47c4" + "template_hash": "d2492bac27dbb7acc60c1b12343942d726faef865c8f5f26c20b807e61c1d39b", + "content_hash": "d2492bac27dbb7acc60c1b12343942d726faef865c8f5f26c20b807e61c1d39b", + "fingerprint": "6b990f198da4ffe1ea3ad924967ebab013bec339d7f5d6ed0857e6057e853fa6" }, { "path": "CODEX.md", - "template_hash": "5a38d571bf991339cf7da9b54fcc32b36d5346e3681144256a5d3addc8a40299", - "content_hash": "5a38d571bf991339cf7da9b54fcc32b36d5346e3681144256a5d3addc8a40299", - "fingerprint": "54d0cb763d6b6d3097ac4071c9aac582aa18af40e46ba88502a6cbd04bc85556" + "template_hash": "c74e53a13e1a244daaed281f4b5488dfce880cdce6f6e70dbf0728adc6600aff", + "content_hash": "c74e53a13e1a244daaed281f4b5488dfce880cdce6f6e70dbf0728adc6600aff", + "fingerprint": "20891c2ec64ebf8e7abaea759133bc4ec73c8858647d57acc18c8199e1a52702" } ], "files": [ { "path": ".decapod/managed/specs/README.md", "template_hash": "6957cf384fae78bcc966182ee914b88230c0cbaea909b7b406c53bece8bbfe6c", - "content_hash": "7173252f323422d4c4a1c289ad295f729df138ec33a5cb3a697afe01be8c2f82", + "content_hash": "72dd9cfb0e7bf8dda2e653d13c31ca6bfec96ececa93506ac1140b39091473f5", "fingerprint": "" }, { "path": ".decapod/managed/specs/INTENT.md", "template_hash": "e6c06430e23ca87ae33169a6b1f9c70a3fd283655f5b5b29755fb83181f989e9", - "content_hash": "49e8b2ad967605902eaf72291029e1083e35d751f6366ed2d19e47a9be5f37ab", + "content_hash": "a90aeb0710175b3bbe69bc770e7db65f8ed4ec0b116799c6c47bd0ed4ff1eee7", "fingerprint": "" }, { "path": ".decapod/managed/specs/ARCHITECTURE.md", "template_hash": "58f2abf6f6e0c3da62bc0850e71e12abd76c5064c793701c7b9330036713b163", - "content_hash": "483014c702d4442115b06dfd2c16b1b48b9237904851f981a703190c580e1959", + "content_hash": "f70c87e742793aa0f349b67342137a6750352af6379a7b695e7940d976ef08b0", "fingerprint": "" }, { "path": ".decapod/managed/specs/INTERFACES.md", "template_hash": "15cc8c3bbfe951da695384106eb78a44c7af28ddc002bcc83c8a7770229fe999", - "content_hash": "f4177ebfcd2d7b3403fe1ba9d5db8d31b831555611625ca36d46b78e80c5fd39", + "content_hash": "d04cc7ed81ec4ba1c553aa4c852c877a07f9b847adc7b2a36ff2c0053d1f5631", "fingerprint": "" }, { "path": ".decapod/managed/specs/VALIDATION.md", - "template_hash": "8e22417cbcbd602159a516e761bdf78173fc4f27bac6d92d91a4ccb968534395", - "content_hash": "5083d74ff26e85d562fe361955a4b073a6aae788b4cb4411bc2b94ab87f0066d", + "template_hash": "0e4df61ec86dd7d2667ee7ce9efd517817441d78455f91dc816f0b6141cdce64", + "content_hash": "4cfd8753c1fb4309947fef143e1b17d74d0093a9355533b9f639163014f263ad", "fingerprint": "" }, { "path": ".decapod/managed/specs/SEMANTICS.md", "template_hash": "3a79850c94b81e29c6462a7ea70d45198252e3eeba2132c6e9206f7b4e4a5829", - "content_hash": "19623d87678f4b027569b9d552c0b967c1f217bfc09e954e302f009a180c0532", + "content_hash": "af42f73fc08993e1eae302fe684f98479cce86065d09219627eb94ea63bde000", "fingerprint": "" }, { "path": ".decapod/managed/specs/OPERATIONS.md", "template_hash": "54ab819ae22bea9f786793a37196749cbacd72c60f58108898e9db044c534acf", - "content_hash": "4c2cd2f1144541fa47ca872109c810fc63154b586a542aea7388322ecff0d464", + "content_hash": "6d633c0827c56a2858ea513623e3ed98e56fb5c247c0a76be641a49aa7a2a5a8", "fingerprint": "" }, { "path": ".decapod/managed/specs/SECURITY.md", "template_hash": "b283bdc62b7c2fce411becf6b31928d1b3502da28be7ad618531e3e1820fc147", - "content_hash": "d3f0fb9a3013408bbb25cb65d1d52d58ab0c2bed86195a8492823046c759bc35", + "content_hash": "ba37454c51710c0337541c8eea122b1f388362532bcc17777ccd8971b4d9b2c5", "fingerprint": "" } ] diff --git a/.decapod/managed/specs/ARCHITECTURE.md b/.decapod/managed/specs/ARCHITECTURE.md index a9ae960..318fc57 100644 --- a/.decapod/managed/specs/ARCHITECTURE.md +++ b/.decapod/managed/specs/ARCHITECTURE.md @@ -3,13 +3,13 @@ library ## What This Project Is -dactyl-db is the single SQL-vendor-agnostic Rust persistence framework: one `query(sql, params)` surface over any structured-query-language backend. SQLite and Neon ship today; Redis, MySQL, and Cassandra are planned behind the same facade. +dactyl-db is a small Rust application driver: one `read(sql, params)` and one `write(sql, params)` surface over SQLite and Vercel Neon. It is not a general SQL framework or database-administration layer. Architectural principles: - **Simplicity**: Keep components focused and reusable. - **Modularity**: Clearly defined interface boundaries and dependency separation. - **Reliability**: Graceful failure handling and thorough verification. -- **Backend-neutrality**: The public API never changes per backend; a new backend is one `Adapter` module plus one `DATASTORE` match arm. +- **Backend-neutrality**: SQLite and Neon receive the same SQL and parameter shapes and return congruent rows/counts; backend-specific handles remain private. ## Current Facts - Runtime/languages: Rust @@ -22,9 +22,9 @@ This project's architecture consists of the following key layers/directories: - `tests/`: Integration and unit test suite. ## Data Flows -- Inbound request/command parses and validates at the entrypoint. -- Core runtime handles business logic and initiates queries or state changes. -- Storage adapter reads or writes data to the underlying persistence layers. +- The application supplies SQL and bound values. +- Dactyl selects SQLite or Neon and forwards the request. +- The adapter returns normalized rows for reads or an affected count for writes. ## Strongest Existing Primitives - Define the strongest existing primitives in the codebase (e.g., helper utilities, base controllers, data access layers). @@ -56,8 +56,8 @@ sequenceDiagram participant DB as Datastore C->>G: Request G->>D: Validate + execute - D->>DB: Commit transaction - DB-->>D: Commit ok + D->>DB: Read or write SQL + DB-->>D: Rows or affected count D-->>G: Domain result G-->>C: Response + trace_id ``` @@ -81,10 +81,10 @@ sequenceDiagram - Verification and artifact emission: ## Concurrency and Runtime Model -- Execution model: each `query` / `execute` / `transaction` call constructs a fresh short-lived adapter for the ambient `DATASTORE` selection and drops it on return. -- Isolation boundaries: no process-global connection cache, so workspace/session isolation is automatic and the public surface is `Send + Sync` without locks. -- Backpressure strategy: N/A at the facade level; Neon adapter relies on the underlying HTTP client. -- Shared state synchronization: none — adapters own their connections for the duration of a call; SQLite serializes its connection behind an internal `Mutex` only because `rusqlite::Connection` is `!Sync`. +- Execution model: each free `read` / `write` call constructs a short-lived adapter for the ambient route and drops it on return; explicit `Connection` scopes several application operations to one route. +- Isolation boundaries: Dactyl keeps no process-global cache and exposes no backend handle. +- Backpressure strategy: owned by the backend or Neon service; Dactyl does not retry or schedule work. +- Shared state synchronization: none at the Dactyl layer. ## Deployment Topology - Runtime units: none (library). @@ -93,16 +93,16 @@ sequenceDiagram - Rollback trigger and blast-radius scope: callers pin a dactyl version; breaking changes are recorded in CHANGELOG.md. ## Data and Contracts -- Inbound contracts (CLI/API/events): `query(sql, params)`, `execute(sql, params)`, `transaction(&[Statement])`, `query!` macro. -- Outbound dependencies (datastores/queues/external APIs): SQLite (rusqlite, bundled) and Neon/Propodus (reqwest, blocking+rustls-tls). -- Data ownership boundaries: callers own all schema. dactyl never silently creates tables; `execute` is the only DDL surface. -- Schema evolution + migration policy: callers version their schema through explicit `execute` DDL statements and `transaction` batches. dactyl records no schema of its own. +- Inbound contracts (application calls): `read(sql, params)` and `write(sql, params)`. +- Outbound dependencies (datastores/queues/external APIs): private SQLite C-API wrapper and Neon/Propodus HTTP transport. +- Data ownership boundaries: the backend owns schema and database intelligence; Dactyl only moves application reads and writes. +- Schema evolution + migration policy: outside Dactyl's scope. ## ADR Register | ADR | Title | Status | Rationale | Date | |---|---|---|---|---| | ADR-001 | Ambient-env routing contract (DATASTORE/DATASTORE_ROUTE/DATASTORE_TOKEN) | Accepted | Single authoritative selector; no init(); per-call adapters for session isolation (dactyl #26) | 2026-08-01 | -| ADR-002 | Slim single-entry API: query(sql, params) + execute + transaction | Accepted | One uniform surface across all current and planned SQL backends (dactyl #23, #24) | 2026-08-01 | +| ADR-002 | Thin application API: read(sql, params) + write(sql, params) | Accepted | One uniform read/write surface for SQLite and Neon; administration remains outside the crate (dactyl #47) | 2026-08-01 | | ADR-003 | Backend-neutral Adapter trait | Proposed | New backends (redis, mysql, cassandra) add one module + one DATASTORE arm; public surface unchanged | 2026-08-01 | ## Delivery Plan (first 3 slices) @@ -139,7 +139,7 @@ sequenceDiagram ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/INTENT.md b/.decapod/managed/specs/INTENT.md index 20048aa..f59f4f9 100644 --- a/.decapod/managed/specs/INTENT.md +++ b/.decapod/managed/specs/INTENT.md @@ -16,11 +16,11 @@ ## Product Outcome -- Establish Dactyl as the governed datastore boundary for Decapod, allowing callers to execute the same query contract against either local SQLite or remote Neon-backed storage without changing application logic. Dactyl must select the requested datastore, analyze SQL before execution, reject unsupported dialect constructs when rewriting is disabled, apply permitted rewrites when enabled, and preserve a narrow public API that does not expose adapter internals. +- Establish Dactyl as the small application-layer database provider for read/write-heavy apps. The same `read(sql, params)` and `write(sql, params)` calls must work against local SQLite and remote Vercel Neon without exposing backend handles or adding database administration behavior. ## What This Project Is dactyl-db is a not classified yet project built using Rust. -Establish Dactyl as the governed datastore boundary for Decapod, allowing callers to execute the same query contract against either local SQLite or remote Neon-backed storage without changing application logic. Dactyl must select the requested datastore, analyze SQL before execution, reject unsupported dialect constructs when rewriting is disabled, apply permitted rewrites when enabled, and preserve a narrow public API that does not expose adapter internals. +Provide a minimal application driver over local SQLite and remote Vercel Neon. Dactyl selects the route, binds application values, forwards raw SQL unchanged, and normalizes rows and affected counts. Database administration, schema ownership, query planning, analytics, retries, and business intelligence remain outside the crate. Key operating facts: - **Primary languages**: Rust @@ -61,7 +61,7 @@ flowchart LR - Security/compliance: sensitive data handling and authz are mandatory. ## Acceptance Criteria (must be objectively testable) -- [ ] The outcome is complete when equivalent queries across all nine Decapod stores produce equivalent row projections through both adapters, compile-time query analysis works for literal queries, runtime analysis works for dynamic queries, inline datastore directives are honored, and the full formatting, linting, and conformance suite passes. +- [ ] The outcome is complete when equivalent application reads and writes produce congruent row projections, bound-value handling, affected counts, and typed error categories through both SQLite and Neon, and the full formatting, linting, and conformance suite passes. - [ ] Non-functional targets are met (latency, reliability, cost, etc.). - [ ] Validation gates pass and artifacts are attached. - [ ] `cargo test` passes for unit/integration coverage @@ -102,9 +102,9 @@ flowchart LR | Strict gates vs dev speed | Higher confidence | More upfront discipline | Lead time regressions | ## First Implementation Slice -- [ ] Define the smallest user-visible workflow to ship first. -- [ ] Define required data/contracts for that workflow. -- [ ] Define what is intentionally postponed until v2. +- [x] Define the smallest user-visible workflow: one parameterized application read and one parameterized application write. +- [x] Define the shared route, parameter, row, affected-count, and error contracts. +- [x] Postpone schema administration, migrations, transactions, query analysis, retries, and business intelligence. ## Open Questions (with decision deadlines) | Question | Owner | Deadline | Decision | @@ -115,7 +115,7 @@ flowchart LR ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/INTERFACES.md b/.decapod/managed/specs/INTERFACES.md index 1ccaa33..863f7d6 100644 --- a/.decapod/managed/specs/INTERFACES.md +++ b/.decapod/managed/specs/INTERFACES.md @@ -71,22 +71,18 @@ pub enum ApiError { ## Dactyl — API Contract (design-time note) -- Public surface (crate root): `query(sql: &str, params: &[Parameter]) -> Result`, `execute(sql: &str, params: &[Parameter]) -> Result`, and `transaction(statements: &[Statement]) -> Result, DactylError>`, plus the `query!("...")` macro and the `Parameter` / `Statement` / `Rows` / `Row` / `DactylError` result types. -- There is no `init` / `active_datastore` and no process-global connection cache. Each call builds a short-lived adapter for the ambient selection and drops it on return, so session isolation is automatic. -- Adapter selection is ambient-env-driven only: - - `DATASTORE` set to `"sqlite"` or `"neon"` (any other value is a typed error). - - `DATASTORE_ROUTE` specifies the database path (SQLite) or the endpoint URL (Neon). - - `DATASTORE_TOKEN` is the optional auth token for Neon. - - No legacy `DACTYL_*` variables are honored. -- Parameters are always adapter-bound, never interpolated into SQL. `Parameter` enumerates `Null` / `Bool` / `Integer` / `Real` / `Text`. -- `execute` is the caller-owned schema surface: dactyl never silently creates tables. -- `Row` provides strict `get` / `try_get`, lenient `get_bool` / `get_int` / `get_real` / `get_str` / `get_json`, borrowed `get_str_ref` / `get_json_ref`, and `is_null`, with explicit `ColumnNotFound` / `Conversion` errors. Named lookup is left-to-right first-match for duplicate aliases. SQL NULL maps to `Option` or a `Conversion` that mentions NULL for non-Option targets. Rows own their cells; borrowed accessors are tied to `&Row` only (dactyl #25 / #2; DecapodLabs/decapod#1111). -- `transaction` is atomic: any per-statement failure rolls back the whole unit on SQLite and is rejected by the Neon `/batch` endpoint (dactyl #24). Nesting is not supported (no SAVEPOINT). dactyl does not retry and exposes no deadline parameter; callers own retry/idempotency after ambiguous transport failures. Empty batch → `Ok([])`. Conformance proves failure-injection on SQLite and Neon mock plus an event-plus-state fixture. -- `query!("sql")` lexically analyzes the literal at compile time and returns the rewritten SQL as a `String` for the caller to pass to `query`. - -### Multi-backend vision - -dactyl-db is the single SQL-vendor-agnostic persistence framework. The `Adapter` trait is backend-neutral and SQL-focused; new backends (Redis, MySQL, Cassandra, and other structured query languages) slot in as one module plus one `DATASTORE` match arm. The public `query` / `execute` / `transaction` surface never changes per backend. +- Public application surface: `read(sql: &str, params: &[Parameter]) -> Result` and `write(sql: &str, params: &[Parameter]) -> Result`, plus `Connection::open` for an explicit route. +- Adapter selection is ambient-env-driven for free functions: `DATASTORE` is `sqlite` or `neon`, `DATASTORE_ROUTE` is the SQLite path or Neon endpoint, and `DATASTORE_TOKEN` is the optional Neon bearer token. +- Parameters are always adapter-bound, never interpolated. `Parameter` covers `Null`, `Bool`, `Integer`, `Real`, `Text`, and `Blob`. +- `Rows` owns normalized `Row` values. SQLite and Neon use the same column/value representation and typed row accessors, including explicit NULL and conversion failures. +- Raw SQL is forwarded unchanged. Dactyl has no query parser, dialect rewriter, schema bootstrap, migration API, transaction API, retry policy, analytics, or business-intelligence behavior. +- Operational adapter errors expose typed categories so application code does not parse backend error strings. + +### Multi-backend boundary + +SQLite and Neon are the current supported application stores. Any future +backend must preserve the same read/write request and response contract; adding +database administration or business-intelligence features is out of scope. @@ -112,7 +108,7 @@ dactyl-db is the single SQL-vendor-agnostic persistence framework. The `Adapter` ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/OPERATIONS.md b/.decapod/managed/specs/OPERATIONS.md index 4b5af2b..f7c4bbe 100644 --- a/.decapod/managed/specs/OPERATIONS.md +++ b/.decapod/managed/specs/OPERATIONS.md @@ -117,7 +117,7 @@ Use `tracing` + `tracing-subscriber` with structured JSON output and request cor ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/README.md b/.decapod/managed/specs/README.md index 5da05cd..6860dda 100644 --- a/.decapod/managed/specs/README.md +++ b/.decapod/managed/specs/README.md @@ -47,7 +47,7 @@ These files are the project-local contract for humans and agents. ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/SECURITY.md b/.decapod/managed/specs/SECURITY.md index f7e2314..a068e35 100644 --- a/.decapod/managed/specs/SECURITY.md +++ b/.decapod/managed/specs/SECURITY.md @@ -102,7 +102,7 @@ Describe the security primitives and security controls implemented in this repos ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/SEMANTICS.md b/.decapod/managed/specs/SEMANTICS.md index 72e814d..f462c39 100644 --- a/.decapod/managed/specs/SEMANTICS.md +++ b/.decapod/managed/specs/SEMANTICS.md @@ -94,7 +94,7 @@ stateDiagram-v2 ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/managed/specs/VALIDATION.md b/.decapod/managed/specs/VALIDATION.md index 0afd25f..750e6da 100644 --- a/.decapod/managed/specs/VALIDATION.md +++ b/.decapod/managed/specs/VALIDATION.md @@ -174,7 +174,7 @@ flowchart LR ## Codebase Attestation -- Repository signal fingerprint: `63442fb00abe0f0d6d0bc4e4603e1a6f021dee36c9c2119d42c2314f5d1256bc` -- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `dactyl-db-macros/` (1 files), `src/` (10 files) +- Repository signal fingerprint: `4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b` +- Significant implementation surfaces: `.github/` (2 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (6 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.github/release.toml b/.github/release.toml index 8f1942f..3d367ee 100644 --- a/.github/release.toml +++ b/.github/release.toml @@ -20,18 +20,10 @@ pr_labels = ["release"] # Allow release-pr computation even if runtime-generated files make tree dirty allow_dirty = true -# The facade and proc-macro crate ship as one compatibility unit. Include each -# package's commits in the other's release calculation so version grouping is -# effective even when a change touches only one package. +# Dactyl is one application-driver crate. [[package]] name = "dactyl-db" version_group = "dactyl" -changelog_include = ["dactyl-db-macros"] - -[[package]] -name = "dactyl-db-macros" -version_group = "dactyl" -changelog_include = ["dactyl-db"] # Semantic versioning based on conventional commits: # - feat: minor version bump diff --git a/AGENTS.md b/AGENTS.md index 42ccf9a..986401f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ - - + + # AGENTS.md — Universal Agent Contract This is a Decapod-managed repository. **Strict Dependency: You are strictly bound to the Decapod governance kernel.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f7c5b..df745d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,3 +142,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - *(dactyl)* read/write are the only public API; first call auto-bootstraps the adapter - ignore .decapod runtime artifacts in .gitignore - Initial commit +## Unreleased + +- *(dactyl-db)* reduce the public contract to congruent application `read` / + `write` operations for SQLite and Neon, remove the proc-macro/query-analysis + layer, and replace the rusqlite facade with a private SQLite C-API driver + ([#47](https://github.com/DecapodLabs/dactyl/issues/47)). diff --git a/CLAUDE.md b/CLAUDE.md index c0c5203..35dba45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ - - + + # CLAUDE.md - Agent Entrypoint You are working in a Decapod-managed repository. diff --git a/CODEX.md b/CODEX.md index b673661..4ed8662 100644 --- a/CODEX.md +++ b/CODEX.md @@ -1,5 +1,5 @@ - - + + # CODEX.md - Agent Entrypoint You are working in a Decapod-managed repository. diff --git a/Cargo.lock b/Cargo.lock index cbb3c97..c918395 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "async-trait" version = "0.1.91" @@ -157,24 +145,13 @@ name = "dactyl-db" version = "0.3.0" dependencies = [ "axum", - "dactyl-db-macros", + "libsqlite3-sys", "reqwest", - "rusqlite", "serde", "serde_json", "tempfile", "thiserror 1.0.69", "tokio", - "tracing", -] - -[[package]] -name = "dactyl-db-macros" -version = "0.3.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", ] [[package]] @@ -198,18 +175,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.5.0" @@ -307,24 +272,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown", -] - [[package]] name = "http" version = "1.5.0" @@ -860,20 +807,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rusqlite" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -1274,21 +1207,9 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -1340,12 +1261,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "want" version = "0.3.1" @@ -1562,26 +1477,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index ad612f1..de353a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,22 +7,20 @@ license = "MIT" repository = "https://github.com/DecapodLabs/dactyl" description = "Interchangeably read and write to local SQLite or cloud-hosted Vercel Neon instances behind a single unified facade." readme = "README.md" -keywords = ["decapod", "datastore", "sql", "sqlite", "postgres"] +keywords = ["datastore", "vercel", "postgres", "sql", "sqlite"] categories = ["database", "api-bindings"] [features] default = [] -sqlite = ["dep:rusqlite"] +sqlite = ["dep:libsqlite3-sys"] neon = ["dep:reqwest"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" -tracing = "0.1" -rusqlite = { version = "0.31", optional = true, features = ["bundled"] } +libsqlite3-sys = { version = "0.28", optional = true, features = ["bundled"] } reqwest = { version = "0.12", optional = true, default-features = false, features = ["blocking", "json", "rustls-tls"] } -dactyl-db-macros = { path = "dactyl-db-macros", version = "0.3.0" } [dev-dependencies] tokio = { version = "1", features = ["full"] } @@ -33,5 +31,4 @@ tempfile = "3" resolver = "2" members = [ ".", - "dactyl-db-macros", ] diff --git a/GEMINI.md b/GEMINI.md index 708bcbb..6b73552 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,5 +1,5 @@ - - + + # GEMINI.md - Agent Entrypoint You are working in a Decapod-managed repository. diff --git a/README.md b/README.md index a1b1822..641c553 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,89 @@ # dactyl-db -[![🦀 Decapod](https://img.shields.io/badge/🦀%20Decapod-v0.96.12-dc2626)](https://github.com/DecapodLabs/decapod) +`dactyl-db` is the application-layer database provider for read/write-heavy +apps that need the same small Rust surface over local SQLite and Vercel Neon. +It forwards application SQL, binds values, and normalizes returned rows so the +application does not need separate driver code for each backend. -`dactyl-db` is a single SQL-vendor-agnostic Rust persistence framework. One crate, one backend-neutral operation contract, and one ambient env-var selector — talk to SQLite, Neon/Postgres, and (planned) Redis, MySQL, Cassandra from one import, instead of pulling a per-database library for each backend. +Dactyl is deliberately not database-administration tooling. It does not own +schema design, migrations, transactions, query planning, analytics, retries, +or business intelligence. Those belong to the backend and the database that +owns the application data. -The same SQL string produces the same logical rows regardless of the active backend. Parameters are always bound, never interpolated, so dynamic values cannot become SQL. +## What is shared -## Key Features +SQLite and Neon use the same application contract: -- **One import, many backends** — SQLite and Neon ship today; Redis, MySQL, and Cassandra are planned behind the same `query` surface. -- **Ambient selection** — `DATASTORE` env var picks the active backend at runtime. No `init()`, no per-call datastore argument, no global connection cache. -- **Safe parameter binding** — `query(sql, &[params])` binds typed values; SQL injection via parameter values is structurally impossible. -- **Atomic batches** — `transaction(&[Statement])` commits all-or-nothing on every backend (no nesting; caller-owned retry/idempotency; see contract below). -- **Connection-scoped integration** — `Connection` and `StorageOp` provide the thin waist needed by Decapod without exposing `rusqlite`, HTTP clients, or adapter types. -- **Caller-owned schema** — dactyl never silently creates tables. `execute("create table ...")` is the only way dactyl touches schema. +- `read(sql, params)` returns owned `Rows`. +- `write(sql, params)` returns the backend-reported affected-row count. +- Values are bound as `Null`, `Bool`, `Integer`, `Real`, `Text`, or `Blob`. +- SQL is forwarded unchanged; Dactyl does not parse, rewrite, or optimize it. +- Adapter failures use the same coarse categories, including query, + constraint, storage, transport, and protocol failures. -## Quick Start +The SQLite implementation uses a small private C-API wrapper through +`libsqlite3-sys`; no `rusqlite` types or high-level database API are exposed. +The Neon implementation sends the same SQL and bound values to the configured +`/query` endpoint. -Add `dactyl-db` to your `Cargo.toml`: +## Quick start ```toml [dependencies] -dactyl-db = { version = "0.1.7", features = ["sqlite", "neon"] } +dactyl-db = { version = "0.3.0", features = ["sqlite", "neon"] } ``` -Set the active backend via env vars at runtime: +Select the backend with environment variables: ```text -DATASTORE=sqlite DATASTORE_ROUTE=/path/to/store.db +DATASTORE=sqlite DATASTORE_ROUTE=/path/to/app.db # or DATASTORE=neon DATASTORE_ROUTE=https://propodus.example DATASTORE_TOKEN=... ``` -### Usage Example +Use the same calls for either backend: ```rust -use dactyl_db::{self, query, execute, Parameter, Rows}; +use dactyl_db::{read, write, Parameter}; -fn main() -> Result<(), dactyl_db::DactylError> { - // The caller owns the schema. dactyl never bootstraps tables on its own. - execute( - "create table if not exists todos (id integer primary key, title text not null, status text not null)", - &[], - )?; - execute( - "insert into todos (id, title, status) values ($1, $2, $3)", - &[ - Parameter::Integer(1), - Parameter::Text("ship dactyl 0.1.7".into()), - Parameter::Text("open".into()), - ], +fn load_app_rows() -> Result<(), dactyl_db::DactylError> { + write( + "insert into app_events (name) values ($1)", + &[Parameter::Text("opened".into())], )?; - let sql = query!("select id, title, status from todos"); - for row in dactyl_db::query(&sql, &[])?.iter() { - // Strict typed projection (owned). Prefer try_get if you like Result style. - let id: i64 = row.try_get("id")?; - let title: String = row.get("title")?; - // Nullable columns use Option; missing columns are ColumnNotFound. - let status: Option = row.get("status")?; - println!("todo {id}: {title} [{status:?}]"); + let rows = read("select name from app_events order by id", &[])?; + for row in rows.iter() { + println!("{}", row.get_str("name")?); } Ok(()) } ``` -Run it: - -```text -DATASTORE=sqlite DATASTORE_ROUTE=/tmp/dactyl-example.db \ - cargo run --features sqlite --example readme_example -``` - -## Named-column projections (`Row`) - -This is the stable contract for typed and NULL-safe extraction (dactyl [#25](https://github.com/DecapodLabs/dactyl/issues/25), conformance [#2](https://github.com/DecapodLabs/dactyl/issues/2); also DecapodLabs/decapod#1111): - -| Concern | Semantics | -|---|---| -| Integer / real / bool / text | `get_int`, `get_real`, `get_bool`, `get_str` (owned) or strict `get::` / `try_get::` via serde | -| Portable bool | `get_bool` accepts JSON `true`/`false` **or** integer `0`/`1` (SQLite stores bools as integers) | -| JSON / text | `get_json` / `get_json_ref` return the raw cell. Text payloads stay strings until the caller parses them; Neon may surface structured JSON objects. | -| SQL NULL | `get::>` → `None`; non-`Option` getters → `Conversion` mentioning NULL; `is_null` / `get_json` surface null without converting | -| Missing column | `DactylError::ColumnNotFound` | -| Duplicate aliases | **First match** left-to-right. `select a as x, b as x` → `get("x")` is `a`. Use a positional index for later duplicates. | -| Owned vs borrowed | `get` / `get_*` return owned values that outlive the row. `get_str_ref` / `get_json_ref` borrow from `&Row` for the row lifetime. A `Row` outlives the adapter connection. | -| Conversion failure | `DactylError::Conversion` with the column key and a reason | - -## How dactyl selects the backend - -The active backend is chosen by ambient environment variables — no `init()` call, no per-call datastore argument, no process-wide connection cache. Each `query` / `execute` / `transaction` call constructs a fresh short-lived adapter and drops it on return, so workspace and session isolation is automatic and the public surface is `Send + Sync` without any lock. - -| Environment Variable | Allowed Values / Format | Required | Description | -|---|---|---|---| -| `DATASTORE` | `"sqlite"` or `"neon"` | yes | Selects the active backend. Any other value is a typed error. | -| `DATASTORE_ROUTE` | SQLite filepath OR Neon endpoint URL | yes | Connection route for the selected backend. | -| `DATASTORE_TOKEN` | Opaque string | no | Bearer token forwarded to Neon. Ignored by SQLite. | -| `DATASTORE_REWRITE` | `1`, `true`, `yes`, or `on` | no | Enables only dactyl's explicitly safe dialect rewrites. Disabled by default. | -| `DATASTORE_SQLITE_ROUTE` | SQLite filepath | no | Optional alternate route for a `-- dactyl: sqlite` inline directive when the active datastore is Neon. | -| `DATASTORE_NEON_ROUTE` | Neon endpoint URL | no | Optional alternate route for a `-- dactyl: neon` inline directive when the active datastore is SQLite. | - -## Connection-scoped integration boundary - -Use an explicit connection when several operations must share one configured -route, such as schema setup, validation, migrations, or a sequence of reads and -writes: +For an explicit route, use `Connection::open`: ```rust -use dactyl_db::{Connection, DatastoreRoute, Parameter, Statement}; - -let db = Connection::open(DatastoreRoute::sqlite(".decapod/data/decapod.db"))?; -db.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY, value TEXT);")?; -db.transaction(&[Statement::new( - "INSERT INTO state(value) VALUES ($1)", - vec![Parameter::Text("ready".into())], -)])?; -``` +use dactyl_db::{Connection, DatastoreRoute, Parameter}; -`Connection::open_with_options` additionally controls read-only SQLite access, -busy timeout, foreign-key enforcement, journal mode (WAL falls back to DELETE), -and safe dialect rewrites. The -operation-based `StorageOp` / `StorageResult` pair is the integration surface -for code that cannot accept a closure tied to a SQLite connection. Dactyl's -public API intentionally does not expose `rusqlite` types. +let db = Connection::open(DatastoreRoute::sqlite("/tmp/app.db"))?; +db.write( + "update accounts set last_seen = $1 where id = $2", + &[Parameter::Integer(1_725_000_000), Parameter::Integer(7)], +)?; +``` -## Public surface +## Environment -| Function | Purpose | +| Variable | Meaning | |---|---| -| `query(sql, params)` | One entry point for any SQL (read or write). Returns `Rows`. | -| `execute(sql, params)` | DDL / migration / affected-row operations. Returns affected count. | -| `execute_batch(sql)` | Caller-owned multi-statement DDL or migration script. | -| `transaction(&[Statement])` | Atomic batch; full rollback on any per-statement failure. | -| `query!("sql")` macro | Compile-time SQL-literal analysis. | -| `Connection` | Connection-scoped backend-neutral operations and configuration. | -| `StorageOp` | Operation-based thin waist for adapter-independent callers. | - -`Parameter` enumerates the typed binding set: `Null`, `Bool`, `Integer`, `Real`, `Text`, and `Blob`. The adapter forwards the values verbatim — never as interpolated SQL. - -`Row` provides `get` / `try_get`, lenient scalar getters, `is_null`, and borrowed `get_str_ref` / `get_json_ref` under the projection contract above. +| `DATASTORE` | `sqlite` or `neon` | +| `DATASTORE_ROUTE` | SQLite file path or Neon `/query` service endpoint | +| `DATASTORE_TOKEN` | Optional opaque bearer token for Neon | -## Atomic batches (`transaction`) - -Stable contract for multi-statement units of work ([#24](https://github.com/DecapodLabs/dactyl/issues/24); prerequisite for DecapodLabs/decapod#1111 / #1120): - -| Concern | Semantics | -|---|---| -| Atomicity | Any per-statement failure aborts the whole unit. SQLite uses a real transaction; Neon uses one `POST /batch` that the server accepts or rejects as a unit. Empty slice → `Ok([])`. | -| Nesting | **Not supported.** No SAVEPOINTs. Each call uses a fresh adapter; put every statement in one slice. | -| Retry | **dactyl does not retry.** Callers own retry policy. | -| Timeout | **No public deadline.** Neon uses reqwest defaults; SQLite is local. | -| Idempotency | **Not idempotent.** Replays may conflict or double-write. Design deterministic keys / upserts if retrying after ambiguous transport failures. | -| Proof | Conformance covers SQLite + Neon-mock failure injection and an event-plus-state fixture (state row + event row in one batch; mid-batch failure leaves neither). | +The database schema and backend endpoint contract are application-owned. Dactyl +expects the tables and query behavior to already exist; it does not bootstrap +or administer them. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +MIT. diff --git a/dactyl-db-macros/Cargo.toml b/dactyl-db-macros/Cargo.toml deleted file mode 100644 index a86adef..0000000 --- a/dactyl-db-macros/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "dactyl-db-macros" -version = "0.3.0" -edition = "2021" -rust-version = "1.75" -license = "MIT" -repository = "https://github.com/DecapodLabs/dactyl" -description = "Proc macros for dactyl-db. Implements the `query!` compile-time analyzer." - -[lib] -proc-macro = true - -[dependencies] -syn = { version = "2", features = ["full"] } -quote = "1" -proc-macro2 = "1" diff --git a/dactyl-db-macros/src/lib.rs b/dactyl-db-macros/src/lib.rs deleted file mode 100644 index ba6cb37..0000000 --- a/dactyl-db-macros/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! `dactyl::query!` proc macro. -//! -//! Lexically analyzes the SQL literal at compile time so: -// -//! 1. Hard-coded constructs are visible to the analyzer before the crate -//! is built. -//! 2. The literal is rewritten (currently identity) at compile time so the -//! runtime path stays allocation-light. -//! 3. Empty literals fail to compile with a clear message. -//! -//! The expanded form returns a `String` containing the (rewritten) SQL. -//! Callers wire it into `dactyl::query` directly: -//! -//! ```ignore -//! let rows = dactyl_db::query(&dactyl_db::query!("select id, title from todos"), &[])?; -//! ``` - -use proc_macro::TokenStream; -use quote::quote; -use syn::{parse_macro_input, LitStr}; - -/// `dactyl::query!("literal")` — returns the rewritten SQL as a `String`. -#[proc_macro] -pub fn query(input: TokenStream) -> TokenStream { - let literal = parse_macro_input!(input as LitStr); - let text = literal.value(); - - if text.trim().is_empty() { - return syn::Error::new_spanned(literal, "`query!` requires a non-empty SQL literal") - .to_compile_error() - .into(); - } - - let rewritten = rewrite(&text); - - let expanded = quote! {{ - // Compile-time lexer: hard-fails on unparseable input. The runtime - // call is a no-op identity rewrite, kept so the contract is uniform - // with non-literal queries. - let __text: &str = #rewritten; - ::dactyl_db::__private::analyze(__text).sql - }}; - - expanded.into() -} - -/// Identity rewrite — placeholder for the real rewriter. Kept as a -/// function so swapping in the structured rewriter is a one-line change. -fn rewrite(input: &str) -> String { - input.to_string() -} diff --git a/examples/readme_example.rs b/examples/readme_example.rs index 9496a5f..5f9c35f 100644 --- a/examples/readme_example.rs +++ b/examples/readme_example.rs @@ -1,38 +1,17 @@ -//! Tiny runnable example referenced from README. +//! Tiny application read/write example. //! -//! Run with: -//! -//! ```text -//! DATASTORE=sqlite DATASTORE_ROUTE=/tmp/dactyl-example.db \ -//! cargo run --features sqlite --example readme_example -//! ``` -//! -//! The example creates a caller-owned table via `dactyl::execute`, inserts a -//! row with bound parameters, then reads it back with `dactyl::query`. -//! dactyl never silently bootstraps schema — the caller owns it. +//! The `app_events` table is assumed to be owned and created by the backend. +//! Dactyl only writes application data and reads it back. fn main() -> Result<(), dactyl_db::DactylError> { - dactyl_db::execute( - "create table if not exists todos (id integer primary key, title text not null, status text not null)", - &[], - )?; - dactyl_db::execute( - "insert into todos (id, title, status) values ($1, $2, $3)", - &[ - dactyl_db::Parameter::Integer(1), - dactyl_db::Parameter::Text("ship dactyl 0.1.7".into()), - dactyl_db::Parameter::Text("open".into()), - ], + dactyl_db::write( + "insert into app_events (name) values ($1)", + &[dactyl_db::Parameter::Text("opened".into())], )?; - let sql = dactyl_db::query!("select id, title, status from todos"); - for row in dactyl_db::query(&sql, &[])?.iter() { - let id: i64 = row.try_get("id")?; - let title: String = row.get("title")?; - let status: String = row.get_str("status")?; - // Borrowed accessor is valid for the row lifetime. - let title_ref: &str = row.get_str_ref("title")?; - println!("todo {id}: {title} ({title_ref}) [{status}]"); + let rows = dactyl_db::read("select name from app_events order by id", &[])?; + for row in rows.iter() { + println!("{}", row.get_str("name")?); } Ok(()) } diff --git a/src/__private.rs b/src/__private.rs deleted file mode 100644 index 0745bc7..0000000 --- a/src/__private.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Public-but-internal surface used by `dactyl_macros::query!`. - -use crate::query::{Construct, QueryAnalyzer, Rewrite}; - -/// Result of running the analyzer at runtime (or at compile time, depending -/// on the call site). -pub struct RuntimeHit { - /// SQL the analyzer says to execute (after any rewrite). - pub sql: String, - /// Constructs the analyzer found. - pub constructs: Vec, -} - -/// Run the analyzer on `query` and return the rewritten SQL plus the -/// construct list. Cheap; the macro emits this at compile time so the -/// runtime path remains allocation-light. -pub fn analyze(query: &str) -> RuntimeHit { - let analyzed = QueryAnalyzer::new().analyze(query); - let sql = match &analyzed.rewrite { - Rewrite::Identity => query.to_string(), - Rewrite::Replaced(s) => s.clone(), - }; - RuntimeHit { - sql, - constructs: analyzed.constructs, - } -} diff --git a/src/adapter/mod.rs b/src/adapter/mod.rs index f1d50d3..284a927 100644 --- a/src/adapter/mod.rs +++ b/src/adapter/mod.rs @@ -1,40 +1,12 @@ -//! Adapter trait — internal to dactyl. -//! -//! Both `SqliteAdapter` and `NeonAdapter` live behind their respective -//! feature gates and are intentionally private to the crate. The public -//! integration boundary is `crate::Connection` and `crate::StorageOp`. +//! Private backend adapters. use crate::error::DactylError; use crate::rows::{Parameter, Rows}; -use crate::Statement; -/// Internal trait every adapter implements. -/// -/// Adapters are constructed per call from [`crate::build_adapter`] and dropped -/// at the end of the call — there is no shared cache, so implementations do -/// not need to be `Sync` across calls. The trait is kept `Send + Sync` so a -/// caller could, if it chose to, hold an adapter across awaited points. -pub trait Adapter: Send + Sync { - /// Execute any SQL statement (read or write) and return its rows. - /// - /// Parameters are bound by the adapter — never interpolated into `query`. - fn execute(&self, query: &str, params: &[Parameter]) -> Result; - - /// Execute a raw schema/DDL/migration operation and return affected rows. - fn execute_raw(&self, query: &str, params: &[Parameter]) -> Result; - - /// Execute an atomic batch of statements. - fn execute_batch(&self, statements: &[Statement]) -> Result, DactylError>; - - /// Execute a caller-owned SQL script containing zero or more statements. - /// - /// This is separate from `execute_raw`: migration scripts commonly contain - /// multiple DDL statements, while `execute_raw` preserves affected-row - /// semantics for one statement. - fn execute_script(&self, query: &str) -> Result<(), DactylError>; - - /// Return the last generated local insert id when the adapter exposes one. - fn last_insert_id(&self) -> Result; +/// The small operation seam Dactyl needs from each backend. +pub trait Adapter { + fn read(&self, sql: &str, params: &[Parameter]) -> Result; + fn write(&self, sql: &str, params: &[Parameter]) -> Result; } #[cfg(feature = "sqlite")] diff --git a/src/adapter/neon/mod.rs b/src/adapter/neon/mod.rs index 02c54fb..aa824be 100644 --- a/src/adapter/neon/mod.rs +++ b/src/adapter/neon/mod.rs @@ -1,39 +1,13 @@ -//! Neon HTTP adapter. -//! -//! Thin SQL-over-HTTP client targeting Propodus. The adapter is constructed -//! per public [`crate::Connection`]. The request shape is the contract for the -//! conformance mock server: -//! -//! ```text -//! POST {endpoint}/query { "sql": "...", "params": [...] } -//! POST {endpoint}/batch { "statements": [...] } -//! ``` -//! -//! ```json -//! { -//! "columns": ["id", "title", "status"], -//! "rows": [ -//! {"id": 1, "title": "...", "status": "..."}, -//! ... -//! ] -//! } -//! ``` -//! -//! `/batch` is the Neon half of [`crate::transaction`]: the server must apply -//! the statement list as one atomic unit (all commit or all reject). Non-2xx -//! responses are surfaced as [`DactylError::Adapter`] with the response body; -//! dactyl does not partially apply a failed batch client-side. -//! -//! Propodus owns auth; dactyl only forwards the opaque `bearer` token. +//! Minimal SQL-over-HTTP transport for Neon. use serde::{Deserialize, Serialize}; use crate::adapter::Adapter; -use crate::error::DactylError; +use crate::error::{AdapterErrorKind, DactylError}; use crate::rows::{Parameter, Row, Rows}; -use crate::Statement; -/// Opaque handle to the Neon adapter. +/// A short-lived Neon adapter. The endpoint owns SQL execution and business +/// logic; Dactyl only sends the request and normalizes the response. pub struct NeonAdapter { endpoint: String, bearer: Option, @@ -41,14 +15,14 @@ pub struct NeonAdapter { } #[derive(Debug, Serialize)] -struct QueryRequest<'a> { +struct Request<'a> { sql: &'a str, #[serde(skip_serializing_if = "Option::is_none")] params: Option<&'a [Parameter]>, } -#[derive(Debug, Deserialize, Serialize)] -struct QueryResponse { +#[derive(Debug, Deserialize)] +struct Response { #[serde(default)] columns: Vec, #[serde(default)] @@ -57,161 +31,80 @@ struct QueryResponse { affected_rows: Option, } -#[derive(Debug, Serialize)] -struct BatchRequest<'a> { - statements: &'a [Statement], -} - -#[derive(Debug, Deserialize, Serialize)] -struct BatchResponse { - #[serde(default)] - results: Vec, -} - impl NeonAdapter { - /// Construct a Neon adapter pointed at the given Propodus endpoint. - /// - /// `bearer` is an opaque token forwarded in the `Authorization` header. pub fn new(endpoint: &str, bearer: Option) -> Self { - let client = reqwest::blocking::Client::builder() - .build() - .expect("reqwest blocking client"); Self { - endpoint: endpoint.trim_end_matches('/').to_string(), + endpoint: endpoint.trim_end_matches('/').to_owned(), bearer, - client, + client: reqwest::blocking::Client::new(), } } -} -fn rows_from_response(body: QueryResponse) -> Result { - let mut out = Vec::with_capacity(body.rows.len()); - for r in body.rows { - let obj = r - .as_object() - .ok_or_else(|| DactylError::Adapter("neon row is not a JSON object".into()))?; - if body.columns.is_empty() { - let cols: Vec = obj.keys().cloned().collect(); - let vals: Vec = cols - .iter() - .map(|c| obj.get(c).cloned().unwrap_or(serde_json::Value::Null)) - .collect(); - out.push(Row { - columns: cols, - values: vals, - }); - } else { - let vals: Vec = body - .columns - .iter() - .map(|c| obj.get(c).cloned().unwrap_or(serde_json::Value::Null)) - .collect(); - out.push(Row { - columns: body.columns.clone(), - values: vals, - }); + fn request(&self, sql: &str, params: &[Parameter]) -> Result { + let mut request = self.client.post(format!("{}/query", self.endpoint)); + if let Some(token) = &self.bearer { + request = request.bearer_auth(token); } - } - Ok(Rows(out)) -} - -impl Adapter for NeonAdapter { - fn execute(&self, query: &str, params: &[Parameter]) -> Result { - let url = format!("{}/query", self.inner_endpoint()); - let req = QueryRequest { - sql: query, - params: Some(params), - }; - let mut rb = self.client.post(&url).json(&req); - if let Some(b) = &self.bearer { - rb = rb.bearer_auth(b); - } - let resp = rb + let response = request + .json(&Request { + sql, + params: Some(params), + }) .send() - .map_err(|e| DactylError::Adapter(format!("neon send: {e}")))?; - let status = resp.status(); - let body: QueryResponse = resp - .json() - .map_err(|e| DactylError::Adapter(format!("neon decode: {e}")))?; + .map_err(|error| { + DactylError::adapter(AdapterErrorKind::Transport, format!("neon send: {error}")) + })?; + let status = response.status(); + let body = response.bytes().map_err(|error| { + DactylError::adapter( + AdapterErrorKind::Transport, + format!("neon response: {error}"), + ) + })?; if !status.is_success() { - return Err(DactylError::Adapter(format!( - "neon status {status}: {}", - serde_json::to_string(&body).unwrap_or_default() - ))); + return Err(DactylError::adapter( + AdapterErrorKind::Query, + format!("neon status {status}: {}", String::from_utf8_lossy(&body)), + )); } - rows_from_response(body) - } - - fn execute_raw(&self, query: &str, params: &[Parameter]) -> Result { - let url = format!("{}/query", self.inner_endpoint()); - let req = QueryRequest { - sql: query, - params: Some(params), - }; - let mut rb = self.client.post(&url).json(&req); - if let Some(b) = &self.bearer { - rb = rb.bearer_auth(b); - } - let resp = rb - .send() - .map_err(|e| DactylError::Adapter(format!("neon send: {e}")))?; - let status = resp.status(); - let body: QueryResponse = resp - .json() - .map_err(|e| DactylError::Adapter(format!("neon decode: {e}")))?; - if !status.is_success() { - return Err(DactylError::Adapter(format!( - "neon status {status}: {}", - serde_json::to_string(&body).unwrap_or_default() - ))); - } - Ok(body.affected_rows.unwrap_or(body.rows.len() as u64)) - } - - fn execute_batch(&self, statements: &[Statement]) -> Result, DactylError> { - let url = format!("{}/batch", self.inner_endpoint()); - let req = BatchRequest { statements }; - let mut rb = self.client.post(&url).json(&req); - if let Some(b) = &self.bearer { - rb = rb.bearer_auth(b); - } - let resp = rb - .send() - .map_err(|e| DactylError::Adapter(format!("neon batch send: {e}")))?; - let status = resp.status(); - // Read bytes first so non-2xx error bodies (often not BatchResponse) - // still surface as Adapter errors with the server payload. - let bytes = resp - .bytes() - .map_err(|e| DactylError::Adapter(format!("neon batch body: {e}")))?; - if !status.is_success() { - return Err(DactylError::Adapter(format!( - "neon batch status {status}: {}", - String::from_utf8_lossy(&bytes) - ))); - } - let body: BatchResponse = serde_json::from_slice(&bytes) - .map_err(|e| DactylError::Adapter(format!("neon batch decode: {e}")))?; - let mut results = Vec::with_capacity(body.results.len()); - for res in body.results { - results.push(rows_from_response(res)?); - } - Ok(results) + serde_json::from_slice(&body).map_err(|error| { + DactylError::adapter(AdapterErrorKind::Protocol, format!("neon decode: {error}")) + }) } +} - fn execute_script(&self, query: &str) -> Result<(), DactylError> { - self.execute_raw(query, &[]).map(|_| ()) +impl Adapter for NeonAdapter { + fn read(&self, sql: &str, params: &[Parameter]) -> Result { + rows_from_response(self.request(sql, params)?) } - fn last_insert_id(&self) -> Result { - Err(DactylError::UnsupportedOperation( - "last_insert_id is not part of the Neon HTTP response contract".into(), - )) + fn write(&self, sql: &str, params: &[Parameter]) -> Result { + let response = self.request(sql, params)?; + Ok(response.affected_rows.unwrap_or(response.rows.len() as u64)) } } -impl NeonAdapter { - fn inner_endpoint(&self) -> &str { - &self.endpoint +fn rows_from_response(response: Response) -> Result { + let mut rows = Vec::with_capacity(response.rows.len()); + for value in response.rows { + let object = value.as_object().ok_or_else(|| { + DactylError::adapter(AdapterErrorKind::Protocol, "neon row is not an object") + })?; + let columns = if response.columns.is_empty() { + object.keys().cloned().collect::>() + } else { + response.columns.clone() + }; + let values = columns + .iter() + .map(|column| { + object + .get(column) + .cloned() + .unwrap_or(serde_json::Value::Null) + }) + .collect(); + rows.push(Row { columns, values }); } + Ok(Rows(rows)) } diff --git a/src/adapter/sqlite/mod.rs b/src/adapter/sqlite/mod.rs index 4672d31..b87f600 100644 --- a/src/adapter/sqlite/mod.rs +++ b/src/adapter/sqlite/mod.rs @@ -1,310 +1,279 @@ -//! SQLite adapter. +//! Small SQLite C-API driver. //! -//! Holds a `rusqlite::Connection` opened against the configured path. The -//! adapter is constructed per public [`crate::Connection`] and lives for that -//! connection's duration. There is no process-wide connection cache. -//! -//! Schemas are managed by the caller — [`SqliteAdapter::open`] opens (or -//! creates) the file but never bootstraps any tables. Callers own and version -//! their schema through explicit [`crate::execute`] / DDL statements -//! (dactyl #27). +//! This module intentionally talks to SQLite through `libsqlite3-sys` rather +//! than exposing or depending on the high-level `rusqlite` API. The safe +//! wrapper below contains only the primitives Dactyl needs: open, bind, +//! step, read cells, execute, and finalize. -use std::sync::Mutex; -use std::time::Duration; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_void}; +use std::ptr; +use std::slice; -use rusqlite::{params_from_iter, types::Value as SqlValue, Connection, OpenFlags}; +use libsqlite3_sys as ffi; use crate::adapter::Adapter; -use crate::error::DactylError; +use crate::error::{AdapterErrorKind, DactylError}; use crate::rows::{Parameter, Row, Rows}; -use crate::SqliteJournalMode; -use crate::Statement; -/// Opaque handle to the SQLite adapter. -/// -/// `rusqlite::Connection` is `!Sync`, so execution is serialized through an -/// internal `Mutex`. The mutex is harmless in practice because each call -/// constructs and drops its own adapter, but it keeps the type `Send + Sync` -/// for callers that choose to hold an adapter longer. +/// A private SQLite connection. No SQLite handle or C type crosses the public +/// Dactyl API boundary. pub struct SqliteAdapter { - conn: Mutex, + db: *mut ffi::sqlite3, } impl SqliteAdapter { - /// Open SQLite with the connection policy supplied by the public dactyl - /// connection boundary. - pub fn open_with_options( - path: &str, - read_only: bool, - busy_timeout: Duration, - foreign_keys: bool, - journal_mode: Option, - ) -> rusqlite::Result { - if !read_only && path != ":memory:" { + pub fn open(path: &str) -> Result { + if path != ":memory:" { if let Some(parent) = std::path::Path::new(path).parent() { if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + std::fs::create_dir_all(parent).map_err(|error| { + DactylError::adapter(AdapterErrorKind::Storage, error.to_string()) + })?; } } } - let conn = if read_only { - Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - )? - } else { - Connection::open(path)? - }; - conn.busy_timeout(busy_timeout)?; - if foreign_keys { - conn.execute_batch("PRAGMA foreign_keys=ON;")?; - } - if !read_only { - if let Some(mode) = journal_mode { - let requested = format!("PRAGMA journal_mode={};", mode.as_sql()); - if conn.query_row(&requested, [], |_| Ok(())).is_err() - && mode == SqliteJournalMode::Wal - { - conn.query_row("PRAGMA journal_mode=DELETE;", [], |_| Ok(()))?; - } + + let filename = CString::new(path) + .map_err(|_| DactylError::Config("SQLite path contains NUL".into()))?; + let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE; + let mut db = ptr::null_mut(); + let code = unsafe { ffi::sqlite3_open_v2(filename.as_ptr(), &mut db, flags, ptr::null()) }; + if code != ffi::SQLITE_OK { + let error = sqlite_error(db, code, "open"); + if !db.is_null() { + unsafe { ffi::sqlite3_close(db) }; } + return Err(error); } - Ok(Self { - conn: Mutex::new(conn), - }) - } -} -impl Adapter for SqliteAdapter { - fn execute(&self, query: &str, params: &[Parameter]) -> Result { - let conn = self - .conn - .lock() - .map_err(|e| DactylError::Adapter(format!("sqlite lock poisoned: {e}")))?; - let translated_sql = translate_placeholders_to_sqlite(query); - let mapped = map_params(params); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = mapped - .iter() - .map(|x| x as &dyn rusqlite::types::ToSql) - .collect(); - let mut stmt = conn - .prepare(&translated_sql) - .map_err(|e| DactylError::Adapter(format!("sqlite prepare: {e}")))?; + Ok(Self { db }) + } - let column_count = stmt.column_count(); - let column_names: Vec = (0..column_count) - .map(|i| stmt.column_name(i).unwrap_or("").to_string()) - .collect(); + fn prepare(&self, sql: &str) -> Result { + let sql = CString::new(sql) + .map_err(|_| DactylError::adapter(AdapterErrorKind::Query, "SQL contains NUL"))?; + let mut statement = ptr::null_mut(); + let code = unsafe { + ffi::sqlite3_prepare_v2(self.db, sql.as_ptr(), -1, &mut statement, ptr::null_mut()) + }; + if code != ffi::SQLITE_OK { + return Err(sqlite_error(self.db, code, "prepare")); + } + Ok(StatementHandle { ptr: statement }) + } - let rows_iter = stmt - .query_map(params_from_iter(param_refs.iter().copied()), |row| { - let mut cells = Vec::with_capacity(column_count); - for i in 0..column_count { - let v = row.get::<_, SqlValue>(i)?; - cells.push(sql_to_json(v)); + fn bind( + &self, + statement: *mut ffi::sqlite3_stmt, + params: &[Parameter], + ) -> Result<(), DactylError> { + let expected = unsafe { ffi::sqlite3_bind_parameter_count(statement) } as usize; + if expected != params.len() { + return Err(DactylError::adapter( + AdapterErrorKind::Query, + format!("expected {expected} parameters, received {}", params.len()), + )); + } + for (index, parameter) in params.iter().enumerate() { + let index = (index + 1) as c_int; + let code = unsafe { + match parameter { + Parameter::Null => ffi::sqlite3_bind_null(statement, index), + Parameter::Bool(value) => { + ffi::sqlite3_bind_int(statement, index, i32::from(*value)) + } + Parameter::Integer(value) => ffi::sqlite3_bind_int64(statement, index, *value), + Parameter::Real(value) => ffi::sqlite3_bind_double(statement, index, *value), + Parameter::Text(value) => bind_bytes(statement, index, value.as_bytes(), true), + Parameter::Blob(value) => bind_bytes(statement, index, value, false), } - Ok(cells) - }) - .map_err(|e| DactylError::Adapter(format!("sqlite query: {e}")))?; + }; + if code != ffi::SQLITE_OK { + return Err(sqlite_error(self.db, code, "bind")); + } + } + Ok(()) + } + fn run(&self, sql: &str, params: &[Parameter]) -> Result<(Rows, u64), DactylError> { + let statement = self.prepare(sql)?; + self.bind(statement.ptr, params)?; + let column_count = unsafe { ffi::sqlite3_column_count(statement.ptr) } as usize; + let columns = (0..column_count) + .map(|index| unsafe { column_name(statement.ptr, index as c_int) }) + .collect::>(); let mut rows = Vec::new(); - for row in rows_iter { - let cells = row.map_err(|e| DactylError::Adapter(format!("sqlite row: {e}")))?; - rows.push(Row { - columns: column_names.clone(), - values: cells, - }); + loop { + let code = unsafe { ffi::sqlite3_step(statement.ptr) }; + match code { + ffi::SQLITE_ROW => { + let values = (0..column_count) + .map(|index| unsafe { column_value(statement.ptr, index as c_int) }) + .collect(); + rows.push(Row { + columns: columns.clone(), + values, + }); + } + ffi::SQLITE_DONE => break, + code => return Err(sqlite_error(self.db, code, "step")), + } } - Ok(Rows(rows)) + let affected = unsafe { ffi::sqlite3_changes(self.db) } as u64; + Ok((Rows(rows), affected)) } +} - fn execute_raw(&self, query: &str, params: &[Parameter]) -> Result { - let conn = self - .conn - .lock() - .map_err(|e| DactylError::Adapter(format!("sqlite lock poisoned: {e}")))?; - let translated_sql = translate_placeholders_to_sqlite(query); - let mapped = map_params(params); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = mapped - .iter() - .map(|x| x as &dyn rusqlite::types::ToSql) - .collect(); - let affected = conn - .execute( - &translated_sql, - params_from_iter(param_refs.iter().copied()), - ) - .map_err(|e| DactylError::Adapter(format!("sqlite execute_raw: {e}")))?; - Ok(affected as u64) +impl Drop for SqliteAdapter { + fn drop(&mut self) { + if !self.db.is_null() { + unsafe { ffi::sqlite3_close(self.db) }; + } } +} - fn execute_batch(&self, statements: &[Statement]) -> Result, DactylError> { - let mut conn = self - .conn - .lock() - .map_err(|e| DactylError::Adapter(format!("sqlite lock poisoned: {e}")))?; - let tx = conn - .transaction() - .map_err(|e| DactylError::Adapter(format!("sqlite transaction begin: {e}")))?; - - let mut results = Vec::with_capacity(statements.len()); - for stmt_info in statements { - let translated_sql = translate_placeholders_to_sqlite(&stmt_info.sql); - let mapped = map_params(&stmt_info.params); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = mapped - .iter() - .map(|x| x as &dyn rusqlite::types::ToSql) - .collect(); - let mut stmt = tx - .prepare(&translated_sql) - .map_err(|e| DactylError::Adapter(format!("sqlite prepare: {e}")))?; +impl Adapter for SqliteAdapter { + fn read(&self, sql: &str, params: &[Parameter]) -> Result { + self.run(sql, params).map(|(rows, _)| rows) + } - let column_count = stmt.column_count(); - let column_names: Vec = (0..column_count) - .map(|i| stmt.column_name(i).unwrap_or("").to_string()) - .collect(); + fn write(&self, sql: &str, params: &[Parameter]) -> Result { + self.run(sql, params).map(|(_, affected)| affected) + } +} - let rows_iter = stmt - .query_map(params_from_iter(param_refs.iter().copied()), |row| { - let mut cells = Vec::with_capacity(column_count); - for i in 0..column_count { - let v = row.get::<_, SqlValue>(i)?; - cells.push(sql_to_json(v)); - } - Ok(cells) - }) - .map_err(|e| DactylError::Adapter(format!("sqlite query: {e}")))?; +struct StatementHandle { + ptr: *mut ffi::sqlite3_stmt, +} - let mut rows = Vec::new(); - for row in rows_iter { - let cells = row.map_err(|e| DactylError::Adapter(format!("sqlite row: {e}")))?; - rows.push(Row { - columns: column_names.clone(), - values: cells, - }); - } - results.push(Rows(rows)); +impl Drop for StatementHandle { + fn drop(&mut self) { + if !self.ptr.is_null() { + unsafe { ffi::sqlite3_finalize(self.ptr) }; } - tx.commit() - .map_err(|e| DactylError::Adapter(format!("sqlite transaction commit: {e}")))?; - Ok(results) - } - - fn execute_script(&self, query: &str) -> Result<(), DactylError> { - let conn = self - .conn - .lock() - .map_err(|e| DactylError::Adapter(format!("sqlite lock poisoned: {e}")))?; - conn.execute_batch(query) - .map_err(|e| DactylError::Adapter(format!("sqlite execute script: {e}"))) } +} - fn last_insert_id(&self) -> Result { - let conn = self - .conn - .lock() - .map_err(|e| DactylError::Adapter(format!("sqlite lock poisoned: {e}")))?; - Ok(conn.last_insert_rowid()) +unsafe fn bind_bytes( + statement: *mut ffi::sqlite3_stmt, + index: c_int, + bytes: &[u8], + text: bool, +) -> c_int { + let length = match c_int::try_from(bytes.len()) { + Ok(length) => length, + Err(_) => return ffi::SQLITE_TOOBIG, + }; + let pointer = if bytes.is_empty() { + ptr::null() + } else { + bytes.as_ptr() + }; + if text { + ffi::sqlite3_bind_text( + statement, + index, + pointer.cast::(), + length, + ffi::SQLITE_TRANSIENT(), + ) + } else { + ffi::sqlite3_bind_blob( + statement, + index, + pointer.cast::(), + length, + ffi::SQLITE_TRANSIENT(), + ) } } -fn map_params(params: &[Parameter]) -> Vec { - params - .iter() - .map(|p| match p { - Parameter::Null => SqlValue::Null, - Parameter::Bool(b) => SqlValue::Integer(if *b { 1 } else { 0 }), - Parameter::Integer(i) => SqlValue::Integer(*i), - Parameter::Real(f) => SqlValue::Real(*f), - Parameter::Text(s) => SqlValue::Text(s.clone()), - Parameter::Blob(b) => SqlValue::Blob(b.clone()), - }) - .collect() +unsafe fn column_name(statement: *mut ffi::sqlite3_stmt, index: c_int) -> String { + let pointer = ffi::sqlite3_column_name(statement, index); + if pointer.is_null() { + String::new() + } else { + CStr::from_ptr(pointer).to_string_lossy().into_owned() + } } -fn translate_placeholders_to_sqlite(query: &str) -> String { - let mut out = String::new(); - let bytes = query.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i] as char; - - // A PostgreSQL-style placeholder is data when it appears inside a - // string or comment. Keep those regions byte-for-byte intact. - if c == '\'' || c == '"' { - let quote = bytes[i]; - out.push(c); - i += 1; - while i < bytes.len() { - out.push(bytes[i] as char); - if bytes[i] == quote { - if i + 1 < bytes.len() && bytes[i + 1] == quote { - out.push(bytes[i + 1] as char); - i += 2; - continue; - } - i += 1; - break; - } - i += 1; - } - continue; +unsafe fn column_value(statement: *mut ffi::sqlite3_stmt, index: c_int) -> serde_json::Value { + match ffi::sqlite3_column_type(statement, index) { + ffi::SQLITE_NULL => serde_json::Value::Null, + ffi::SQLITE_INTEGER => { + serde_json::Value::Number((ffi::sqlite3_column_int64(statement, index) as i64).into()) } - if c == '-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' { - while i < bytes.len() { - let ch = bytes[i] as char; - out.push(ch); - i += 1; - if ch == '\n' { - break; - } - } - continue; + ffi::SQLITE_FLOAT => { + serde_json::Number::from_f64(ffi::sqlite3_column_double(statement, index)) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null) } - if c == '/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - out.push('/'); - out.push('*'); - i += 2; - while i < bytes.len() { - out.push(bytes[i] as char); - if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - i += 2; - break; - } - i += 1; + ffi::SQLITE_TEXT => { + let pointer = ffi::sqlite3_column_text(statement, index); + let length = ffi::sqlite3_column_bytes(statement, index).max(0) as usize; + if pointer.is_null() { + serde_json::Value::String(String::new()) + } else { + let bytes = slice::from_raw_parts(pointer, length); + serde_json::Value::String(String::from_utf8_lossy(bytes).into_owned()) } - continue; } - - if c == '$' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() { - out.push('?'); - i += 1; - while i < bytes.len() && bytes[i].is_ascii_digit() { - i += 1; - } - continue; + ffi::SQLITE_BLOB => { + let pointer = ffi::sqlite3_column_blob(statement, index); + let length = ffi::sqlite3_column_bytes(statement, index).max(0) as usize; + let bytes = if pointer.is_null() || length == 0 { + &[] + } else { + slice::from_raw_parts(pointer.cast::(), length) + }; + serde_json::Value::Array( + bytes + .iter() + .map(|byte| serde_json::Value::Number((*byte as u64).into())) + .collect(), + ) } - - out.push(c); - i += 1; + _ => serde_json::Value::Null, } - out } -fn sql_to_json(v: SqlValue) -> serde_json::Value { - match v { - SqlValue::Null => serde_json::Value::Null, - SqlValue::Integer(i) => serde_json::Value::Number(i.into()), - SqlValue::Real(f) => serde_json::Number::from_f64(f) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - SqlValue::Text(s) => serde_json::Value::String(s), - SqlValue::Blob(b) => serde_json::Value::Array( - b.into_iter() - .map(|byte| serde_json::Value::Number((byte as u64).into())) - .collect(), - ), - } +fn sqlite_error(db: *mut ffi::sqlite3, code: c_int, operation: &str) -> DactylError { + sqlite_error_with_detail(db, code, operation, None) +} + +fn sqlite_error_with_detail( + db: *mut ffi::sqlite3, + code: c_int, + operation: &str, + detail: Option, +) -> DactylError { + let primary = code & 0xff; + let kind = match primary { + ffi::SQLITE_BUSY => AdapterErrorKind::Busy, + ffi::SQLITE_LOCKED => AdapterErrorKind::Locked, + ffi::SQLITE_CONSTRAINT => AdapterErrorKind::Constraint, + ffi::SQLITE_READONLY => AdapterErrorKind::ReadOnly, + ffi::SQLITE_IOERR | ffi::SQLITE_CANTOPEN | ffi::SQLITE_CORRUPT | ffi::SQLITE_NOTADB => { + AdapterErrorKind::Storage + } + ffi::SQLITE_ERROR | ffi::SQLITE_SCHEMA | ffi::SQLITE_AUTH => AdapterErrorKind::Query, + _ => AdapterErrorKind::Unknown, + }; + let message = detail.unwrap_or_else(|| { + if db.is_null() { + format!("{operation} failed with SQLite code {code}") + } else { + let pointer = unsafe { ffi::sqlite3_errmsg(db) }; + if pointer.is_null() { + format!("{operation} failed with SQLite code {code}") + } else { + format!( + "{operation}: {}", + unsafe { CStr::from_ptr(pointer) }.to_string_lossy() + ) + } + } + }); + DactylError::adapter(kind, message) } diff --git a/src/error.rs b/src/error.rs index fe74ff6..5139740 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,44 +1,49 @@ -//! Public error type for dactyl. +//! Errors returned by the Dactyl driver. use thiserror::Error; -use crate::query::Construct; +/// Coarse operational categories stable enough for callers to branch on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdapterErrorKind { + Busy, + Locked, + Constraint, + Query, + ReadOnly, + Storage, + Transport, + Protocol, + Unknown, +} -/// All errors dactyl can raise on its public surface. +/// The backend-neutral public error surface. #[derive(Debug, Error)] pub enum DactylError { - /// The query contains a dialect-specific construct the active adapter - /// does not support and `optimize = false` was passed. - #[error("dialect mismatch: construct `{construct:?}` not supported")] - Unsupported { - /// The unsupported construct. - construct: Construct, - }, + #[error("configuration error: {0}")] + Config(String), - /// The query selected a datastore that does not match the connection - /// route, or an inline override could not be resolved to a configured - /// route. - #[error("datastore routing error: {0}")] - Routing(String), + #[error("adapter error ({kind:?}): {message}")] + Adapter { + kind: AdapterErrorKind, + message: String, + }, - /// The requested backend-neutral operation is not available for the - /// selected adapter. #[error("unsupported datastore operation: {0}")] UnsupportedOperation(String), - /// The query string failed to parse under the analyzer's lexical rules. - #[error("invalid query: {0}")] - InvalidQuery(String), - - /// Adapter-level failure. Wraps the underlying error message. - #[error("adapter error: {0}")] - Adapter(String), - - /// Requested column or index was not found in the row. #[error("column not found: {0}")] ColumnNotFound(String), - /// Type conversion of column value failed. #[error("conversion error: {0}")] Conversion(String), } + +impl DactylError { + #[allow(dead_code)] + pub(crate) fn adapter(kind: AdapterErrorKind, message: impl Into) -> Self { + Self::Adapter { + kind, + message: message.into(), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index f5a23a1..53c619a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,68 +1,28 @@ -//! Dactyl — the backend-neutral datastore boundary for Decapod. +//! Dactyl — the application-layer read/write driver for SQLite and Neon. //! -//! The public API contains no `rusqlite` or HTTP adapter types. Callers can -//! use one-shot functions for small operations or retain a [`Connection`] for -//! migrations, validation probes, and several operations against one route. -//! Both paths use the same parameter, row, transaction, and SQL-analysis -//! contracts. +//! Dactyl owns only backend selection, parameter binding, and response +//! normalization. It forwards raw application SQL to the selected database; +//! schema administration, migrations, transactions, analytics, retries, and +//! business intelligence stay outside this crate. mod adapter; mod rows; pub mod error; -pub mod query; -#[doc(hidden)] -pub mod __private; - -pub use dactyl_db_macros::query; - -pub use crate::error::DactylError; -pub use crate::query::{Construct, Dialect, QueryAnalyzer}; +pub use crate::error::{AdapterErrorKind, DactylError}; pub use crate::rows::{Parameter, Row, Rows}; use crate::adapter::Adapter; -use std::time::Duration; -/// A backend selected by a [`DatastoreRoute`]. +/// A supported application datastore. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Datastore { Sqlite, Neon, } -/// SQLite journal policy. `Wal` is the default for concurrent local readers; -/// the adapter falls back to `Delete` if the filesystem cannot support WAL. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SqliteJournalMode { - Wal, - Delete, - Memory, - Off, -} - -impl SqliteJournalMode { - #[cfg(feature = "sqlite")] - pub(crate) fn as_sql(self) -> &'static str { - match self { - Self::Wal => "WAL", - Self::Delete => "DELETE", - Self::Memory => "MEMORY", - Self::Off => "OFF", - } - } -} - -impl Datastore { - fn dialect(self) -> Dialect { - match self { - Datastore::Sqlite => Dialect::Sqlite, - Datastore::Neon => Dialect::Postgres, - } - } -} - -/// Explicit route information for a backend connection. +/// The route needed to send an application read or write. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DatastoreRoute { datastore: Datastore, @@ -71,7 +31,6 @@ pub struct DatastoreRoute { } impl DatastoreRoute { - /// Route a connection to a local SQLite file. pub fn sqlite(path: impl Into) -> Self { Self { datastore: Datastore::Sqlite, @@ -80,7 +39,6 @@ impl DatastoreRoute { } } - /// Route a connection to a Neon/Propodus SQL-over-HTTP endpoint. pub fn neon(endpoint: impl Into, token: Option) -> Self { Self { datastore: Datastore::Neon, @@ -101,351 +59,108 @@ impl DatastoreRoute { self.token.as_deref() } - /// Resolve the active route from `DATASTORE`, `DATASTORE_ROUTE`, and the - /// optional `DATASTORE_TOKEN` environment variables. + /// Resolve `DATASTORE`, `DATASTORE_ROUTE`, and `DATASTORE_TOKEN`. pub fn from_env() -> Result { - let datastore = std::env::var("DATASTORE").map_err(|_| { - DactylError::Adapter("DATASTORE is not set: set DATASTORE and DATASTORE_ROUTE".into()) - })?; - let kind = match datastore.as_str() { - "sqlite" => Datastore::Sqlite, - "neon" => Datastore::Neon, - other => Err(DactylError::Adapter(format!( - "invalid DATASTORE value {other:?}: must be 'sqlite' or 'neon'" - )))?, - }; - let route = std::env::var("DATASTORE_ROUTE").map_err(|_| { - DactylError::Adapter( - "DATASTORE_ROUTE is not set: set DATASTORE and DATASTORE_ROUTE".into(), - ) - })?; - Ok(match kind { - Datastore::Sqlite => Self::sqlite(route), - Datastore::Neon => Self::neon(route, std::env::var("DATASTORE_TOKEN").ok()), - }) - } -} - -/// Per-connection behavior that is safe to expose across all adapters. -#[derive(Debug, Clone)] -pub struct ConnectionOptions { - /// Permit only dactyl's explicitly safe dialect rewrites. - pub allow_rewrites: bool, - /// Open SQLite in read-only mode. Ignored by Neon. - pub read_only: bool, - /// Busy timeout for SQLite lock contention. Ignored by Neon. - pub busy_timeout: Duration, - /// Enable SQLite foreign-key enforcement. Ignored by Neon. - pub foreign_keys: bool, - /// SQLite journal mode. Read-only connections leave the existing mode - /// unchanged. Ignored by Neon. - pub journal_mode: Option, -} - -impl Default for ConnectionOptions { - fn default() -> Self { - Self { - allow_rewrites: false, - read_only: false, - busy_timeout: Duration::from_secs(5), - foreign_keys: true, - journal_mode: Some(SqliteJournalMode::Wal), + let datastore = std::env::var("DATASTORE") + .map_err(|_| DactylError::Config("DATASTORE is not set: use sqlite or neon".into()))?; + let route = std::env::var("DATASTORE_ROUTE") + .map_err(|_| DactylError::Config("DATASTORE_ROUTE is not set".into()))?; + match datastore.as_str() { + "sqlite" => Ok(Self::sqlite(route)), + "neon" => Ok(Self::neon(route, std::env::var("DATASTORE_TOKEN").ok())), + other => Err(DactylError::Config(format!( + "invalid DATASTORE value {other:?}: use sqlite or neon" + ))), } } } -impl ConnectionOptions { - pub fn with_rewrites(mut self, allow: bool) -> Self { - self.allow_rewrites = allow; - self - } - - pub fn read_only(mut self, read_only: bool) -> Self { - self.read_only = read_only; - self - } - - fn from_env() -> Self { - let allow_rewrites = std::env::var("DATASTORE_REWRITE") - .ok() - .map(|value| { - matches!( - value.to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(false); - Self { - allow_rewrites, - ..Self::default() - } - } -} - -/// A connection-scoped backend-neutral datastore handle. +/// A route-scoped application driver. Backend handles remain private. pub struct Connection { adapter: Box, route: DatastoreRoute, - options: ConnectionOptions, } impl Connection { - /// Open a route with the default policy. pub fn open(route: DatastoreRoute) -> Result { - Self::open_with_options(route, ConnectionOptions::default()) + let adapter = build_adapter(&route)?; + Ok(Self { adapter, route }) } - /// Open a route with explicit connection policy. - #[cfg(any(feature = "sqlite", feature = "neon"))] - pub fn open_with_options( - route: DatastoreRoute, - options: ConnectionOptions, - ) -> Result { - let adapter: Box = match route.datastore { - Datastore::Sqlite => { - #[cfg(feature = "sqlite")] - { - Box::new( - crate::adapter::sqlite::SqliteAdapter::open_with_options( - &route.route, - options.read_only, - options.busy_timeout, - options.foreign_keys, - options.journal_mode, - ) - .map_err(|e| DactylError::Adapter(format!("sqlite open: {e}")))?, - ) - } - #[cfg(not(feature = "sqlite"))] - { - return Err(DactylError::Adapter( - "sqlite adapter requested but `sqlite` feature is disabled".into(), - )); - } - } - Datastore::Neon => { - #[cfg(feature = "neon")] - { - Box::new(crate::adapter::neon::NeonAdapter::new( - &route.route, - route.token.clone(), - )) - } - #[cfg(not(feature = "neon"))] - { - return Err(DactylError::Adapter( - "neon adapter requested but `neon` feature is disabled".into(), - )); - } - } - }; - Ok(Self { - adapter, - route, - options, - }) - } - - /// Keep the crate's no-feature build useful for analysis-only consumers. - #[cfg(not(any(feature = "sqlite", feature = "neon")))] - pub fn open_with_options( - _route: DatastoreRoute, - _options: ConnectionOptions, - ) -> Result { - Err(DactylError::Adapter( - "no datastore adapter feature is enabled; enable `sqlite` or `neon`".into(), - )) - } - - /// Open the active environment-selected route. pub fn from_env() -> Result { - Self::open_with_options(DatastoreRoute::from_env()?, ConnectionOptions::from_env()) + Self::open(DatastoreRoute::from_env()?) } pub fn datastore(&self) -> Datastore { self.route.datastore } - pub fn dialect(&self) -> Dialect { - self.route.datastore.dialect() - } - pub fn route(&self) -> &DatastoreRoute { &self.route } - pub fn options(&self) -> &ConnectionOptions { - &self.options + /// Read application rows from the selected backend. + pub fn read(&self, sql: &str, params: &[Parameter]) -> Result { + self.adapter.read(sql, params) } - /// Execute a query or write and return its projected rows. - pub fn query(&self, sql: &str, params: &[Parameter]) -> Result { - let prepared = self.prepare(sql)?; - self.adapter.execute(&prepared, params) + /// Write application data and return the backend-reported affected count. + pub fn write(&self, sql: &str, params: &[Parameter]) -> Result { + self.adapter.write(sql, params) } - - /// Execute one DDL/migration/affected-row statement. - pub fn execute(&self, sql: &str, params: &[Parameter]) -> Result { - let prepared = self.prepare(sql)?; - self.adapter.execute_raw(&prepared, params) - } - - /// Execute a caller-owned SQL script containing multiple statements. - pub fn execute_batch(&self, sql: &str) -> Result<(), DactylError> { - let prepared = self.prepare(sql)?; - self.adapter.execute_script(&prepared) - } - - /// Execute an all-or-nothing batch of parameterized statements. - pub fn transaction(&self, statements: &[Statement]) -> Result, DactylError> { - if statements.is_empty() { - return Ok(Vec::new()); - } - let prepared = statements - .iter() - .map(|statement| { - Ok(Statement { - sql: self.prepare(&statement.sql)?, - params: statement.params.clone(), - }) - }) - .collect::, DactylError>>()?; - self.adapter.execute_batch(&prepared) - } - - /// Return the adapter's last generated insert id when available. - pub fn last_insert_id(&self) -> Result { - self.adapter.last_insert_id() - } - - /// Execute the operation-based thin-waist contract used by callers that - /// must support both local and remote storage without closure types tied to - /// SQLite. - pub fn execute_op(&self, op: StorageOp) -> Result { - match op { - StorageOp::Query { sql, params } => self.query(&sql, ¶ms).map(StorageResult::Rows), - StorageOp::Execute { sql, params } => { - self.execute(&sql, ¶ms).map(StorageResult::Affected) - } - StorageOp::Script { sql } => self.execute_batch(&sql).map(|()| StorageResult::Unit), - StorageOp::Transaction { statements } => { - self.transaction(&statements).map(StorageResult::Batch) - } - StorageOp::LastInsertId => self.last_insert_id().map(StorageResult::LastInsertId), - } - } - - fn prepare(&self, sql: &str) -> Result { - QueryAnalyzer::new().prepare(sql, self.dialect(), self.options.allow_rewrites) - } -} - -/// Operation-based storage contract for integration boundaries. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -pub enum StorageOp { - Query { sql: String, params: Vec }, - Execute { sql: String, params: Vec }, - Script { sql: String }, - Transaction { statements: Vec }, - LastInsertId, -} - -/// Results for [`StorageOp`]. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -pub enum StorageResult { - Rows(Rows), - Affected(u64), - Unit, - Batch(Vec), - LastInsertId(i64), } -/// A parameterized SQL statement for batch execution. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -pub struct Statement { - pub sql: String, - pub params: Vec, -} +/// Alias that makes the application-driver role explicit. +pub type Driver = Connection; -impl Statement { - pub fn new(sql: &str, params: Vec) -> Self { - Self { - sql: sql.to_string(), - params, - } - } +pub fn read(sql: &str, params: &[Parameter]) -> Result { + Connection::from_env()?.read(sql, params) } -/// Test-only compatibility helper. Connections are explicit and there is no -/// process-wide cache to clear. -#[doc(hidden)] -pub fn reset() {} - -fn connection_for_query(sql: &str) -> Result { - let route = route_for_query(sql)?; - Connection::open_with_options(route, ConnectionOptions::from_env()) -} - -fn route_for_query(sql: &str) -> Result { - let active = DatastoreRoute::from_env()?; - let analyzed = QueryAnalyzer::new().analyze(sql); - let Some(inline) = analyzed.inline_override else { - return Ok(active); - }; - let Some(inline_datastore) = (match inline { - "sqlite" => Some(Datastore::Sqlite), - "neon" => Some(Datastore::Neon), - _ => None, - }) else { - return Err(DactylError::Routing(format!( - "unknown inline datastore {inline:?}" - ))); - }; - if inline_datastore == active.datastore { - return Ok(active); - } - - let (route_var, token_var) = match inline_datastore { - Datastore::Sqlite => ("DATASTORE_SQLITE_ROUTE", None), - Datastore::Neon => ("DATASTORE_NEON_ROUTE", Some("DATASTORE_NEON_TOKEN")), - }; - let route = std::env::var(route_var).map_err(|_| { - DactylError::Routing(format!( - "inline datastore {inline:?} requires {route_var} when it differs from DATASTORE" - )) - })?; - let token = token_var - .and_then(|name| std::env::var(name).ok()) - .or_else(|| { - (inline_datastore == Datastore::Neon) - .then(|| std::env::var("DATASTORE_TOKEN").ok()) - .flatten() - }); - Ok(match inline_datastore { - Datastore::Sqlite => DatastoreRoute::sqlite(route), - Datastore::Neon => DatastoreRoute::neon(route, token), - }) +pub fn write(sql: &str, params: &[Parameter]) -> Result { + Connection::from_env()?.write(sql, params) } -/// Execute any query against the active environment-selected datastore. +#[deprecated(note = "use dactyl_db::read")] pub fn query(sql: &str, params: &[Parameter]) -> Result { - connection_for_query(sql)?.query(sql, params) + read(sql, params) } -/// Execute one DDL/migration/affected-row statement. +#[deprecated(note = "use dactyl_db::write")] pub fn execute(sql: &str, params: &[Parameter]) -> Result { - connection_for_query(sql)?.execute(sql, params) -} - -/// Execute a caller-owned SQL script. -pub fn execute_batch(sql: &str) -> Result<(), DactylError> { - connection_for_query(sql)?.execute_batch(sql) + write(sql, params) } -/// Execute an atomic batch of parameterized statements. -pub fn transaction(statements: &[Statement]) -> Result, DactylError> { - if statements.is_empty() { - return Ok(Vec::new()); +fn build_adapter(route: &DatastoreRoute) -> Result, DactylError> { + match route.datastore { + Datastore::Sqlite => { + #[cfg(feature = "sqlite")] + { + Ok(Box::new(crate::adapter::sqlite::SqliteAdapter::open( + &route.route, + )?)) + } + #[cfg(not(feature = "sqlite"))] + { + Err(DactylError::Config( + "sqlite support is disabled; enable the `sqlite` feature".into(), + )) + } + } + Datastore::Neon => { + #[cfg(feature = "neon")] + { + Ok(Box::new(crate::adapter::neon::NeonAdapter::new( + &route.route, + route.token.clone(), + ))) + } + #[cfg(not(feature = "neon"))] + { + Err(DactylError::Config( + "neon support is disabled; enable the `neon` feature".into(), + )) + } + } } - connection_for_query(&statements[0].sql)?.transaction(statements) } diff --git a/src/query/dialect.rs b/src/query/dialect.rs deleted file mode 100644 index 553e5ac..0000000 --- a/src/query/dialect.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Dialect detection for the lexical analyzer. -//! -//! The analyzer recognizes the explicit list of constructs the project ships -//! with and treats anything else as portable SQL. A full SQL parser is -//! intentionally out of scope; unsafe constructs fail closed instead of being -//! guessed into a rewrite. - -/// SQL dialect an adapter speaks natively. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Dialect { - /// Constructs supported by both shipped SQL adapters. - Portable, - /// Local file-backed SQLite. - Sqlite, - /// Remote Postgres via Neon HTTP (Propodus). - Postgres, -} - -/// A single dialect-specific construct the analyzer found in a query. -/// -/// Anything not enumerated here is treated as portable SQL and never produces a -/// `Construct`. This keeps the dialect-mismatch check tight. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Construct { - // SQLite-only - JsonEach, - JsonTree, - WithoutRowId, - Strict, - // Portable or Postgres-only - JsonArrowText, - JsonArrow, - JsonContains, - JsonContained, - JsonExists, - Jsonb, - Returning, - Ilike, - GenRandomUuid, - NowFn, -} - -impl Construct { - /// Return the lexeme that triggered detection. - pub fn lexeme(self) -> &'static str { - match self { - Construct::JsonEach => "json_each", - Construct::JsonTree => "json_tree", - Construct::WithoutRowId => "without rowid", - Construct::Strict => "strict", - Construct::JsonArrowText => "->>", - Construct::JsonArrow => "->", - Construct::JsonContains => "@>", - Construct::JsonContained => "<@", - Construct::JsonExists => "?", - Construct::Jsonb => "jsonb", - Construct::Returning => "returning", - Construct::Ilike => "ilike", - Construct::GenRandomUuid => "gen_random_uuid", - Construct::NowFn => "now", - } - } - - /// The dialect this construct belongs to. - pub fn dialect(self) -> Dialect { - match self { - Construct::JsonEach - | Construct::JsonTree - | Construct::WithoutRowId - | Construct::Strict => Dialect::Sqlite, - Construct::JsonArrowText | Construct::JsonArrow | Construct::Returning => { - Dialect::Portable - } - _ => Dialect::Postgres, - } - } - - /// Whether the dialect natively supports this construct. - pub fn supported_by(self, dialect: Dialect) -> bool { - self.dialect() == Dialect::Portable || self.dialect() == dialect - } -} - -/// Decide whether the construct list is portable enough for the active dialect. -/// -/// Returns the first unsupported construct, or `None` if every construct is -/// supported (or the list is empty). -pub fn first_unsupported(constructs: &[Construct], dialect: Dialect) -> Option { - constructs - .iter() - .copied() - .find(|c| !c.supported_by(dialect)) -} diff --git a/src/query/lexer.rs b/src/query/lexer.rs deleted file mode 100644 index 8dd8653..0000000 --- a/src/query/lexer.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Lightweight SQL tokenizer used by the analyzer. -//! -//! This is **not** a full SQL parser. It only needs to: -//! 1. Skip whitespace and `--` line comments (so we can detect the -//! `-- dactyl: ` inline override). -//! 2. Recognize single-word lexemes (case-insensitive) like `returning`, -//! `ilike`, `json_each`, `without`, `rowid`, `strict`, `gen_random_uuid`, -//! `now`. -//! 3. Recognize multi-char operator lexemes (`->>`, `->`, `@>`, `<@`). -//! -//! The lexer is total: it never panics on arbitrary input and never raises -//! errors. Anything it cannot classify becomes an `Other` token, which the -//! analyzer ignores. - -/// A single token emitted by the lexer. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Token { - /// `select`, `from`, `where`, `returning`, etc. - Word(String), - /// Multi-char operator lexeme (`->>`, `->`, `@>`, `<@`). - Op(String), - /// A single `?` (Postgres JSON existence operator in this context). - Question, - /// `;` statement separator. - Semi, - /// Any other punctuation / character the analyzer doesn't care about. - Other(char), -} - -/// Tokenize a SQL string. -pub fn tokenize(input: &str) -> Vec { - let mut out = Vec::new(); - let bytes = input.as_bytes(); - let mut i = 0; - - while i < bytes.len() { - let c = bytes[i] as char; - - // line comment: -- ... \n - if c == '-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - continue; - } - - // block comment: /* ... */ (unterminated runs to EOF — matches sqlite) - if c == '/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - if i + 1 < bytes.len() { - i += 2; - } else { - i = bytes.len(); - } - continue; - } - - // string literal: '...' with '' escapes - if c == '\'' { - i += 1; - while i < bytes.len() { - if bytes[i] == b'\'' && i + 1 < bytes.len() && bytes[i + 1] == b'\'' { - i += 2; - continue; - } - if bytes[i] == b'\'' { - i += 1; - break; - } - i += 1; - } - continue; - } - - // quoted identifier: "..." with "" escapes - if c == '"' { - i += 1; - while i < bytes.len() { - if bytes[i] == b'"' && i + 1 < bytes.len() && bytes[i + 1] == b'"' { - i += 2; - continue; - } - if bytes[i] == b'"' { - i += 1; - break; - } - i += 1; - } - continue; - } - - // whitespace - if c.is_whitespace() { - i += 1; - continue; - } - - // identifier / keyword: [A-Za-z_][A-Za-z0-9_]* - if c.is_ascii_alphabetic() || c == '_' { - let start = i; - i += 1; - while i < bytes.len() { - let b = bytes[i]; - if (b as char).is_ascii_alphanumeric() || b == b'_' { - i += 1; - } else { - break; - } - } - let word = std::str::from_utf8(&bytes[start..i]) - .unwrap_or("") - .to_ascii_lowercase(); - out.push(Token::Word(word)); - continue; - } - - // digit start of a number literal — skip without emitting. - if c.is_ascii_digit() { - i += 1; - while i < bytes.len() - && ((bytes[i] as char).is_ascii_alphanumeric() || bytes[i] == b'.') - { - i += 1; - } - continue; - } - - // multi-char operators first - if i + 1 < bytes.len() { - let two = &bytes[i..i + 2]; - if two == b"->" { - if i + 2 < bytes.len() && bytes[i + 2] == b'>' { - out.push(Token::Op("->>".into())); - i += 3; - continue; - } - out.push(Token::Op("->".into())); - i += 2; - continue; - } - if two == b"@>" { - out.push(Token::Op("@>".into())); - i += 2; - continue; - } - if two == b"<@" { - out.push(Token::Op("<@".into())); - i += 2; - continue; - } - } - - match c { - '?' => { - out.push(Token::Question); - i += 1; - } - ';' => { - out.push(Token::Semi); - i += 1; - } - _ => { - out.push(Token::Other(c)); - i += 1; - } - } - } - - out -} - -/// If the SQL begins with a `-- dactyl: ` directive line, return -/// the recognized datastore plus the remainder of the input with the -/// directive line stripped. The remainder preserves all subsequent text -/// verbatim (including other comments). -pub fn strip_dactyl_directive(input: &str) -> (Option<&'static str>, String) { - let bytes = input.as_bytes(); - let mut i = 0; - - // Skip leading whitespace. - while i < bytes.len() && (bytes[i] as char).is_whitespace() { - i += 1; - } - - // Must start with `--`. - if i + 1 >= bytes.len() || bytes[i] != b'-' || bytes[i + 1] != b'-' { - return (None, input.to_string()); - } - let directive_start = i; - i += 2; - - // Skip whitespace after `--`. - while i < bytes.len() && (bytes[i] as char).is_whitespace() { - i += 1; - } - - // Expect "dactyl". - if i + 6 > bytes.len() || !bytes[i..i + 6].eq_ignore_ascii_case(b"dactyl") { - return (None, input.to_string()); - } - i += 6; - - // Skip whitespace before `:`. - while i < bytes.len() && (bytes[i] as char).is_whitespace() { - i += 1; - } - if i >= bytes.len() || bytes[i] != b':' { - return (None, input.to_string()); - } - i += 1; - - // Skip whitespace after `:`. - while i < bytes.len() && (bytes[i] as char).is_whitespace() { - i += 1; - } - - // Read identifier (alpha + alnum + _ + -). - let id_start = i; - while i < bytes.len() { - let b = bytes[i]; - if (b as char).is_ascii_alphanumeric() || b == b'_' || b == b'-' { - i += 1; - } else { - break; - } - } - if id_start == i { - return (None, input.to_string()); - } - let id = std::str::from_utf8(&bytes[id_start..i]).unwrap_or(""); - let mapped = match id.to_ascii_lowercase().as_str() { - "sqlite" => Some("sqlite"), - "neon" | "postgres" | "postgresql" | "pg" => Some("neon"), - _ => return (None, input.to_string()), - }; - - // Consume to end of line / start of next directive. - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - let line_end = i; - // Skip the trailing newline if any. - let after_directive = if line_end < bytes.len() { - line_end + 1 - } else { - line_end - }; - - // Drop the directive line. Also drop preceding whitespace-only lines so - // we don't leave an awkward blank at the head of the result. - let mut line_begin = directive_start; - while line_begin > 0 && bytes[line_begin - 1] != b'\n' { - line_begin -= 1; - } - // Walk backward over lines that are entirely whitespace before the - // directive line. - let mut trim_to = line_begin; - let mut scan = 0usize; - while scan < line_begin { - let next = input[scan..line_begin].find('\n').map(|o| scan + o); - let line_end_idx = next.unwrap_or(line_begin); - let line = &input[scan..line_end_idx]; - if !line.chars().all(|c| c.is_whitespace()) { - trim_to = line_begin; - break; - } - trim_to = line_end_idx + 1; // skip past newline - scan = line_end_idx + 1; - } - - let mut remainder = String::with_capacity(input.len()); - remainder.push_str(&input[..trim_to]); - remainder.push_str(&input[after_directive..]); - (mapped, remainder) -} diff --git a/src/query/mod.rs b/src/query/mod.rs deleted file mode 100644 index 02a43c3..0000000 --- a/src/query/mod.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Query analysis and bounded dialect preparation. -//! -//! Dactyl uses a total lexical scanner rather than pretending to be a full SQL -//! parser. It rejects constructs that cannot be translated safely and applies -//! only explicit, semantics-bounded rewrites when the caller enables them. - -mod dialect; -mod lexer; - -use crate::error::DactylError; - -pub use dialect::{first_unsupported, Construct, Dialect}; - -/// Outcome of analyzing a query without selecting an adapter. -#[derive(Debug, Clone)] -pub struct Analyzed { - /// All dialect-specific constructs the lexer found, in source order. - pub constructs: Vec, - /// Inline `-- dactyl: ` override, if any. - pub inline_override: Option<&'static str>, - /// A directive-stripping rewrite plan. Adapter-specific rewrites are - /// selected by [`QueryAnalyzer::prepare`]. - pub rewrite: Rewrite, -} - -/// A no-op or directive-stripping rewrite plan. -#[derive(Debug, Clone)] -pub enum Rewrite { - /// Pass the query through unchanged. - Identity, - /// A rewritten query string. - Replaced(String), -} - -impl Rewrite { - /// Apply the rewrite and return the SQL string to execute. - pub fn apply(&self, original: &str) -> String { - match self { - Rewrite::Identity => original.to_string(), - Rewrite::Replaced(s) => s.clone(), - } - } -} - -/// The analyzer. Cheap to construct; carries no state. -#[derive(Debug, Default, Clone, Copy)] -pub struct QueryAnalyzer; - -impl QueryAnalyzer { - pub fn new() -> Self { - Self - } - - /// Lex a SQL string and produce an [`Analyzed`] value. - pub fn analyze(&self, query: &str) -> Analyzed { - let (inline_override, remainder) = lexer::strip_dactyl_directive(query); - let tokens = lexer::tokenize(&remainder); - let constructs = detect_constructs(&tokens); - let rewrite = if constructs.is_empty() && inline_override.is_none() { - Rewrite::Identity - } else { - Rewrite::Replaced(remainder) - }; - Analyzed { - constructs, - inline_override, - rewrite, - } - } - - /// Analyze and prepare SQL for one concrete adapter dialect. - /// - /// Inline directives are validated against the connection's selected - /// dialect. Unsupported constructs are rejected by default. When - /// `allow_rewrites` is true, only the small set of loss-bounded rewrites - /// below is applied; constructs with no safe translation still fail. - pub fn prepare( - &self, - query: &str, - dialect: Dialect, - allow_rewrites: bool, - ) -> Result { - let analyzed = self.analyze(query); - if let Some(override_ds) = analyzed.inline_override { - let override_dialect = dialect_of(override_ds).ok_or_else(|| { - DactylError::Routing(format!("unknown inline datastore {override_ds:?}")) - })?; - if override_dialect != dialect { - return Err(DactylError::Routing(format!( - "inline datastore {override_ds:?} does not match the active connection" - ))); - } - } - - let unsupported = first_unsupported(&analyzed.constructs, dialect); - let (_, remainder) = lexer::strip_dactyl_directive(query); - if unsupported.is_none() { - return Ok(remainder); - } - if !allow_rewrites { - return Err(DactylError::Unsupported { - construct: unsupported.expect("checked above"), - }); - } - - rewrite_for_dialect(&remainder, &analyzed.constructs, dialect).ok_or_else(|| { - DactylError::Unsupported { - construct: unsupported.expect("checked above"), - } - }) - } -} - -/// Walk the token stream and collect dialect-specific constructs. -fn detect_constructs(tokens: &[lexer::Token]) -> Vec { - let mut out = Vec::new(); - let mut i = 0; - while i < tokens.len() { - if let lexer::Token::Word(w) = &tokens[i] { - match w.as_str() { - "json_each" => out.push(Construct::JsonEach), - "json_tree" => out.push(Construct::JsonTree), - "without" => { - if let Some(lexer::Token::Word(n)) = tokens.get(i + 1) { - if n == "rowid" { - out.push(Construct::WithoutRowId); - i += 1; - } - } - } - "strict" => out.push(Construct::Strict), - "jsonb" => out.push(Construct::Jsonb), - "returning" => out.push(Construct::Returning), - "ilike" => out.push(Construct::Ilike), - "gen_random_uuid" => out.push(Construct::GenRandomUuid), - "now" => out.push(Construct::NowFn), - _ => {} - } - } else if let lexer::Token::Op(o) = &tokens[i] { - match o.as_str() { - "->>" => out.push(Construct::JsonArrowText), - "->" => out.push(Construct::JsonArrow), - "@>" => out.push(Construct::JsonContains), - "<@" => out.push(Construct::JsonContained), - _ => {} - } - } - // `?` is also a valid SQLite parameter placeholder. The lexical - // scanner therefore never classifies it as JSON existence syntax. - i += 1; - } - out -} - -/// Apply only rewrites whose semantics are stable for the supported adapters. -/// Table-valued JSON functions, JSON containment, and UUID generation remain -/// explicit errors because a lexical substitution would silently change data. -fn rewrite_for_dialect(sql: &str, constructs: &[Construct], dialect: Dialect) -> Option { - let mut rewritten = sql.to_string(); - for &construct in constructs { - match (dialect, construct) { - (Dialect::Sqlite, Construct::Ilike) => { - rewritten = replace_word(&rewritten, "ilike", "like"); - } - (Dialect::Sqlite, Construct::NowFn) => { - rewritten = replace_now_function(&rewritten); - } - (Dialect::Sqlite, Construct::Jsonb) => { - rewritten = replace_word(&rewritten, "jsonb", "text"); - } - (Dialect::Postgres, Construct::Strict) => { - rewritten = replace_word(&rewritten, "strict", ""); - } - (_, Construct::JsonEach) - | (_, Construct::JsonTree) - | (_, Construct::WithoutRowId) - | (_, Construct::JsonContains) - | (_, Construct::JsonContained) - | (_, Construct::JsonExists) - | (_, Construct::GenRandomUuid) => return None, - (_, Construct::JsonArrowText | Construct::JsonArrow | Construct::Returning) => { - if !construct.supported_by(dialect) { - return None; - } - } - (_, Construct::Ilike | Construct::NowFn | Construct::Jsonb | Construct::Strict) => { - if !construct.supported_by(dialect) { - return None; - } - } - } - } - Some(rewritten) -} - -fn replace_word(input: &str, needle: &str, replacement: &str) -> String { - rewrite_unquoted(input, |segment| { - replace_word_unquoted(segment, needle, replacement) - }) -} - -fn replace_word_unquoted(input: &str, needle: &str, replacement: &str) -> String { - let mut out = String::with_capacity(input.len()); - let lower = input.to_ascii_lowercase(); - let bytes = input.as_bytes(); - let needle_bytes = needle.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let end = i + needle_bytes.len(); - let boundary_before = - i == 0 || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_'); - let boundary_after = - end >= bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_'); - if end <= bytes.len() - && boundary_before - && boundary_after - && &lower.as_bytes()[i..end] == needle_bytes - { - out.push_str(replacement); - i = end; - } else { - out.push(bytes[i] as char); - i += 1; - } - } - out -} - -fn replace_now_function(input: &str) -> String { - rewrite_unquoted(input, replace_now_function_unquoted) -} - -fn replace_now_function_unquoted(input: &str) -> String { - let bytes = input.as_bytes(); - let lower = input.to_ascii_lowercase(); - let mut out = String::with_capacity(input.len()); - let mut i = 0; - while i < bytes.len() { - if lower.as_bytes()[i..].starts_with(b"now") - && (i == 0 || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_')) - && (i + 3 == bytes.len() - || (!bytes[i + 3].is_ascii_alphanumeric() && bytes[i + 3] != b'_')) - { - let mut j = i + 3; - while j < bytes.len() && bytes[j].is_ascii_whitespace() { - j += 1; - } - if j + 1 < bytes.len() && bytes[j] == b'(' && bytes[j + 1] == b')' { - out.push_str("CURRENT_TIMESTAMP"); - i = j + 2; - continue; - } - } - out.push(bytes[i] as char); - i += 1; - } - out -} - -/// Apply a transformation only to SQL code, preserving string literals and -/// comments byte-for-byte. This keeps dialect rewrites from changing data or -/// documentation embedded in the query. -fn rewrite_unquoted(input: &str, mut transform: F) -> String -where - F: FnMut(&str) -> String, -{ - let bytes = input.as_bytes(); - let mut out = String::with_capacity(input.len()); - let mut segment_start = 0; - let mut i = 0; - while i < bytes.len() { - let quote = if bytes[i] == b'\'' || bytes[i] == b'"' { - Some(bytes[i]) - } else { - None - }; - let comment = (bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-') - || (bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*'); - if quote.is_none() && !comment { - i += 1; - continue; - } - - out.push_str(&transform(&input[segment_start..i])); - let protected_start = i; - if let Some(delimiter) = quote { - i += 1; - while i < bytes.len() { - if bytes[i] == delimiter { - if i + 1 < bytes.len() && bytes[i + 1] == delimiter { - i += 2; - continue; - } - i += 1; - break; - } - i += 1; - } - } else if bytes[i] == b'-' { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - } else { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - if i + 1 < bytes.len() { - i += 2; - } else { - i = bytes.len(); - } - } - out.push_str(&input[protected_start..i]); - segment_start = i; - } - out.push_str(&transform(&input[segment_start..])); - out -} - -/// Map a datastore name to its native dialect. -pub fn dialect_of(datastore: &str) -> Option { - match datastore { - "sqlite" => Some(Dialect::Sqlite), - "neon" | "postgres" | "postgresql" | "pg" => Some(Dialect::Postgres), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prepare_strips_directive_and_rejects_unsafe_mismatch() { - let analyzer = QueryAnalyzer::new(); - let sql = analyzer - .prepare("-- dactyl: sqlite\nselect ?1", Dialect::Sqlite, false) - .unwrap(); - assert_eq!(sql, "select ?1"); - assert!(matches!( - analyzer.prepare("select data @> $1", Dialect::Sqlite, true), - Err(DactylError::Unsupported { .. }) - )); - } - - #[test] - fn safe_rewrites_are_explicit() { - let analyzer = QueryAnalyzer::new(); - let sql = analyzer - .prepare( - "select now() where name ilike $1 and note = 'now() ilike'", - Dialect::Sqlite, - true, - ) - .unwrap(); - assert!(sql.contains("CURRENT_TIMESTAMP")); - assert!(sql.contains("like")); - assert!(sql.contains("'now() ilike'")); - } - - #[test] - fn question_placeholders_are_not_json_operators() { - let analyzed = QueryAnalyzer::new().analyze("select ?1, ? from values"); - assert!(analyzed.constructs.is_empty()); - } -} diff --git a/src/rows.rs b/src/rows.rs index 4dadbb7..a7044a0 100644 --- a/src/rows.rs +++ b/src/rows.rs @@ -1,4 +1,4 @@ -//! Row projection returned by [`crate::query`] and [`crate::transaction`]. +//! Row projection returned by [`crate::read`]. //! //! # Named-column projection contract (dactyl #25) //! @@ -59,7 +59,7 @@ impl IntoIterator for Rows { /// One result row. Carries the column names plus the per-cell JSON values. /// -/// The row **owns** both vectors. After `query` / `transaction` returns, the +/// The row **owns** both vectors. After `read` returns, the /// short-lived adapter is dropped; callers may keep `Row` values indefinitely. /// Borrowed accessors (`get_str_ref`, `get_json_ref`) are tied to `&self` only. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/tests/backend_contract.rs b/tests/backend_contract.rs index 76aaa83..1d6a07b 100644 --- a/tests/backend_contract.rs +++ b/tests/backend_contract.rs @@ -1,112 +1,71 @@ #![cfg(feature = "sqlite")] -use dactyl_db::{ - Connection, ConnectionOptions, Datastore, DatastoreRoute, Parameter, Statement, StorageOp, - StorageResult, -}; +use dactyl_db::{AdapterErrorKind, Connection, Datastore, DatastoreRoute, Parameter}; use tempfile::NamedTempFile; #[test] -fn connection_contract_covers_scripts_batches_and_last_insert_id() { - let db = NamedTempFile::new().unwrap(); - let connection = Connection::open(DatastoreRoute::sqlite(db.path().to_string_lossy())) - .expect("sqlite connection"); +fn sqlite_read_write_is_the_only_driver_contract() { + let file = NamedTempFile::new().expect("temp sqlite file"); + let db = Connection::open(DatastoreRoute::sqlite(file.path().to_string_lossy())).unwrap(); + assert_eq!(db.datastore(), Datastore::Sqlite); - connection - .execute_batch( - "CREATE TABLE records (id INTEGER PRIMARY KEY, body BLOB NOT NULL);\ - CREATE INDEX records_id ON records(id);", + db.write( + "create table app (id integer primary key, name text, enabled integer, payload blob)", + &[], + ) + .unwrap(); + assert_eq!( + db.write( + "insert into app (name, enabled, payload) values ($1, $2, $3)", + &[ + Parameter::Text("opened".into()), + Parameter::Bool(true), + Parameter::Blob(vec![1, 2, 3]), + ], ) - .expect("schema script"); - connection - .transaction(&[ - Statement::new( - "INSERT INTO records(body) VALUES ($1)", - vec![Parameter::Blob(vec![1, 2, 3])], - ), - Statement::new( - "INSERT INTO records(body) VALUES ($1)", - vec![Parameter::Blob(vec![4, 5])], - ), - ]) - .expect("atomic writes"); + .unwrap(), + 1 + ); - assert_eq!(connection.last_insert_id().unwrap(), 2); - let rows = connection - .query("SELECT id, body FROM records ORDER BY id", &[]) + let rows = db + .read("select id, name, enabled, payload from app", &[]) .unwrap(); - assert_eq!(rows.len(), 2); + let row = &rows.as_slice()[0]; + assert_eq!(row.get_int("id").unwrap(), 1); + assert_eq!(row.get_str("name").unwrap(), "opened"); + assert!(row.get_bool("enabled").unwrap()); assert_eq!( - rows.as_slice()[0].get_json("body").unwrap(), + row.get_json("payload").unwrap(), serde_json::json!([1, 2, 3]) ); - - let result = connection - .execute_op(StorageOp::Query { - sql: "SELECT count(*) AS count FROM records".into(), - params: vec![], - }) - .unwrap(); - assert!(matches!(result, StorageResult::Rows(rows) if rows.len() == 1)); - assert_eq!(connection.datastore(), Datastore::Sqlite); } #[test] -fn runtime_analysis_rejects_unsafe_sql_and_allows_bounded_rewrites() { - let db = NamedTempFile::new().unwrap(); - let route = DatastoreRoute::sqlite(db.path().to_string_lossy()); - let strict = Connection::open(route.clone()).unwrap(); - assert!(matches!( - strict.query("SELECT now()", &[]), - Err(dactyl_db::DactylError::Unsupported { .. }) - )); - - let rewriting = - Connection::open_with_options(route, ConnectionOptions::default().with_rewrites(true)) - .unwrap(); - let rows = rewriting - .query("SELECT now() AS current_time", &[]) - .unwrap(); - assert_eq!(rows.len(), 1); - assert!(rows.as_slice()[0].get_str("current_time").is_ok()); -} - -#[test] -fn inline_directives_are_checked_against_the_connection() { - let db = NamedTempFile::new().unwrap(); - let connection = Connection::open(DatastoreRoute::sqlite(db.path().to_string_lossy())).unwrap(); - let rows = connection - .query("-- dactyl: sqlite\nSELECT 1 AS value", &[]) - .unwrap(); - assert_eq!(rows.as_slice()[0].get_int("value").unwrap(), 1); +fn sqlite_errors_are_typed_without_string_parsing() { + let file = NamedTempFile::new().expect("temp sqlite file"); + let db = Connection::open(DatastoreRoute::sqlite(file.path().to_string_lossy())).unwrap(); + db.write( + "create table app (id integer primary key, name text unique)", + &[], + ) + .unwrap(); + db.write( + "insert into app (name) values ($1)", + &[Parameter::Text("a".into())], + ) + .unwrap(); + let error = db + .write( + "insert into app (name) values ($1)", + &[Parameter::Text("a".into())], + ) + .unwrap_err(); assert!(matches!( - connection.query("-- dactyl: neon\nSELECT 1", &[]), - Err(dactyl_db::DactylError::Routing(_)) + error, + dactyl_db::DactylError::Adapter { + kind: AdapterErrorKind::Constraint, + .. + } )); } - -#[test] -fn read_only_policy_is_enforced_by_the_local_boundary() { - let db = NamedTempFile::new().unwrap(); - let writable = Connection::open(DatastoreRoute::sqlite(db.path().to_string_lossy())).unwrap(); - writable - .execute("CREATE TABLE values_table (value INTEGER)", &[]) - .unwrap(); - - let read_only = Connection::open_with_options( - DatastoreRoute::sqlite(db.path().to_string_lossy()), - ConnectionOptions::default().read_only(true), - ) - .unwrap(); - assert_eq!( - read_only - .query("SELECT count(*) AS count FROM values_table", &[]) - .unwrap() - .len(), - 1 - ); - assert!(read_only - .execute("INSERT INTO values_table(value) VALUES (1)", &[]) - .is_err()); -} diff --git a/tests/conformance.rs b/tests/conformance.rs index 1635085..431b942 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -1,1555 +1,100 @@ -//! Conformance harness: same query, two adapters, identical row projections. -//! -//! Each test selects the active datastore through ambient env vars -//! (`DATASTORE` / `DATASTORE_ROUTE` / `DATASTORE_TOKEN`). SQLite tests point -//! at a tempfile SQLite DB; Neon tests point at an in-process axum mock -//! server. Row projections must match column-for-column. -//! -//! Covers dactyl issues: -//! - #2 every store × every adapter × parameterized reads/writes -//! - #23 parameter binding, NULL/bool/int/real/text + injection attempt -//! - #24 atomic transaction + rollback-on-failure (SQLite + Neon mock), -//! event-plus-state fixture, nesting/retry/timeout/idempotency contract -//! - #25 typed named extraction, NULL, missing column, conversion error, -//! duplicate aliases, full scalar matrix through both adapters, and -//! borrowed-vs-owned accessors (also DecapodLabs/decapod#1111) -//! - #26 session isolation via per-call adapter construction -//! - #27 caller-owned schema; dactyl never silently bootstraps tables - -#![cfg(all(feature = "sqlite", feature = "neon"))] - -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::Arc; - -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::{extract::State, routing::post, Json, Router}; -use serde::{Deserialize, Serialize}; -use tempfile::TempDir; -use tokio::sync::Mutex; - -use dactyl_db::{DactylError, Parameter, Rows, Statement}; - -/// Serialize tests that mutate `DATASTORE*` env vars so they cannot race with -/// each other. The lib no longer caches adapters, so env-var discipline is the -/// only shared mutable state. -static ENV_MUTEX: std::sync::OnceLock> = std::sync::OnceLock::new(); - -fn lock_env() -> std::sync::MutexGuard<'static, ()> { - ENV_MUTEX - .get_or_init(|| std::sync::Mutex::new(())) +#![cfg(feature = "neon")] + +use std::sync::{Arc, Mutex}; + +use axum::{extract::State, http::HeaderMap, routing::post, Json, Router}; +use dactyl_db::{Connection, Datastore, DatastoreRoute, Parameter}; +use serde_json::{json, Value}; +use tokio::runtime::Runtime; +use tokio::sync::oneshot; + +type RequestLog = Arc)>>>; + +#[derive(Clone)] +struct MockState(RequestLog); + +async fn query( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Json { + let authorization = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + state + .0 .lock() - .unwrap_or_else(|e| e.into_inner()) -} - -/// Select sqlite as the active datastore pointing at `path`. -fn select_sqlite(path: &str) { - unsafe { - std::env::set_var("DATASTORE", "sqlite"); - std::env::set_var("DATASTORE_ROUTE", path); - std::env::remove_var("DATASTORE_TOKEN"); - } -} - -/// Select neon as the active datastore pointing at `endpoint`. -fn select_neon(endpoint: &str) { - unsafe { - std::env::set_var("DATASTORE", "neon"); - std::env::set_var("DATASTORE_ROUTE", endpoint); - std::env::set_var("DATASTORE_TOKEN", "test-token"); - } -} - -/// Clear every dactyl env var. The lib has no cache to clear; this just makes -/// the next test that forgets to call `select_*` fail loudly. -fn clear_env() { - unsafe { - std::env::remove_var("DATASTORE"); - std::env::remove_var("DATASTORE_ROUTE"); - std::env::remove_var("DATASTORE_TOKEN"); - } -} - -// --------------------------------------------------------------------------- -// Mock neon server: a tiny in-process axum app that stores rows per table. -// --------------------------------------------------------------------------- - -/// One mock table: explicit column order plus row objects. -#[derive(Clone, Default)] -struct MockTable { - columns: Vec, - rows: Vec, -} - -/// Mock neon-server state, shared across all tests. -/// -/// `/batch` is **atomic**: statements apply against a snapshot; any error -/// restores the snapshot so no partial writes remain (mirrors Propodus -/// all-or-nothing batch contract for dactyl #24). -#[derive(Default)] -struct MockState { - tables: Mutex>, -} - -impl MockState { - async fn handle(State(state): State>, Json(req): Json) -> Response { - let mut tables = state.tables.lock().await; - match apply_statement(&mut tables, &req.sql, req.params.as_ref()) { - Ok(resp) => Json(resp).into_response(), - Err(msg) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": msg })), - ) - .into_response(), - } - } - - async fn handle_batch( - State(state): State>, - Json(req): Json, - ) -> Response { - let mut tables = state.tables.lock().await; - // Snapshot for full rollback on any per-statement failure. - let snapshot = tables.clone(); - let mut results = Vec::new(); - for stmt in &req.statements { - match apply_statement(&mut tables, &stmt.sql, stmt.params.as_ref()) { - Ok(resp) => results.push(resp), - Err(msg) => { - *tables = snapshot; - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": format!("batch aborted: {msg}"), - })), - ) - .into_response(); - } - } - } - (StatusCode::OK, Json(MockBatchResponse { results })).into_response() - } -} - -/// Apply one SQL statement against the mock table map. -/// -/// Supports a small dialect used by conformance tests: `CREATE TABLE`, -/// `INSERT INTO … VALUES ($n,…)`, and `SELECT` (including `count(*)`). -fn apply_statement( - tables: &mut HashMap, - sql: &str, - params: Option<&serde_json::Value>, -) -> Result { - let lower = sql.to_ascii_lowercase(); - let trimmed = lower.trim(); - if trimmed.starts_with("create table") { - return apply_create(tables, &lower); - } - if trimmed.starts_with("insert into") { - return apply_insert(tables, sql, params); - } - if trimmed.starts_with("select") { - return apply_select(tables, &lower); - } - // Fallback: treat as a read of `FROM ` if present. - apply_select(tables, &lower) -} - -fn apply_create( - tables: &mut HashMap, - lower_sql: &str, -) -> Result { - // create table [if not exists] name ( - let after = lower_sql - .split_once("table") - .map(|(_, rest)| rest.trim()) - .ok_or_else(|| "create table: missing name".to_string())?; - let after = after - .strip_prefix("if not exists") - .map(str::trim) - .unwrap_or(after); - let name: String = after - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - if name.is_empty() { - return Err("create table: empty name".into()); - } - let cols = parse_create_columns(lower_sql); - tables.entry(name).or_insert_with(|| MockTable { - columns: cols, - rows: Vec::new(), - }); - Ok(MockResponse { - columns: vec![], - rows: vec![], - }) -} - -fn parse_create_columns(lower_sql: &str) -> Vec { - let Some(start) = lower_sql.find('(') else { - return vec![]; - }; - let Some(end) = lower_sql.rfind(')') else { - return vec![]; - }; - if end <= start { - return vec![]; - } - lower_sql[start + 1..end] - .split(',') - .filter_map(|part| { - let col = part - .split_whitespace() - .next() - .unwrap_or("") - .trim_matches('"'); - if col.is_empty() || col == "primary" { - None - } else { - Some(col.to_string()) - } - }) - .collect() -} - -fn apply_insert( - tables: &mut HashMap, - sql: &str, - params: Option<&serde_json::Value>, -) -> Result { - let lower = sql.to_ascii_lowercase(); - let after = lower - .split_once("insert into") - .map(|(_, r)| r.trim()) - .ok_or_else(|| "insert: parse failed".to_string())?; - let table: String = after - .chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect(); - if table.is_empty() { - return Err("insert: empty table".into()); - } - let cols = parse_insert_columns(sql).unwrap_or_else(|| { - tables - .get(&table) - .map(|t| t.columns.clone()) - .unwrap_or_default() - }); - let p = params_list(params); - if cols.is_empty() { - return Err(format!("insert into {table}: no columns")); - } - if p.len() < cols.len() { - return Err(format!( - "insert into {table}: expected {} params, got {}", - cols.len(), - p.len() - )); - } - let mut obj = serde_json::Map::new(); - for (i, c) in cols.iter().enumerate() { - obj.insert(c.clone(), p[i].clone()); - } - let entry = tables.entry(table.clone()).or_insert_with(|| MockTable { - columns: cols.clone(), - rows: Vec::new(), - }); - if entry.columns.is_empty() { - entry.columns = cols.clone(); - } - // Primary-key style uniqueness on `id` when present. - if let Some(id) = obj.get("id") { - if entry.rows.iter().any(|r| r.get("id") == Some(id)) { - return Err(format!("duplicate key id={id} in {table}")); - } - } - entry.rows.push(serde_json::Value::Object(obj)); - Ok(MockResponse { - columns: entry.columns.clone(), - rows: vec![], - }) -} - -fn parse_insert_columns(sql: &str) -> Option> { - let lower = sql.to_ascii_lowercase(); - let after_table = lower.split_once("insert into")?.1.trim(); - let rest = after_table.find('(').map(|i| &after_table[i..])?; - let end = rest.find(')')?; - let inner = &rest[1..end]; - // Only the column list before VALUES. - if !lower.contains("values") { - return None; - } - let cols: Vec = inner - .split(',') - .map(|c| c.trim().trim_matches('"').to_string()) - .filter(|c| !c.is_empty()) - .collect(); - if cols.is_empty() { - None - } else { - Some(cols) - } -} - -fn params_list(params: Option<&serde_json::Value>) -> Vec { - match params { - Some(serde_json::Value::Array(a)) => a.clone(), - Some(other) => vec![other.clone()], - None => vec![], - } -} - -fn apply_select( - tables: &mut HashMap, - lower_sql: &str, -) -> Result { - let table = table_of(lower_sql); - let data = tables.get(&table).cloned().unwrap_or_default(); - let columns = if data.columns.is_empty() { - // Legacy default for seeded store fixtures. - vec!["id".into(), "title".into(), "status".into()] + .unwrap() + .push((request.clone(), authorization)); + let sql = request["sql"].as_str().unwrap_or_default(); + if sql.starts_with("select") { + Json(json!({ + "columns": ["id", "name", "enabled", "payload"], + "rows": [{"id": 1, "name": "opened", "enabled": true, "payload": [1, 2, 3]}] + })) } else { - data.columns.clone() - }; - - if lower_sql.contains("count(*)") { - let alias = if lower_sql.contains(" as cnt") { - "cnt" - } else { - "count(*)" - }; - return Ok(MockResponse { - columns: vec![alias.into()], - rows: vec![serde_json::json!({ alias: data.rows.len() as i64 })], - }); - } - - Ok(MockResponse { - columns, - rows: data.rows, - }) -} - -fn table_of(sql: &str) -> String { - sql.to_ascii_lowercase() - .split_whitespace() - .skip_while(|w| *w != "from") - .nth(1) - .map(|s| { - s.chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') - .collect::() - }) - .unwrap_or_default() -} - -#[derive(Debug, Deserialize)] -struct MockRequest { - sql: String, - #[serde(default)] - params: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct MockResponse { - columns: Vec, - rows: Vec, -} - -#[derive(Debug, Deserialize)] -struct MockBatchRequest { - statements: Vec, -} - -#[derive(Debug, Deserialize)] -struct MockStatement { - sql: String, - #[serde(default)] - params: Option, -} - -#[derive(Debug, Serialize)] -struct MockBatchResponse { - results: Vec, -} - -/// Spin up the in-process axum mock on a random port. -async fn spawn_mock(state: Arc) -> SocketAddr { - let app = Router::new() - .route("/query", post(MockState::handle)) - .route("/batch", post(MockState::handle_batch)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("axum serve"); - }); - addr -} - -/// Helper: run a closure against a live mock Neon endpoint, then shut it down. -fn with_neon_mock(seed: HashMap, f: F) -where - F: FnOnce(&str), -{ - let state = Arc::new(MockState { - tables: Mutex::new(seed), - }); - let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); - let mock_thread = std::thread::spawn({ - let state = state.clone(); - move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("mock rt"); - rt.block_on(async { - let addr = spawn_mock(state).await; - let _ = ready_tx.send(format!("http://{addr}")); - let _ = done_rx.await; - }); - } - }); - let endpoint = ready_rx.recv().expect("ready"); - f(&endpoint); - let _ = done_tx.send(()); - let _ = mock_thread.join(); -} - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const STORES: &[&str] = &[ - "todos", - "knowledge", - "governance", - "memory", - "automation", - "broker_dedupe", - "lcm", - "federation", - "events", -]; - -fn seed_rows(table: &str) -> Vec { - vec![ - serde_json::json!({"id": 1, "title": format!("{table}-a"), "status": "open"}), - serde_json::json!({"id": 2, "title": format!("{table}-b"), "status": "done"}), - ] -} - -fn sqlite_path(tmp: &TempDir, store: &str) -> std::path::PathBuf { - let dir = tmp.path().join(".decapod/data"); - std::fs::create_dir_all(&dir).unwrap(); - dir.join(format!("{store}.db")) -} - -/// Caller-owned schema bootstrap: dactyl must NOT create tables itself -/// (#27), so the harness creates them via raw rusqlite (test-only) and then -/// drives dactyl against the seeded DB. -fn seed_sqlite(path: &std::path::Path, store: &str, rows: &[serde_json::Value]) { - use rusqlite::Connection; - let conn = Connection::open(path).expect("open"); - let ddl = format!( - "create table if not exists {store} ( - id integer primary key, - title text not null, - status text not null - )" - ); - conn.execute(&ddl, []).expect("create table"); - for r in rows { - let id = r.get("id").and_then(|v| v.as_i64()).unwrap_or(0); - let title = r.get("title").and_then(|v| v.as_str()).unwrap_or(""); - let status = r.get("status").and_then(|v| v.as_str()).unwrap_or(""); - let _ = conn.execute( - &format!("delete from {store} where id = ?1"), - rusqlite::params![id], - ); - conn.execute( - &format!("insert into {store}(id, title, status) values (?1, ?2, ?3)"), - rusqlite::params![id, title, status], - ) - .expect("insert"); - } -} - -/// Create a fresh empty SQLite file with NO tables. Used to prove dactyl does -/// not silently bootstrap schema (#27): querying an unbootstrapped table must -/// surface the adapter's "no such table" error rather than succeed. -fn empty_sqlite(tmp: &TempDir, name: &str) -> std::path::PathBuf { - use rusqlite::Connection; - let dir = tmp.path().join(".decapod/data"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(format!("{name}.db")); - { - let _ = Connection::open(&path).expect("open creates the file"); - } - path -} - -fn project(rows: &Rows) -> Vec<(String, serde_json::Value)> { - rows.iter() - .flat_map(|r| { - r.columns - .iter() - .zip(r.values.iter()) - .map(|(c, v)| (c.clone(), v.clone())) - .collect::>() - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -/// Conformance: every store, every adapter, identical projections. -#[test] -fn conformance_all_stores() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let state = Arc::new(MockState::default()); - - let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); - - let mock_thread = std::thread::spawn({ - let state = state.clone(); - move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("mock rt"); - rt.block_on(async { - { - let mut tables = state.tables.lock().await; - for store in STORES { - tables.insert( - store.to_string(), - MockTable { - columns: vec!["id".into(), "title".into(), "status".into()], - rows: seed_rows(store), - }, - ); - } - } - let addr = spawn_mock(state.clone()).await; - let _ = ready_tx.send(format!("http://{addr}")); - let _ = done_rx.await; - }); - } - }); - - let endpoint = ready_rx.recv().expect("ready"); - - for store in STORES { - let path = sqlite_path(&tmp, store); - seed_sqlite(&path, store, &seed_rows(store)); - - let query = format!("select id, title, status from {store}"); - - // SQLite pass. - select_sqlite(path.to_str().unwrap()); - let sqlite_rows = dactyl_db::query(&query, &[]).expect("sqlite read"); - assert_eq!(sqlite_rows.len(), 2, "store {store} sqlite row count"); - for row in sqlite_rows.iter() { - assert_eq!(row.columns, vec!["id", "title", "status"]); - assert_eq!(row.values.len(), 3); - } - - // Neon pass. - select_neon(&endpoint); - let neon_rows = dactyl_db::query(&query, &[]).expect("neon read"); - assert_eq!(neon_rows.len(), 2, "store {store} neon row count"); - for row in neon_rows.iter() { - assert_eq!(row.columns, vec!["id", "title", "status"]); - assert_eq!(row.values.len(), 3); - } - - // Cross-adapter projection equality at the JSON level. - select_sqlite(path.to_str().unwrap()); - let sqlite_rows = dactyl_db::query(&query, &[]).expect("sqlite re-read"); - select_neon(&endpoint); - let neon_rows = dactyl_db::query(&query, &[]).expect("neon re-read"); - assert_eq!( - project(&neon_rows), - project(&sqlite_rows), - "store {store}: projection mismatch" - ); - } - - clear_env(); - let _ = done_tx.send(()); - let _ = mock_thread.join(); -} - -/// #23: parameterized reads/writes with every supported scalar type plus an -/// attempted SQL-injection value. The injection payload must hit the table as -/// data, never as SQL. -#[test] -fn parameterized_queries_and_injection_regression() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = tmp.path().join("params.db"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute( - "create table params ( - id integer primary key, - flag integer, - ratio real, - label text, - note text, - nullable_id integer - )", - &[], - ) - .expect("caller creates schema"); - - let injection = "'; drop table params; --"; - dactyl_db::execute( - "insert into params (id, flag, ratio, label, note, nullable_id) values ($1, $2, $3, $4, $5, $6)", - &[ - Parameter::Integer(1), - Parameter::Bool(true), - Parameter::Real(1.5), - Parameter::Text("normal".into()), - Parameter::Text(injection.into()), - Parameter::Null, - ], - ) - .expect("insert with injection payload as bound data"); - - // The table still exists: the payload was bound, not interpolated. - let rows = dactyl_db::query( - "select id, flag, ratio, label, note from params where id = $1", - &[Parameter::Integer(1)], - ) - .expect("select back"); - assert_eq!(rows.len(), 1); - let row = &rows.as_slice()[0]; - assert_eq!(row.get::<_, i64>("id").expect("id"), 1); - assert!(row.get_bool("flag").expect("flag")); - assert_eq!(row.get::<_, f64>("ratio").expect("ratio"), 1.5); - assert_eq!(row.get_str("label").expect("label"), "normal"); - assert_eq!( - row.get::<_, String>("note").expect("note"), - injection, - "injection payload preserved verbatim as data" - ); - - // NULL parameter binding round-trips: a row where a non-key column was - // bound to NULL must read back as None through Option. - dactyl_db::execute( - "insert into params (id, flag, ratio, label, note, nullable_id) values ($1, $2, $3, $4, $5, $6)", - &[ - Parameter::Integer(2), - Parameter::Null, - Parameter::Null, - Parameter::Null, - Parameter::Null, - Parameter::Null, - ], - ) - .expect("insert nulls"); - let nulls = dactyl_db::query( - "select flag, ratio, label from params where id = $1", - &[Parameter::Integer(2)], - ) - .expect("select nulls"); - assert_eq!(nulls.len(), 1); - let n = nulls.as_slice()[0].clone(); - assert!(n.get::<_, Option>("flag").expect("flag").is_none()); - assert!(n.get::<_, Option>("ratio").expect("ratio").is_none()); - assert!(n - .get::<_, Option>("label") - .expect("label") - .is_none()); - - clear_env(); -} - -/// #25: typed named extraction with NULL, missing column, and conversion error. -#[test] -fn typed_row_extraction_and_error_semantics() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = tmp.path().join("typed.db"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute( - "create table typed (id integer primary key, title text not null, status text)", - &[], - ) - .expect("create"); - dactyl_db::execute( - "insert into typed (id, title, status) values ($1, $2, $3)", - &[ - Parameter::Integer(2), - Parameter::Text("todos-b".into()), - Parameter::Null, - ], - ) - .expect("insert"); - - let rows = dactyl_db::query( - "select id, title, status from typed where id = $1", - &[Parameter::Integer(2)], - ) - .expect("read"); - assert_eq!(rows.len(), 1); - let row = &rows.as_slice()[0]; - let id: i64 = row.get("id").expect("id"); - let title: String = row.get("title").expect("title"); - assert_eq!(id, 2); - assert_eq!(title, "todos-b"); - - // nullable column deserializes to Option = None. - let status: Option = row.get("status").expect("status nullable"); - assert!(status.is_none()); - - // Missing column -> ColumnNotFound. - let missing: Result = row.get("missing_col"); - assert!( - matches!(missing, Err(DactylError::ColumnNotFound(_))), - "missing column must be ColumnNotFound" - ); - - // Conversion failure -> Conversion. - let bad_cast: Result = row.get("title"); - assert!( - matches!(bad_cast, Err(DactylError::Conversion(_))), - "type mismatch must be Conversion" - ); - - // try_get is an alias for get. - let id2: i64 = row.try_get("id").expect("try_get id"); - assert_eq!(id2, 2); - - // NULL on non-Option surfaces a Conversion that mentions NULL. - let null_int: Result = row.get("status"); - assert!( - matches!(null_int, Err(DactylError::Conversion(ref m)) if m.contains("NULL")), - "NULL into non-Option must mention NULL: {null_int:?}" - ); - - clear_env(); -} - -/// #25: full scalar / NULL conversion matrix through SQLite, including -/// duplicate-alias first-match and borrowed accessors. -#[test] -fn typed_projection_matrix_sqlite() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = tmp.path().join("matrix.db"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute( - "create table matrix ( - id integer primary key, - flag integer not null, - ratio real not null, - label text not null, - note text, - payload text not null - )", - &[], - ) - .expect("create"); - dactyl_db::execute( - "insert into matrix (id, flag, ratio, label, note, payload) values ($1, $2, $3, $4, $5, $6)", - &[ - Parameter::Integer(7), - Parameter::Bool(true), - Parameter::Real(2.25), - Parameter::Text("alpha".into()), - Parameter::Null, - Parameter::Text(r#"{"k":9}"#.into()), - ], - ) - .expect("insert"); - - let rows = dactyl_db::query( - "select id, flag, ratio, label, note, payload from matrix where id = $1", - &[Parameter::Integer(7)], - ) - .expect("select"); - let row = &rows.as_slice()[0]; - - assert_eq!(row.get_int("id").expect("id"), 7); - assert_eq!(row.get::<_, i64>("id").expect("id strict"), 7); - assert!(row.get_bool("flag").expect("flag"), "sqlite bool as 0/1"); - assert_eq!(row.get_real("ratio").expect("ratio"), 2.25); - assert_eq!(row.get_str("label").expect("label"), "alpha"); - assert_eq!(row.get_str_ref("label").expect("label ref"), "alpha"); - assert!(row.is_null("note").expect("note null")); - assert!(row - .get::<_, Option>("note") - .expect("note opt") - .is_none()); - // JSON/text: payload remains a string cell until the caller parses it. - assert_eq!(row.get_str("payload").expect("payload text"), r#"{"k":9}"#); - assert_eq!( - row.get_json_ref("payload").expect("payload json").as_str(), - Some(r#"{"k":9}"#) - ); - - // Duplicate aliases: first match wins; positional reaches the later one. - let alias_rows = dactyl_db::query( - "select id as name, label as name from matrix where id = $1", - &[Parameter::Integer(7)], - ) - .expect("alias select"); - let a = &alias_rows.as_slice()[0]; - assert_eq!(a.columns, vec!["name", "name"]); - assert_eq!(a.get_int("name").expect("first name is id"), 7); - assert_eq!(a.get_str(1usize).expect("second name is label"), "alpha"); - - // Missing + conversion still typed through the adapter path. - assert!(matches!( - a.get_str("missing"), - Err(DactylError::ColumnNotFound(_)) - )); - assert!(matches!( - a.get_bool("name"), - Err(DactylError::Conversion(_)) - )); - - clear_env(); -} - -/// #25: same projection matrix through the Neon adapter path (mock server). -/// Proves typed getters, NULL, missing columns, and conversion errors are -/// adapter-agnostic once cells are normalized to JSON. -#[test] -fn typed_projection_matrix_neon() { - let _guard = lock_env(); - let state = Arc::new(MockState::default()); - let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); - - let mock_thread = std::thread::spawn({ - let state = state.clone(); - move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("mock rt"); - rt.block_on(async { - { - let mut tables = state.tables.lock().await; - tables.insert( - "matrix".into(), - MockTable { - columns: vec![ - "id".into(), - "flag".into(), - "ratio".into(), - "label".into(), - "note".into(), - "payload".into(), - "name".into(), - "name".into(), - ], - rows: vec![serde_json::json!({ - "id": 7, - "flag": true, - "ratio": 2.25, - "label": "alpha", - "note": null, - "payload": {"k": 9}, - // JSON objects cannot hold two "name" keys; the - // columns list still carries the duplicate - // alias pair and both map to the same wire key. - "name": "first", - })], - }, - ); - } - let addr = spawn_mock(state.clone()).await; - let _ = ready_tx.send(format!("http://{addr}")); - let _ = done_rx.await; - }); - } - }); - - let endpoint = ready_rx.recv().expect("ready"); - select_neon(&endpoint); - - let rows = dactyl_db::query( - "select id, flag, ratio, label, note, payload, name from matrix", - &[], - ) - .expect("neon select"); - assert_eq!(rows.len(), 1); - let row = &rows.as_slice()[0]; - - assert_eq!(row.get_int("id").expect("id"), 7); - assert_eq!(row.try_get::<_, i64>("id").expect("try_get"), 7); - assert!(row.get_bool("flag").expect("flag")); - assert!(row.get::<_, bool>("flag").expect("strict bool")); - assert_eq!(row.get_real("ratio").expect("ratio"), 2.25); - assert_eq!(row.get_str("label").expect("label"), "alpha"); - assert_eq!(row.get_str_ref("label").expect("label ref"), "alpha"); - assert!(row.is_null("note").expect("note")); - assert!(row - .get::<_, Option>("note") - .expect("note opt") - .is_none()); - // Neon can surface structured JSON objects directly. - assert_eq!(row.get_json("payload").expect("payload")["k"], 9); - #[derive(serde::Deserialize)] - struct Payload { - k: i64, - } - assert_eq!(row.get::<_, Payload>("payload").expect("typed json").k, 9); - - // Duplicate alias names: first-match semantics still hold. - assert_eq!(row.columns.iter().filter(|c| *c == "name").count(), 2); - assert_eq!(row.get_str("name").expect("first name"), "first"); - assert_eq!( - row.get_str(row.columns.len() - 1).expect("last name col"), - "first", - "JSON wire loses distinct duplicate values; both aliases map to the key" - ); - - assert!(matches!( - row.get_int("missing"), - Err(DactylError::ColumnNotFound(_)) - )); - assert!(matches!( - row.get_int("label"), - Err(DactylError::Conversion(_)) - )); - assert!(matches!( - row.get::<_, i64>("note"), - Err(DactylError::Conversion(ref m)) if m.contains("NULL") - )); - - clear_env(); - let _ = done_tx.send(()); - let _ = mock_thread.join(); -} - -/// #25 + #2: cross-adapter equality of typed projections for the same logical -/// scalar row (integer, bool-as-portable, real, text, null). -#[test] -fn typed_projection_cross_adapter_parity() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = tmp.path().join("parity.db"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute( - "create table parity (id integer primary key, flag integer, ratio real, label text, note text)", - &[], - ) - .expect("create"); - dactyl_db::execute( - "insert into parity (id, flag, ratio, label, note) values ($1, $2, $3, $4, $5)", - &[ - Parameter::Integer(1), - Parameter::Bool(false), - Parameter::Real(0.5), - Parameter::Text("parity".into()), - Parameter::Null, - ], - ) - .expect("insert"); - - select_sqlite(path.to_str().unwrap()); - let sqlite_rows = dactyl_db::query( - "select id, flag, ratio, label, note from parity where id = $1", - &[Parameter::Integer(1)], - ) - .expect("sqlite"); - let s = &sqlite_rows.as_slice()[0]; - - let state = Arc::new(MockState::default()); - let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); - let mock_thread = std::thread::spawn({ - let state = state.clone(); - // Neon bool as true JSON bool; SQLite stores 0/1. Lenient get_bool - // unifies both. - let neon_row = serde_json::json!({ - "id": 1, - "flag": false, - "ratio": 0.5, - "label": "parity", - "note": null, + Json(json!({"affected_rows": 1, "rows": []})) + } +} + +fn with_server(test: impl FnOnce(String, RequestLog) + Send + 'static) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let state = MockState(requests.clone()); + let (shutdown, shutdown_rx) = oneshot::channel(); + let thread = std::thread::spawn(move || { + let runtime = Runtime::new().unwrap(); + runtime.block_on(async move { + let app = Router::new().route("/query", post(query)).with_state(state); + axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); }); - move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("mock rt"); - rt.block_on(async { - { - let mut tables = state.tables.lock().await; - tables.insert( - "parity".into(), - MockTable { - columns: vec![ - "id".into(), - "flag".into(), - "ratio".into(), - "label".into(), - "note".into(), - ], - rows: vec![neon_row], - }, - ); - } - let addr = spawn_mock(state.clone()).await; - let _ = ready_tx.send(format!("http://{addr}")); - let _ = done_rx.await; - }); - } }); - let endpoint = ready_rx.recv().expect("ready"); - select_neon(&endpoint); - let neon_rows = dactyl_db::query( - "select id, flag, ratio, label, note from parity where id = $1", - &[Parameter::Integer(1)], - ) - .expect("neon"); - let n = &neon_rows.as_slice()[0]; - assert_eq!(s.get_int("id").unwrap(), n.get_int("id").unwrap()); - assert_eq!(s.get_bool("flag").unwrap(), n.get_bool("flag").unwrap()); - assert_eq!(s.get_real("ratio").unwrap(), n.get_real("ratio").unwrap()); - assert_eq!(s.get_str("label").unwrap(), n.get_str("label").unwrap()); - assert_eq!(s.is_null("note").unwrap(), n.is_null("note").unwrap()); - assert_eq!( - s.get::<_, Option>("note").unwrap(), - n.get::<_, Option>("note").unwrap() - ); - - clear_env(); - let _ = done_tx.send(()); - let _ = mock_thread.join(); + test(format!("http://{address}"), requests); + let _ = shutdown.send(()); + thread.join().unwrap(); } -/// #24: atomic transaction batch commits on success and rolls back fully on -/// any per-statement failure (SQLite). No partial state remains. #[test] -fn atomic_transaction_rollback_on_failure() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = tmp.path().join("tx.db"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute("create table tx (id integer primary key, value text)", &[]) - .expect("create"); +fn neon_matches_the_application_read_write_shape() { + with_server(|endpoint, requests| { + let db = + Connection::open(DatastoreRoute::neon(endpoint, Some("test-token".into()))).unwrap(); + assert_eq!(db.datastore(), Datastore::Neon); - let stmts = vec![ - Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(1), Parameter::Text("val1".into())], - ), - Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(2), Parameter::Text("val2".into())], - ), - ]; - dactyl_db::transaction(&stmts).expect("tx success"); - - let rows = dactyl_db::query("select count(*) as cnt from tx", &[]).expect("count"); - let cnt: i64 = rows.as_slice()[0].get("cnt").expect("cnt"); - assert_eq!(cnt, 2); - - // Second batch contains a duplicate key -> must roll back the whole batch. - let failing = vec![ - Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(3), Parameter::Text("val3".into())], - ), - Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(1), Parameter::Text("duplicate".into())], - ), - ]; - let res = dactyl_db::transaction(&failing); - assert!(res.is_err(), "duplicate key must fail the batch"); - - let after = dactyl_db::query("select count(*) as cnt from tx", &[]).expect("count after"); - let cnt_after: i64 = after.as_slice()[0].get("cnt").expect("cnt"); - assert_eq!( - cnt_after, 2, - "row id=3 must NOT have been committed: full rollback" - ); - - // Empty batch is a successful no-op. - let empty = dactyl_db::transaction(&[]).expect("empty batch"); - assert!(empty.is_empty()); - - clear_env(); -} - -/// #24: Neon/mock failure-injection — a multi-statement `/batch` that fails -/// mid-way leaves no partial state (mirrors SQLite rollback proof). -#[test] -fn atomic_transaction_rollback_neon_mock() { - let _guard = lock_env(); - - let mut seed = HashMap::new(); - seed.insert( - "tx".into(), - MockTable { - columns: vec!["id".into(), "value".into()], - rows: vec![ - serde_json::json!({"id": 1, "value": "val1"}), - serde_json::json!({"id": 2, "value": "val2"}), - ], - }, - ); - - with_neon_mock(seed, |endpoint| { - select_neon(endpoint); - - // Successful batch appends id=3. - dactyl_db::transaction(&[Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(3), Parameter::Text("val3".into())], - )]) - .expect("neon batch success"); - - let rows = dactyl_db::query("select count(*) as cnt from tx", &[]).expect("count"); - let cnt: i64 = rows.as_slice()[0].get("cnt").expect("cnt"); - assert_eq!(cnt, 3, "successful batch must persist"); - - // Failing batch: first insert would add id=4, second hits duplicate id=1. - // The whole unit must abort — id=4 must not remain. - let res = dactyl_db::transaction(&[ - Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(4), Parameter::Text("val4".into())], - ), - Statement::new( - "insert into tx (id, value) values ($1, $2)", - vec![Parameter::Integer(1), Parameter::Text("duplicate".into())], - ), - ]); - assert!( - matches!(res, Err(DactylError::Adapter(_))), - "neon batch failure must be Adapter error: {res:?}" - ); - - let after = dactyl_db::query("select count(*) as cnt from tx", &[]).expect("count after"); - let cnt_after: i64 = after.as_slice()[0].get("cnt").expect("cnt"); assert_eq!( - cnt_after, 3, - "row id=4 must NOT have been committed on neon mock rollback" + db.write( + "update app set name = $1 where id = $2", + &[Parameter::Text("opened".into()), Parameter::Integer(1)], + ) + .unwrap(), + 1 ); - }); - - clear_env(); -} - -/// #24: event-plus-state atomicity fixture (SQLite). -/// -/// A domain-shaped unit of work updates durable state and appends an event -/// in one `transaction`. Mid-batch failure leaves neither side committed. -#[test] -fn atomic_event_plus_state_sqlite() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = tmp.path().join("event_state.db"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute( - "create table state (id integer primary key, value text not null)", - &[], - ) - .expect("create state"); - dactyl_db::execute( - "create table events ( - id integer primary key, - kind text not null, - payload text not null - )", - &[], - ) - .expect("create events"); - - // Success path: state row + event row commit together. - dactyl_db::transaction(&[ - Statement::new( - "insert into state (id, value) values ($1, $2)", - vec![Parameter::Integer(1), Parameter::Text("ready".into())], - ), - Statement::new( - "insert into events (id, kind, payload) values ($1, $2, $3)", - vec![ - Parameter::Integer(1), - Parameter::Text("state.changed".into()), - Parameter::Text(r#"{"id":1,"value":"ready"}"#.into()), - ], - ), - ]) - .expect("event+state success"); - - let state_cnt: i64 = dactyl_db::query("select count(*) as cnt from state", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - let event_cnt: i64 = dactyl_db::query("select count(*) as cnt from events", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - assert_eq!(state_cnt, 1); - assert_eq!(event_cnt, 1); - - // Failure path: state insert would succeed, event hits duplicate PK → both roll back. - let res = dactyl_db::transaction(&[ - Statement::new( - "insert into state (id, value) values ($1, $2)", - vec![Parameter::Integer(2), Parameter::Text("partial".into())], - ), - Statement::new( - "insert into events (id, kind, payload) values ($1, $2, $3)", - vec![ - Parameter::Integer(1), // duplicate event id - Parameter::Text("state.changed".into()), - Parameter::Text(r#"{"id":2}"#.into()), - ], - ), - ]); - assert!(res.is_err(), "duplicate event key must fail batch: {res:?}"); - - let state_after: i64 = dactyl_db::query("select count(*) as cnt from state", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - let event_after: i64 = dactyl_db::query("select count(*) as cnt from events", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - assert_eq!( - state_after, 1, - "state id=2 must not commit when event side fails" - ); - assert_eq!( - event_after, 1, - "events must stay at the successful batch only" - ); - - clear_env(); -} - -/// #24: event-plus-state atomicity fixture (Neon mock). -#[test] -fn atomic_event_plus_state_neon_mock() { - let _guard = lock_env(); - - let mut seed = HashMap::new(); - seed.insert( - "state".into(), - MockTable { - columns: vec!["id".into(), "value".into()], - rows: vec![], - }, - ); - seed.insert( - "events".into(), - MockTable { - columns: vec!["id".into(), "kind".into(), "payload".into()], - rows: vec![], - }, - ); - - with_neon_mock(seed, |endpoint| { - select_neon(endpoint); - - dactyl_db::transaction(&[ - Statement::new( - "insert into state (id, value) values ($1, $2)", - vec![Parameter::Integer(1), Parameter::Text("ready".into())], - ), - Statement::new( - "insert into events (id, kind, payload) values ($1, $2, $3)", - vec![ - Parameter::Integer(1), - Parameter::Text("state.changed".into()), - Parameter::Text(r#"{"id":1}"#.into()), - ], - ), - ]) - .expect("neon event+state success"); - - let state_cnt: i64 = dactyl_db::query("select count(*) as cnt from state", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - let event_cnt: i64 = dactyl_db::query("select count(*) as cnt from events", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") + let rows = db + .read("select id, name, enabled, payload from app", &[]) .unwrap(); - assert_eq!(state_cnt, 1); - assert_eq!(event_cnt, 1); - - let res = dactyl_db::transaction(&[ - Statement::new( - "insert into state (id, value) values ($1, $2)", - vec![Parameter::Integer(2), Parameter::Text("partial".into())], - ), - Statement::new( - "insert into events (id, kind, payload) values ($1, $2, $3)", - vec![ - Parameter::Integer(1), - Parameter::Text("state.changed".into()), - Parameter::Text(r#"{"id":2}"#.into()), - ], - ), - ]); - assert!( - matches!(res, Err(DactylError::Adapter(_))), - "neon event+state failure: {res:?}" + let row = &rows.as_slice()[0]; + assert_eq!(row.get_int("id").unwrap(), 1); + assert_eq!(row.get_str("name").unwrap(), "opened"); + assert!(row.get_bool("enabled").unwrap()); + assert_eq!(row.get_json("payload").unwrap(), json!([1, 2, 3])); + + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].0["sql"], + "update app set name = $1 where id = $2" ); - - let state_after: i64 = dactyl_db::query("select count(*) as cnt from state", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - let event_after: i64 = dactyl_db::query("select count(*) as cnt from events", &[]) - .unwrap() - .as_slice()[0] - .get("cnt") - .unwrap(); - assert_eq!(state_after, 1, "no partial state on neon mock"); - assert_eq!(event_after, 1, "no partial events on neon mock"); + assert_eq!(requests[0].0["params"], json!(["opened", 1])); + assert_eq!(requests[0].1.as_deref(), Some("Bearer test-token")); + assert_eq!(requests[1].0["params"], json!([])); }); - - clear_env(); -} - -/// #27: dactyl never silently bootstraps schema. Querying a table that was -/// not created by the caller must surface the adapter's "no such table" -/// error, not succeed with fabricated rows. -#[test] -fn no_silent_schema_bootstrap() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = empty_sqlite(&tmp, "unbootstrapped"); - select_sqlite(path.to_str().unwrap()); - - let res = dactyl_db::query("select id, title from todos", &[]); - assert!( - matches!(res, Err(DactylError::Adapter(ref e)) if e.contains("no such table")), - "expected adapter 'no such table' error, got {res:?}" - ); - - // And the directory still contains no Decapod-style tables dactyl might - // have fabricated on open. - use rusqlite::Connection; - let conn = Connection::open(&path).expect("open"); - let mut stmt = conn - .prepare("select count(*) from sqlite_master where type='table' and name='todos'") - .expect("prepare"); - let count: i64 = stmt.query_row([], |r| r.get(0)).expect("count"); - assert_eq!(count, 0, "dactyl must not have created the 'todos' table"); - - clear_env(); -} - -/// #26 + #27: caller-owned schema flow. A fresh empty database, then the -/// caller creates an index, an audit table, and a trigger, then upgrades -/// schema via dactyl. Everything goes through `execute`; dactyl is purely a -/// vehicle and never mutates undeclared schema. -#[test] -fn caller_owned_schema_ddl_migration() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = empty_sqlite(&tmp, "caller_owned"); - select_sqlite(path.to_str().unwrap()); - - dactyl_db::execute( - "create table app (id integer primary key, name text not null unique)", - &[], - ) - .expect("create table"); - dactyl_db::execute( - "create table app_audit (id integer primary key, app_id integer, name text)", - &[], - ) - .expect("create audit table"); - dactyl_db::execute("create index app_name_idx on app(name)", &[]).expect("create index"); - dactyl_db::execute( - "create trigger app_audit after insert on app begin - insert into app_audit(app_id, name) values (new.id, 'audit-' || new.name); - end", - &[], - ) - .expect("create trigger"); - - dactyl_db::execute( - "insert into app (id, name) values ($1, $2)", - &[Parameter::Integer(1), Parameter::Text("alpha".into())], - ) - .expect("insert"); - - let rows = dactyl_db::query("select name from app order by id", &[]).expect("select app"); - let names: Vec = rows - .iter() - .map(|r| r.get::<_, String>("name").expect("name")) - .collect(); - assert_eq!(names, vec!["alpha".to_string()]); - - let audit = dactyl_db::query("select app_id, name from app_audit order by id", &[]) - .expect("select audit"); - let audit_rows: Vec<(i64, String)> = audit - .iter() - .map(|r| { - ( - r.get::<_, i64>("app_id").expect("app_id"), - r.get::<_, String>("name").expect("name"), - ) - }) - .collect(); - assert_eq!( - audit_rows, - vec![(1, "audit-alpha".to_string())], - "trigger wrote exactly one audit row" - ); - - // Migration-style schema upgrade via dactyl execute. - dactyl_db::execute("alter table app add column status text default 'open'", &[]) - .expect("alter table"); - let cols = dactyl_db::query("pragma table_info(app)", &[]).expect("pragma after migration"); - let has_status = cols.iter().any(|r| { - r.get::<_, String>("name") - .ok() - .map(|n| n == "status") - .unwrap_or(false) - }); - assert!(has_status, "migration should have added the status column"); - - clear_env(); -} - -/// #26: session isolation. Because dactyl builds a fresh adapter per call and -/// caches nothing, two concurrent selections against two distinct SQLite files -/// must not bleed into each other. -#[test] -fn session_isolation_across_distinct_databases() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let left = tmp.path().join("left.db"); - let right = tmp.path().join("right.db"); - - select_sqlite(left.to_str().unwrap()); - dactyl_db::execute( - "create table t (id integer primary key, origin text not null)", - &[], - ) - .expect("create left"); - dactyl_db::execute( - "insert into t (id, origin) values ($1, $2)", - &[Parameter::Integer(1), Parameter::Text("left".into())], - ) - .expect("seed left"); - - select_sqlite(right.to_str().unwrap()); - dactyl_db::execute( - "create table t (id integer primary key, origin text not null)", - &[], - ) - .expect("create right"); - dactyl_db::execute( - "insert into t (id, origin) values ($1, $2)", - &[Parameter::Integer(1), Parameter::Text("right".into())], - ) - .expect("seed right"); - - // Switch back to left and assert we see left's row, not right's. - select_sqlite(left.to_str().unwrap()); - let rows = dactyl_db::query( - "select origin from t where id = $1", - &[Parameter::Integer(1)], - ) - .expect("read left"); - let origin: String = rows.as_slice()[0].get("origin").expect("origin"); - assert_eq!(origin, "left"); - - // Same for right. - select_sqlite(right.to_str().unwrap()); - let rows = dactyl_db::query( - "select origin from t where id = $1", - &[Parameter::Integer(1)], - ) - .expect("read right"); - let origin: String = rows.as_slice()[0].get("origin").expect("origin"); - assert_eq!(origin, "right"); - - clear_env(); -} - -/// `query!` macro still returns the analyzed SQL string and composes with the -/// runtime `query` entry point. -#[test] -fn query_macro_composes_with_runtime() { - let _guard = lock_env(); - let tmp = TempDir::new().expect("tempdir"); - let path = sqlite_path(&tmp, "macro"); - seed_sqlite(&path, "macro", &seed_rows("macro")); - select_sqlite(path.to_str().unwrap()); - - let sql: String = dactyl_db::query!("select id, title, status from macro"); - assert_eq!(sql, "select id, title, status from macro"); - let rows = dactyl_db::query(&sql, &[]).expect("read"); - assert_eq!(rows.len(), 2); - - clear_env(); -} - -/// Env-var validation: missing `DATASTORE` and an unknown value both produce -/// typed errors before any adapter is constructed. -#[test] -fn env_validation_errors_are_typed() { - let _guard = lock_env(); - - clear_env(); - let res = dactyl_db::query("select 1", &[]); - assert!( - matches!(res, Err(DactylError::Adapter(ref e)) if e.contains("DATASTORE is not set")), - "missing DATASTORE: {res:?}" - ); - - unsafe { - std::env::set_var("DATASTORE", "redis"); - } - let res = dactyl_db::query("select 1", &[]); - assert!( - matches!(res, Err(DactylError::Adapter(ref e)) if e.contains("invalid DATASTORE")), - "unknown DATASTORE: {res:?}" - ); - - unsafe { - std::env::set_var("DATASTORE", "sqlite"); - std::env::remove_var("DATASTORE_ROUTE"); - } - let res = dactyl_db::query("select 1", &[]); - assert!( - matches!(res, Err(DactylError::Adapter(ref e)) if e.contains("DATASTORE_ROUTE is not set")), - "missing route: {res:?}" - ); - - clear_env(); } diff --git a/tests/release_configuration.rs b/tests/release_configuration.rs index 62ca06a..ea25e2c 100644 --- a/tests/release_configuration.rs +++ b/tests/release_configuration.rs @@ -1,17 +1,14 @@ const RELEASE_CONFIG: &str = include_str!("../.github/release.toml"); const DATABASE_MANIFEST: &str = include_str!("../Cargo.toml"); -const MACROS_MANIFEST: &str = include_str!("../dactyl-db-macros/Cargo.toml"); fn workspace_setting(name: &str) -> &str { let mut in_workspace = false; - for line in RELEASE_CONFIG.lines() { let line = line.trim(); if line.starts_with('[') { in_workspace = line == "[workspace]"; continue; } - if in_workspace { if let Some((key, value)) = line.split_once('=') { if key.trim() == name { @@ -20,41 +17,11 @@ fn workspace_setting(name: &str) -> &str { } } } - - panic!("missing [workspace].{name} in .github/release.toml"); -} - -fn package_setting<'a>(package: &str, name: &str) -> &'a str { - let mut in_package = false; - let mut selected_package = false; - - for line in RELEASE_CONFIG.lines() { - let line = line.trim(); - if line.starts_with('[') { - in_package = line == "[[package]]"; - selected_package = false; - continue; - } - - if in_package { - if let Some((key, value)) = line.split_once('=') { - let key = key.trim(); - let value = value.trim(); - if key == "name" { - selected_package = value.trim_matches('"') == package; - } else if selected_package && key == name { - return value; - } - } - } - } - - panic!("missing [[package]] {package:?} setting {name:?} in .github/release.toml"); + panic!("missing [workspace].{name}"); } fn manifest_package_version(manifest: &str) -> &str { let mut in_package = false; - for line in manifest.lines() { let line = line.trim(); if line.starts_with('[') { @@ -69,7 +36,6 @@ fn manifest_package_version(manifest: &str) -> &str { } } } - panic!("missing [package].version"); } @@ -81,54 +47,13 @@ fn ordinary_main_pushes_do_not_publish_packages() { #[test] fn release_commits_cannot_recursively_prepare_another_release() { let filter = workspace_setting("release_commits"); - - assert_eq!(filter, r"'^(feat|fix)(\([^)]*\))?!?:'"); - assert!(!filter.contains("chore")); - assert!(filter.contains("feat|fix")); -} - -#[test] -fn workspace_packages_render_distinct_tags_at_the_same_version() { - let template = workspace_setting("git_tag_name").trim_matches('"'); - assert_eq!(template, "{{ package }}-v{{ version }}"); - - let render = |package: &str, version: &str| { - template - .replace("{{ package }}", package) - .replace("{{ version }}", version) - }; - - let database_tag = render("dactyl-db", "0.2.2"); - let macros_tag = render("dactyl-db-macros", "0.2.2"); - assert_eq!(database_tag, "dactyl-db-v0.2.2"); - assert_eq!(macros_tag, "dactyl-db-macros-v0.2.2"); - assert_ne!(database_tag, macros_tag); -} - -#[test] -fn workspace_package_manifests_and_dependency_are_aligned() { - let database_version = manifest_package_version(DATABASE_MANIFEST); - let macros_version = manifest_package_version(MACROS_MANIFEST); - - assert_eq!(database_version, macros_version); - assert!(DATABASE_MANIFEST.contains(&format!( - r#"dactyl-db-macros = {{ path = "dactyl-db-macros", version = "{macros_version}" }}"# - ))); + assert_eq!(filter, r#"'^(feat|fix)(\([^)]*\))?!?:'"#); } #[test] -fn release_worthy_changes_select_and_group_both_packages() { - assert_eq!(package_setting("dactyl-db", "version_group"), r#""dactyl""#); - assert_eq!( - package_setting("dactyl-db-macros", "version_group"), - r#""dactyl""# - ); - assert_eq!( - package_setting("dactyl-db", "changelog_include"), - r#"["dactyl-db-macros"]"# - ); - assert_eq!( - package_setting("dactyl-db-macros", "changelog_include"), - r#"["dactyl-db"]"# - ); +fn package_metadata_has_the_application_driver_tags() { + assert_eq!(manifest_package_version(DATABASE_MANIFEST), "0.3.0"); + assert!(DATABASE_MANIFEST + .contains("keywords = [\"datastore\", \"vercel\", \"postgres\", \"sql\", \"sqlite\"]")); + assert!(!DATABASE_MANIFEST.contains("#decapod")); } From 9d1440c112e98ffa761e740fbb86d40860be3d30 Mon Sep 17 00:00:00 2001 From: "Alex H. Raber" Date: Thu, 6 Aug 2026 01:27:49 -0700 Subject: [PATCH 2/3] feat: make dactyl a thin application read write driver --- .decapod/governance/trajectory.json | 30 ++++++++++- .decapod/governance/validation.json | 84 ++++++++++++++--------------- 2 files changed, 69 insertions(+), 45 deletions(-) diff --git a/.decapod/governance/trajectory.json b/.decapod/governance/trajectory.json index 99d14be..9a08a3a 100644 --- a/.decapod/governance/trajectory.json +++ b/.decapod/governance/trajectory.json @@ -45,7 +45,7 @@ "shortcut_risk": "supported", "completion_proof": "supported" }, - "artifact_hash": "sha256:d977cc6ed5f7674ab4b1a685a0227391eaa712d0d53723aa9c4f29dc057d7217", + "artifact_hash": "sha256:29be254e0d8124eeed6b0522b83978dc186078370eae86ac9e05c9d22bfa8413", "custody": { "schema_version": "1.0.0", "intents": { @@ -102,6 +102,13 @@ "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", "kind": "trajectory_step_recorded", "detail": "trajectory:validation_01KZB2S1AFQ708TDRS6BAR17TH" + }, + { + "sequence": 7, + "event_id": "event:7", + "intent_id": "intent:validation_01KZB2S1AFQ708TDRS6BAR17TH", + "kind": "trajectory_step_recorded", + "detail": "trajectory:validation_01KZB2S1AFQ708TDRS6BAR17TH" } ], "trajectories": { @@ -166,12 +173,31 @@ ], "validation_findings": [], "custody_event_id": "event:6" + }, + { + "sequence": 7, + "action": "validation", + "command": "decapod validate", + "scope": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json" + ], + "observations": [ + ".decapod/governance/trajectory.json", + ".decapod/governance/validation.json", + "validation epoch ve_df73bc67cd796539 completed with zero failures" + ], + "proof_refs": [ + "decapod validate" + ], + "validation_findings": [], + "custody_event_id": "event:7" } ] } }, "summaries": {}, "knowledge_candidates": [], - "next_sequence": 6 + "next_sequence": 7 } } \ No newline at end of file diff --git a/.decapod/governance/validation.json b/.decapod/governance/validation.json index 58e7919..4988ec3 100644 --- a/.decapod/governance/validation.json +++ b/.decapod/governance/validation.json @@ -2,10 +2,10 @@ "schema_version": "1.0.0", "kind": "validation_receipt", "decapod_release": "0.96.13", - "git_revision": "71e468e720e32cdc0a349837b6d47d6ac0ee62b3", + "git_revision": "f625af5c5ab11be9325f1715e33b08bf1b26e5c5", "repo_signal_fingerprint": "4c9f2d54af60b251796edfdb274cd05721ccdafbc0314c2c80ed31bf68cf141b", "trajectory_run_id": "validation_01KZB2S1AFQ708TDRS6BAR17TH", - "trajectory_artifact_hash": "sha256:d977cc6ed5f7674ab4b1a685a0227391eaa712d0d53723aa9c4f29dc057d7217", + "trajectory_artifact_hash": "sha256:29be254e0d8124eeed6b0522b83978dc186078370eae86ac9e05c9d22bfa8413", "validation_epoch": { "schema_version": "1.0.0", "epoch_id": "ve_df73bc67cd796539", @@ -44,56 +44,55 @@ } }, "status": "ok", - "pass_count": 182, + "pass_count": 183, "fail_count": 0, - "warn_count": 4, - "elapsed_ms": 1271, + "warn_count": 3, + "elapsed_ms": 3996, "drift_findings": [], "temporary_artifacts_cleaned": 0, "failures": [], "warnings": [ - "Failed to prune stale Decapod images: Validation error: Failed to list container images: Failed to obtain podman configuration: set sticky bit on: chmod /run/user/1000/libpod: read-only file system", "Risk map missing (run `decapod riskmap init`)", "Watcher audit trail missing (run `decapod govern watcher run`)", "Workspace branch 'agent/unknown/code-01kzb14pzzgekyfs' has unpushed commits or does not exist on origin. Run `git push origin agent/unknown/code-01kzb14pzzgekyfs`." ], "gate_timings": [ { - "name": "validate_machine_contract", - "elapsed_ms": 501 - }, - { - "name": "validate_markdown_primitives_roundtrip_gate", - "elapsed_ms": 262 + "name": "validate_stale_workspaces", + "elapsed_ms": 1936 }, { - "name": "validate_federation_gates", - "elapsed_ms": 92 + "name": "validate_machine_contract", + "elapsed_ms": 1189 }, { - "name": "validate_project_specs_docs", - "elapsed_ms": 89 + "name": "validate_control_plane_contract", + "elapsed_ms": 254 }, { - "name": "validate_stale_workspaces", - "elapsed_ms": 63 + "name": "validate_markdown_primitives_roundtrip_gate", + "elapsed_ms": 247 }, { - "name": "validate_control_plane_contract", - "elapsed_ms": 42 + "name": "validate_project_specs_docs", + "elapsed_ms": 85 }, { - "name": "validate_obligations", - "elapsed_ms": 41 + "name": "validate_federation_gates", + "elapsed_ms": 76 }, { "name": "validate_git_workspace_context", - "elapsed_ms": 40 + "elapsed_ms": 35 }, { "name": "validate_schema_determinism", "elapsed_ms": 33 }, + { + "name": "validate_obligations", + "elapsed_ms": 31 + }, { "name": "validate_archive_integrity", "elapsed_ms": 10 @@ -138,16 +137,12 @@ "name": "validate_repomap_determinism", "elapsed_ms": 1 }, - { - "name": "validate_watcher_audit", - "elapsed_ms": 1 - }, { "name": "validate_lcm_immutability", "elapsed_ms": 1 }, { - "name": "validate_lcm_rebuild_gate", + "name": "validate_watcher_audit", "elapsed_ms": 1 }, { @@ -158,6 +153,10 @@ "name": "validate_health_cache_integrity", "elapsed_ms": 1 }, + { + "name": "validate_lcm_rebuild_gate", + "elapsed_ms": 1 + }, { "name": "validate_plan_governed_execution_gate", "elapsed_ms": 0 @@ -167,11 +166,11 @@ "elapsed_ms": 0 }, { - "name": "validate_no_legacy_namespaces", + "name": "validate_health_purity", "elapsed_ms": 0 }, { - "name": "validate_health_purity", + "name": "validate_no_legacy_namespaces", "elapsed_ms": 0 }, { @@ -191,11 +190,11 @@ "elapsed_ms": 0 }, { - "name": "validate_heartbeat_invocation_gate", + "name": "validate_risk_map_violations", "elapsed_ms": 0 }, { - "name": "validate_risk_map_violations", + "name": "validate_heartbeat_invocation_gate", "elapsed_ms": 0 }, { @@ -222,16 +221,12 @@ "name": "validate_spec_drift", "elapsed_ms": 0 }, - { - "name": "validate_context_capsule_policy_contract", - "elapsed_ms": 0 - }, { "name": "validate_docs_templates_bucket", "elapsed_ms": 0 }, { - "name": "validate_workunit_manifests_if_present", + "name": "validate_state_commit_gate", "elapsed_ms": 0 }, { @@ -239,11 +234,11 @@ "elapsed_ms": 0 }, { - "name": "validate_embedded_self_contained", + "name": "validate_context_capsule_policy_contract", "elapsed_ms": 0 }, { - "name": "validate_state_commit_gate", + "name": "validate_workunit_manifests_if_present", "elapsed_ms": 0 }, { @@ -259,11 +254,11 @@ "elapsed_ms": 0 }, { - "name": "validate_recursive_improvement_passes_if_present", + "name": "validate_context_capsules_if_present", "elapsed_ms": 0 }, { - "name": "validate_context_capsules_if_present", + "name": "validate_recursive_improvement_passes_if_present", "elapsed_ms": 0 }, { @@ -274,6 +269,10 @@ "name": "validate_interface_contract_bootstrap", "elapsed_ms": 0 }, + { + "name": "validate_embedded_self_contained", + "elapsed_ms": 0 + }, { "name": "validate_tooling_gate", "elapsed_ms": 0 @@ -284,7 +283,6 @@ "result": "review", "confidence": "medium", "reasons": [ - "Failed to prune stale Decapod images: Validation error: Failed to list container images: Failed to obtain podman configuration: set sticky bit on: chmod /run/user/1000/libpod: read-only file system", "Risk map missing (run `decapod riskmap init`)", "Watcher audit trail missing (run `decapod govern watcher run`)", "Workspace branch 'agent/unknown/code-01kzb14pzzgekyfs' has unpushed commits or does not exist on origin. Run `git push origin agent/unknown/code-01kzb14pzzgekyfs`." @@ -294,5 +292,5 @@ "Use `decapod validate -v --format json` to inspect the affected gate output." ] }, - "receipt_hash": "sha256:8c5edb99eb1a9ddf1c00f6a32e3eacab793a285742846cec91e53b6865cb4138" + "receipt_hash": "sha256:4d601a81e72299a5548ae57fae7c7afdabe34635a8a213e384d1911fcc0e0dd5" } \ No newline at end of file From f49e4501064c1c8c1708bf041cd3ea39c267bcb5 Mon Sep 17 00:00:00 2001 From: "Alex H. Raber" Date: Thu, 6 Aug 2026 01:31:12 -0700 Subject: [PATCH 3/3] Rename project to 'dactyl' and add Decapod badge Updated project name and added a badge for Decapod. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 641c553..112d673 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# dactyl-db +# dactyl + +[![🦀 Decapod](https://img.shields.io/badge/🦀%20Decapod-v0.96.12-dc2626)](https://github.com/DecapodLabs/decapod) `dactyl-db` is the application-layer database provider for read/write-heavy apps that need the same small Rust surface over local SQLite and Vercel Neon.