ecoportal-api-graphql 1.3.13 → 1.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -0
- data/lib/ecoportal/api/common/graphql/client.rb +0 -2
- data/lib/ecoportal/api/common/graphql/http_client.rb +6 -0
- data/lib/ecoportal/api/graphql/base/ai_summary_version.rb +17 -17
- data/lib/ecoportal/api/graphql/base/file_container.rb +14 -14
- data/lib/ecoportal/api/graphql/base/location_classification_type.rb +15 -15
- data/lib/ecoportal/api/graphql/base/location_structure.rb +27 -27
- data/lib/ecoportal/api/graphql/base/organization.rb +15 -15
- data/lib/ecoportal/api/graphql/base/page/task.rb +24 -0
- data/lib/ecoportal/api/graphql/base/page.rb +17 -16
- data/lib/ecoportal/api/graphql/base/template.rb +35 -0
- data/lib/ecoportal/api/graphql/base.rb +37 -35
- data/lib/ecoportal/api/graphql/builder/contractor_entity.rb +41 -41
- data/lib/ecoportal/api/graphql/compat/response.rb +35 -35
- data/lib/ecoportal/api/graphql/connection/action.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/action_category.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/contractor_entity.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/page.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/person_member.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/preview_page.rb +11 -11
- data/lib/ecoportal/api/graphql/connection.rb +17 -17
- data/lib/ecoportal/api/graphql/file_upload/client.rb +21 -10
- data/lib/ecoportal/api/graphql/fragment/field_configuration.rb +38 -0
- data/lib/ecoportal/api/graphql/fragment/force.rb +14 -10
- data/lib/ecoportal/api/graphql/fragment/location_node.rb +26 -26
- data/lib/ecoportal/api/graphql/fragment/page_task.rb +39 -0
- data/lib/ecoportal/api/graphql/fragment/template.rb +51 -0
- data/lib/ecoportal/api/graphql/fragment.rb +3 -0
- data/lib/ecoportal/api/graphql/input/contractor_entity.rb +14 -14
- data/lib/ecoportal/api/graphql/input/preset_view/update.rb +16 -16
- data/lib/ecoportal/api/graphql/input/workflow_command/add_stage.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_field_configuration.rb +21 -21
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_page.rb +28 -28
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_page_creator_permissions.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/cross_reference.rb +23 -23
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/date.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/image_gallery.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/people.rb +24 -24
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/plain_text.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/rich_text.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/select.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/move_field.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/reorder_forces.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/reorder_section.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command.rb +251 -251
- data/lib/ecoportal/api/graphql/input.rb +24 -24
- data/lib/ecoportal/api/graphql/logic/connection.rb +34 -34
- data/lib/ecoportal/api/graphql/model/page/task.rb +16 -0
- data/lib/ecoportal/api/graphql/model/page.rb +16 -15
- data/lib/ecoportal/api/graphql/model/person_member.rb +15 -15
- data/lib/ecoportal/api/graphql/model/template.rb +15 -0
- data/lib/ecoportal/api/graphql/model.rb +32 -30
- data/lib/ecoportal/api/graphql/mutation/ai_summary/generate.rb +45 -45
- data/lib/ecoportal/api/graphql/mutation/ai_summary/submit_feedback.rb +40 -40
- data/lib/ecoportal/api/graphql/mutation/contractor_entity.rb +14 -14
- data/lib/ecoportal/api/graphql/mutation/page/approve_review_task.rb +40 -40
- data/lib/ecoportal/api/graphql/mutation/page/batch_update_review_task.rb +38 -40
- data/lib/ecoportal/api/graphql/mutation/page/create_draft.rb +38 -40
- data/lib/ecoportal/api/graphql/mutation/page/delete_draft.rb +38 -40
- data/lib/ecoportal/api/graphql/mutation/page/execute_force_commands.rb +7 -7
- data/lib/ecoportal/api/graphql/mutation/page/execute_workflow_commands.rb +8 -7
- data/lib/ecoportal/api/graphql/mutation/page/publish_draft.rb +38 -40
- data/lib/ecoportal/api/graphql/mutation/page/reject_review_task.rb +40 -40
- data/lib/ecoportal/api/graphql/mutation/page/restart_review_task.rb +40 -40
- data/lib/ecoportal/api/graphql/mutation/page/undo_review_task.rb +40 -40
- data/lib/ecoportal/api/graphql/mutation/preset_view/destroy.rb +34 -35
- data/lib/ecoportal/api/graphql/mutation/register/destroy.rb +35 -35
- data/lib/ecoportal/api/graphql/mutation/smart_fill/generate.rb +36 -36
- data/lib/ecoportal/api/graphql/mutation/smart_fill/submit_feedback.rb +40 -40
- data/lib/ecoportal/api/graphql/mutation/smart_fill.rb +13 -13
- data/lib/ecoportal/api/graphql/mutation/template/create_related_page.rb +46 -46
- data/lib/ecoportal/api/graphql/mutation/template/destroy_related_page.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/template/update_information.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation.rb +20 -20
- data/lib/ecoportal/api/graphql/payload/contractor_entity.rb +14 -14
- data/lib/ecoportal/api/graphql/payload/ok_payload.rb +36 -21
- data/lib/ecoportal/api/graphql/payload/page/draft.rb +26 -13
- data/lib/ecoportal/api/graphql/payload/page/review_task.rb +13 -13
- data/lib/ecoportal/api/graphql/payload/page/review_task_batch.rb +23 -0
- data/lib/ecoportal/api/graphql/payload/page.rb +20 -19
- data/lib/ecoportal/api/graphql/payload/preset_view.rb +15 -11
- data/lib/ecoportal/api/graphql/payload/register.rb +15 -11
- data/lib/ecoportal/api/graphql/payload/template/create_related_page.rb +1 -1
- data/lib/ecoportal/api/graphql/payload/template/destroy_related_page.rb +1 -1
- data/lib/ecoportal/api/graphql/payload/template/update_information.rb +1 -1
- data/lib/ecoportal/api/graphql/payload.rb +23 -23
- data/lib/ecoportal/api/graphql/query/location_structures.rb +57 -57
- data/lib/ecoportal/api/graphql/query/page.rb +45 -45
- data/lib/ecoportal/api/graphql/query/page_with_forces.rb +9 -3
- data/lib/ecoportal/api/graphql/query/pages_workflow_commands.rb +9 -3
- data/lib/ecoportal/api/graphql/query/register_preset_views.rb +78 -78
- data/lib/ecoportal/api/graphql/query.rb +27 -27
- data/lib/ecoportal/api/graphql.rb +9 -5
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +9 -180
- data/.ai-assistance/bridge/CLAUDE.md +0 -338
- data/.ai-assistance/bridge/archive/.gitkeep +0 -0
- data/.ai-assistance/bridge/archive/oscar-a1b2c3d-gitlab-mcp-doc-update.inbox.md +0 -29
- data/.ai-assistance/bridge/archive/oscar-a1b2c3d-gitlab-mcp-doc-update.outbox.md +0 -18
- data/.ai-assistance/bridge/archive/oscar-c912c25-gemini-design-review.inbox.md +0 -42
- data/.ai-assistance/bridge/archive/oscar-c912c25-gemini-design-review.outbox.md +0 -115
- data/.ai-assistance/bridge/context/gemini-review-prompt.txt +0 -48
- data/.ai-assistance/bridge/context/gemini-review-response.md +0 -104
- data/.ai-assistance/bridge/context/project.md +0 -42
- data/.ai-assistance/bridge/inbox/.gitkeep +0 -0
- data/.ai-assistance/bridge/outbox/.gitkeep +0 -0
- data/.ai-assistance/bridge/outbox/request-for-standards-discovery.md +0 -48
- data/.ai-assistance/bridge/queue/.gitkeep +0 -1
- data/.ai-assistance/capabilities/CLAUDE.md +0 -27
- data/.ai-assistance/capabilities/assumptions-log.md +0 -80
- data/.ai-assistance/capabilities/code.md +0 -47
- data/.ai-assistance/capabilities/connectors.md +0 -37
- data/.ai-assistance/capabilities/cowork.md +0 -55
- data/.ai-assistance/code/OVERVIEW.md +0 -155
- data/.ai-assistance/code/data_fields.md +0 -242
- data/.ai-assistance/code/dependencies.md +0 -151
- data/.ai-assistance/code/diff_as_input.md +0 -234
- data/.ai-assistance/code/diff_pairing_engine.md +0 -243
- data/.ai-assistance/code/diff_service_deep_dive.md +0 -192
- data/.ai-assistance/code/ecoPortal_architecture/00_overview_and_index.md +0 -55
- data/.ai-assistance/code/ecoPortal_architecture/01_terminology_dictionary.md +0 -181
- data/.ai-assistance/code/ecoPortal_architecture/02_data_model.md +0 -192
- data/.ai-assistance/code/ecoPortal_architecture/03_api_layers.md +0 -147
- data/.ai-assistance/code/ecoPortal_architecture/04_graphql_queries_mutations.md +0 -277
- data/.ai-assistance/code/ecoPortal_architecture/05_page_workflows.md +0 -200
- data/.ai-assistance/code/ecoPortal_architecture/06_search_and_filters.md +0 -228
- data/.ai-assistance/code/ecoPortal_architecture/07_data_fields.md +0 -197
- data/.ai-assistance/code/ecoPortal_architecture/08_stages_sections.md +0 -243
- data/.ai-assistance/code/ecoPortal_architecture/09_people_contractors_locations.md +0 -196
- data/.ai-assistance/code/ecoPortal_architecture/10_forces_workflow_builder.md +0 -132
- data/.ai-assistance/code/ecoPortal_architecture/11_integration_gems.md +0 -187
- data/.ai-assistance/code/ecoPortal_architecture/12_ai_documentation_sources_gaps.md +0 -236
- data/.ai-assistance/code/ecoPortal_architecture/13_ai_infrastructure.md +0 -183
- data/.ai-assistance/code/ecoportal_schema_reference.md +0 -240
- data/.ai-assistance/code/filter_contract_matrix.md +0 -177
- data/.ai-assistance/code/graphql_domain_knowledge.md +0 -240
- data/.ai-assistance/code/refactoring/datafield-readwrite-shape-asymmetry.md +0 -71
- data/.ai-assistance/code/refactoring/opportunities.md +0 -251
- data/.ai-assistance/code/schema_analysis.md +0 -321
- data/.ai-assistance/code/search_filters.md +0 -868
- data/.ai-assistance/code/spec_coverage.md +0 -73
- data/.ai-assistance/code/template_diff_pairing_domain.md +0 -175
- data/.ai-assistance/code/workflow-command-guide.md +0 -466
- data/.ai-assistance/code/workflow-space.md +0 -353
- data/.ai-assistance/conventions/CLAUDE.md +0 -30
- data/.ai-assistance/conventions/code-working-tree-protocol.md +0 -199
- data/.ai-assistance/conventions/gitignore-rules.md +0 -42
- data/.ai-assistance/conventions/permission-guidance.md +0 -120
- data/.ai-assistance/integrations/README.md +0 -70
- data/.ai-assistance/integrations/gitkraken-mcp.md +0 -107
- data/.ai-assistance/integrations/gitlab-mcp.md +0 -123
- data/.ai-assistance/integrations/local-git.md +0 -60
- data/.ai-assistance/local_paths.example.md +0 -17
- data/.ai-assistance/projects/TODO.md +0 -118
- data/.ai-assistance/projects/api-v2-to-graphql-migration/DECISIONS.md +0 -168
- data/.ai-assistance/projects/api-v2-to-graphql-migration/INTENT.md +0 -60
- data/.ai-assistance/projects/api-v2-to-graphql-migration/TODO.md +0 -267
- data/.ai-assistance/projects/api-v2-to-graphql-migration/UPSTREAM.md +0 -53
- data/.ai-assistance/projects/api-v2-to-graphql-migration/notes/csv-template-pipeline-design.md +0 -102
- data/.ai-assistance/projects/api-v2-to-graphql-migration/notes/cutover-usecase-gap-audit.md +0 -139
- data/.ai-assistance/projects/compat-layer-audit/COMPAT_AUDIT.md +0 -244
- data/.ai-assistance/projects/dynamic-model-generation/INTENT.md +0 -93
- data/.ai-assistance/projects/eco-helpers-compat/INTENT.md +0 -244
- data/.ai-assistance/projects/eco-helpers-compat/MIGRATION_GUIDE.md +0 -266
- data/.ai-assistance/projects/eco-helpers-compat/TODO.md +0 -86
- data/.ai-assistance/projects/ecoportal-api-v2-doublemodel-review/INTENT.md +0 -101
- data/.ai-assistance/projects/graphql-agent/GAP_ANALYSIS.md +0 -177
- data/.ai-assistance/projects/ooze-graphql-native-migration/DECISIONS.md +0 -161
- data/.ai-assistance/projects/ooze-graphql-native-migration/INTENT.md +0 -125
- data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +0 -136
- data/.ai-assistance/projects/ooze-graphql-native-migration/RISKS.md +0 -126
- data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +0 -261
- data/.ai-assistance/projects/ooze-graphql-native-migration/analysis/2026-06-30-cutover-workflow-deep-review.md +0 -122
- data/.ai-assistance/projects/ooze-graphql-native-migration/analysis/2026-07-01-forces-via-workflow-commands-miss-rca.md +0 -148
- data/.ai-assistance/projects/page-model/DECISIONS.md +0 -245
- data/.ai-assistance/projects/page-model/TODO.md +0 -190
- data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +0 -93
- data/.ai-assistance/projects/qa-services-delivery/INTENT.md +0 -76
- data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +0 -115
- data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +0 -99
- data/.ai-assistance/projects/qa-services-delivery/TODO.md +0 -81
- data/.ai-assistance/projects/search-filter-builder/INTENT.md +0 -107
- data/.ai-assistance/projects/search-filter-builder/TODO.md +0 -131
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +0 -87
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +0 -108
- data/.ai-assistance/projects/template-diff-deploy/INTENT.md +0 -12
- data/.ai-assistance/projects/template-diff-deploy/TODO.md +0 -9
- data/.ai-assistance/projects/template-maintenance/DESIGN.md +0 -134
- data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +0 -93
- data/.ai-assistance/projects/template-maintenance/README.md +0 -14
- data/.ai-assistance/projects/template-model-logic/CATEGORY_CATALOG.md +0 -236
- data/.ai-assistance/projects/template-model-logic/CLASSIFICATION_SPIKE.md +0 -243
- data/.ai-assistance/projects/template-model-logic/DESIGN_NOTE.md +0 -154
- data/.ai-assistance/projects/workflow-space/TODO.md +0 -213
- data/.ai-assistance/reinstall-claude-desktop-windows.md +0 -136
- data/.ai-assistance/scripts/CLAUDE.md +0 -150
- data/.ai-assistance/scripts/bridge-inbox-check.sh +0 -75
- data/.ai-assistance/scripts/bridge-init.sh +0 -86
- data/.ai-assistance/scripts/bridge-status.sh +0 -44
- data/.ai-assistance/scripts/capabilities-check.ts +0 -104
- data/.ai-assistance/scripts/check-outbox.sh +0 -43
- data/.ai-assistance/scripts/dep_graph.rb +0 -91
- data/.ai-assistance/scripts/lock-acquire.sh +0 -103
- data/.ai-assistance/scripts/lock-multi.sh +0 -124
- data/.ai-assistance/scripts/lock-queue.sh +0 -94
- data/.ai-assistance/scripts/setup-mcps.test.ts +0 -188
- data/.ai-assistance/scripts/setup-mcps.ts +0 -234
- data/.ai-assistance/scripts/task-complete.ts +0 -74
- data/.ai-assistance/scripts/task-create.ts +0 -75
- data/.ai-assistance/scripts/task-read.ts +0 -125
- data/.ai-assistance/scripts/token-logger.js +0 -220
- data/.ai-assistance/scripts/token-report.ts +0 -158
- data/.ai-assistance/scripts/token-session-start.js +0 -66
- data/.ai-assistance/skills/ai-instructions/SKILL.md +0 -48
- data/.ai-assistance/skills/code-specs/SKILL.md +0 -69
- data/.ai-assistance/skills/corporate-policies/SKILL.md +0 -201
- data/.ai-assistance/skills/dep-graph/SKILL.md +0 -139
- data/.ai-assistance/skills/ep-ai-manager/SKILL.md +0 -417
- data/.ai-assistance/skills/gemini-assist/SKILL.md +0 -63
- data/.ai-assistance/skills/gemini-assist/gemini-mcp-server.js +0 -205
- data/.ai-assistance/skills/gemini-assist/gemini_ask.py +0 -1
- data/.ai-assistance/skills/gemini-assist/gemini_ask.rb +0 -240
- data/.ai-assistance/skills/gemini-assist/prompts/cycle_end_review.txt +0 -25
- data/.ai-assistance/skills/graphql-schema-analysis/SKILL.md +0 -261
- data/.ai-assistance/skills/procedural-memory/SKILL.md +0 -319
- data/.ai-assistance/skills/project-cycle/SKILL.md +0 -177
- data/.ai-assistance/skills/project-self-docs/SKILL.md +0 -181
- data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +0 -378
- data/.ai-assistance/skills/refactor/SKILL.md +0 -62
- data/.ai-assistance/skills/rubocop/SKILL.md +0 -93
- data/.ai-assistance/skills/ruby-scripting/SKILL.md +0 -215
- data/.ai-assistance/skills/spec-generation/SKILL.md +0 -72
- data/.ai-assistance/standards-version.json +0 -22
- data/.ai-assistance/token-budget.json +0 -32
- data/.ai-assistance/version.json +0 -39
- data/.claude/settings.json +0 -150
- data/.env.example +0 -18
- data/.gitattributes +0 -15
- data/.gitignore +0 -37
- data/.gitlab-ci.yml +0 -45
- data/.markdownlint.json +0 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -121
- data/.ruby-version +0 -1
- data/.yardopts +0 -10
- data/CLAUDE.md +0 -243
- data/Gemfile +0 -30
- data/Rakefile +0 -90
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/bin/setup.ps1 +0 -6
- data/docs/self-docs/ARCHITECTURE.md +0 -88
- data/docs/self-docs/CHANGES.jsonl +0 -12
- data/docs/self-docs/COMPLIANCE.md +0 -79
- data/docs/self-docs/CONVENTIONS.md +0 -74
- data/docs/self-docs/INTEGRATIONS.md +0 -65
- data/docs/self-docs/OPERATIONS.md +0 -76
- data/docs/self-docs/OVERVIEW.md +0 -64
- data/docs/self-docs/STATUS.md +0 -73
- data/docs/self-docs/self-docs-index.json +0 -51
- data/docs/worklog.md +0 -977
- data/ecoportal-api-graphql.gemspec +0 -40
- data/scripts/auto-worker-scheduler.sh +0 -386
- data/tests/actions_get.rb +0 -7
- data/tests/contractor_entities_get.rb +0 -20
- data/tests/contractor_entity_create.rb +0 -19
- data/tests/contractor_entity_udpate.rb +0 -20
- data/tests/dump_page_model.rb +0 -74
- data/tests/dump_template_model.rb +0 -90
- data/tests/loc_structure_get.rb +0 -10
- data/tests/loc_structure_update.rb +0 -51
- data/tests/loc_structures_get.rb +0 -15
- data/tests/local_libs.rb +0 -20
- data/tests/validate_queries.rb +0 -127
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
# Code Spec: `as_update` / `as_input` Pipeline
|
|
2
|
-
|
|
3
|
-
**Scope:** `Common::GraphQL::Model::Diffable`, `Common::GraphQL::Model::AsInput`,
|
|
4
|
-
`DiffService`, `ClassicDiffService`, `HashDiffNesting`
|
|
5
|
-
**Last updated:** 2026-06-05
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## What This Pipeline Does
|
|
10
|
-
|
|
11
|
-
Models that include `AsInput` (via `Model < DoubleModel`) can:
|
|
12
|
-
1. **Diff themselves** against their original document via `#as_update` → returns a Hash
|
|
13
|
-
of only the changed properties.
|
|
14
|
-
2. **Convert that diff** to mutation input via `#as_input` → returns a Hash ready to be
|
|
15
|
-
passed as the `input:` variable to a GraphQL mutation.
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## Class Hierarchy
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
Common::GraphQL::Model < Common::Content::DoubleModel
|
|
23
|
-
includes Diffable → provides #as_update, #dirty?
|
|
24
|
-
includes AsInput → provides #as_input, .as_input(hash)
|
|
25
|
-
|
|
26
|
-
Diffable::DiffService < ClassicDiffService
|
|
27
|
-
includes HashDiffNesting → change_diff, patch_update, patch_new, patch_delete, ...
|
|
28
|
-
includes HashHelpers
|
|
29
|
-
|
|
30
|
-
Diffable::ClassicDiffService
|
|
31
|
-
includes HashDiffNesting
|
|
32
|
-
provides #diff, #classic_diff, #curr_doc, #prev_doc
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## Current Output Format of `DiffService#diff` — KNOWN ISSUE
|
|
38
|
-
|
|
39
|
-
**`#as_update` calls `DiffService#diff` which calls `classic_diff(flat: true)`, which calls
|
|
40
|
-
`change_diff(curr_doc, prev_doc)`.**
|
|
41
|
-
|
|
42
|
-
`change_diff` routes through `patch_update` for any Hash that has an `:id` key, producing:
|
|
43
|
-
|
|
44
|
-
```ruby
|
|
45
|
-
{
|
|
46
|
-
id: "abc123",
|
|
47
|
-
api_operation: :update,
|
|
48
|
-
change_data: { name: "New Name", status: "active" }
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
**This is WRONG for GraphQL mutations.** The GraphQL mutation input should be flat:
|
|
53
|
-
|
|
54
|
-
```ruby
|
|
55
|
-
{ id: "abc123", name: "New Name", status: "active" }
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
**Why it's wrong:** GraphQL mutation inputs (`UpdateActionInput`, `UpdatePageInput`, etc.)
|
|
59
|
-
do not have `api_operation` or `change_data` fields — they expect changed properties directly
|
|
60
|
-
at the top level alongside `id`.
|
|
61
|
-
|
|
62
|
-
**Root cause:** `HashDiffNesting` was ported from `ecoportal-api-v2`'s `HashDiffPatch`.
|
|
63
|
-
The v2 gem uses JSON Patch semantics (`api_operation`, `change_data`). GraphQL mutations
|
|
64
|
-
do not — they use direct property assignment.
|
|
65
|
-
|
|
66
|
-
**Decision (2026-06-04):** GraphQL `as_update` must produce changed-props-only output with
|
|
67
|
-
no patch-operation wrapper. See `DECISIONS.md` ("as_update diff: changed props only").
|
|
68
|
-
|
|
69
|
-
**Fix required (TODO 4.2/4.3):** Either:
|
|
70
|
-
- Override `classic_diff` in `DiffService` to call `change_data(curr, prev)` directly
|
|
71
|
-
(returns just the changed fields, no wrapper), and re-add `id` separately; or
|
|
72
|
-
- Have `AsInput#as_input` detect and unwrap the patch-op structure before building input.
|
|
73
|
-
The preferred approach per the DECISIONS is to fix `DiffService` itself.
|
|
74
|
-
|
|
75
|
-
### Nested objects
|
|
76
|
-
|
|
77
|
-
`diff_reduce` cascades through nested model objects (those that respond to `#as_update`)
|
|
78
|
-
and replaces the nested path in the result with each nested model's own `as_update` output.
|
|
79
|
-
These nested diffs also currently produce `api_operation`/`change_data` wrappers if the
|
|
80
|
-
nested object has an `id`.
|
|
81
|
-
|
|
82
|
-
For GraphQL, nested arrays of objects with IDs (e.g. `locations`, `associatedPeople`)
|
|
83
|
-
are expressed differently per mutation — sometimes as `[ID]` arrays, sometimes as
|
|
84
|
-
`IdDiffInput` (add/remove deltas), sometimes as full nested input objects. The `InputClass`
|
|
85
|
-
(per the `from_model` pattern, TODO 4.3) is responsible for reshaping nested diffs into
|
|
86
|
-
the correct format for each mutation.
|
|
87
|
-
|
|
88
|
-
---
|
|
89
|
-
|
|
90
|
-
## `AsInput` Current Behavior
|
|
91
|
-
|
|
92
|
-
### Instance method `#as_input(clientMutationId: '')`
|
|
93
|
-
|
|
94
|
-
1. Calls `as_update` → gets the diff hash (currently with patch-op wrapper, see above).
|
|
95
|
-
2. Calls `self.class.as_input(diff, clientMutationId: ...)`.
|
|
96
|
-
|
|
97
|
-
### Class method `.as_input(hash, clientMutationId: '')`
|
|
98
|
-
|
|
99
|
-
1. Deep-converts hash keys to symbols.
|
|
100
|
-
2. Merges `clientMutationId:`.
|
|
101
|
-
3. Calls `remove_nil_keys_deep(hash, target: :id)` — removes nil-valued `:id` keys
|
|
102
|
-
(keeps `id:` only when it has a value; removes nil ids from nested objects so they
|
|
103
|
-
don't confuse the server).
|
|
104
|
-
|
|
105
|
-
**Current output (wrong):**
|
|
106
|
-
```ruby
|
|
107
|
-
{
|
|
108
|
-
id: "abc123",
|
|
109
|
-
api_operation: :update,
|
|
110
|
-
change_data: { name: "New Name" },
|
|
111
|
-
clientMutationId: ""
|
|
112
|
-
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
**Desired output (target):**
|
|
116
|
-
```ruby
|
|
117
|
-
{ id: "abc123", name: "New Name", clientMutationId: "" }
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
---
|
|
121
|
-
|
|
122
|
-
## `patch_ver` — Concurrency Control
|
|
123
|
-
|
|
124
|
-
**Schema:** `patchVer: Int!` (non-null) on `BasePageInterface`. Present on all Page types.
|
|
125
|
-
`BasePage` in the gem maps it as `passthrough :patchVer`.
|
|
126
|
-
|
|
127
|
-
**How it works:**
|
|
128
|
-
- Every Page response includes `patchVer` (an incrementing Integer).
|
|
129
|
-
- On `UpdatePage` mutation, the caller may pass `patchVer:` (optional).
|
|
130
|
-
- The server checks that the submitted `patchVer` matches the current value; if not,
|
|
131
|
-
the update is rejected (optimistic locking / last-write-wins prevention).
|
|
132
|
-
|
|
133
|
-
**Current gap (TODO 4.2):** `AsInput#as_input` does not inject `patchVer` into the
|
|
134
|
-
output. The subject model (`BasePage` subclass) holds `patchVer` via `passthrough :patchVer`
|
|
135
|
-
but it is never extracted and included in the mutation input hash.
|
|
136
|
-
|
|
137
|
-
**Fix required:** In `AsInput#as_input` (instance method), read `self.patchVer` if the
|
|
138
|
-
model responds to it, and include it in the input hash at the top level:
|
|
139
|
-
|
|
140
|
-
```ruby
|
|
141
|
-
def as_input(clientMutationId: '')
|
|
142
|
-
diff = as_update # TODO: once fixed, returns flat changed props
|
|
143
|
-
diff[:patchVer] = patchVer if respond_to?(:patchVer) && !patchVer.nil?
|
|
144
|
-
self.class.as_input(diff, clientMutationId: clientMutationId)
|
|
145
|
-
end
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
---
|
|
149
|
-
|
|
150
|
-
## `from_model` Pattern (TODO 4.3)
|
|
151
|
-
|
|
152
|
-
**Decision (2026-06-04):** Conversion from model diff to mutation-specific input shape is
|
|
153
|
-
owned by the `Input` class, not the model. Each mutation's input class implements
|
|
154
|
-
`InputClass.from_model(model)`.
|
|
155
|
-
|
|
156
|
-
**Why:** The model (`BasePage`, `Base::Action`, etc.) should not know which mutation it
|
|
157
|
-
feeds. The input class knows its own schema shape (field names, nested structure, ID vs
|
|
158
|
-
IdDiff arrays, etc.).
|
|
159
|
-
|
|
160
|
-
**Pattern:**
|
|
161
|
-
```ruby
|
|
162
|
-
class Input::Action::Update < Logic::Input
|
|
163
|
-
def self.from_model(action_model)
|
|
164
|
-
diff = action_model.as_update # after fixing: flat {name:, status:, ...}
|
|
165
|
-
new(
|
|
166
|
-
id: action_model.id,
|
|
167
|
-
patchVer: action_model.patchVer, # if applicable
|
|
168
|
-
**map_fields(diff)
|
|
169
|
-
)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
private_class_method def self.map_fields(diff)
|
|
173
|
-
# rename, reshape, filter fields as needed for this specific mutation input
|
|
174
|
-
diff.slice(:name, :description, :status, :dueDate, :assignedPersonMemberIds, ...)
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
**The `as_input` instance method becomes a convenience delegator:**
|
|
180
|
-
```ruby
|
|
181
|
-
def as_input(target_class: default_input_class, **kargs)
|
|
182
|
-
target_class.from_model(self, **kargs)
|
|
183
|
-
end
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
|
-
## `root?` Objects in the Diff
|
|
189
|
-
|
|
190
|
-
Models marked `root!` (via `Ecoportal::API::Common::GraphQL::ClassHelpers`) are excluded
|
|
191
|
-
from cascaded diffs. These are typically "lookup" objects that are referenced but not
|
|
192
|
-
owned — e.g. `Base::Organization` (root!), `Base::Action` (root!). Their nested presence
|
|
193
|
-
in a model's JSON will not generate diff entries.
|
|
194
|
-
|
|
195
|
-
This is correct behaviour: you don't diff a referenced Organization when updating a Page;
|
|
196
|
-
you only diff properties the Page itself owns.
|
|
197
|
-
|
|
198
|
-
---
|
|
199
|
-
|
|
200
|
-
## Key Files
|
|
201
|
-
|
|
202
|
-
| File | Role |
|
|
203
|
-
|------|------|
|
|
204
|
-
| `lib/.../model.rb` | `Model < DoubleModel`, includes `Diffable` and `AsInput` |
|
|
205
|
-
| `lib/.../model/diffable.rb` | `#as_update`, `#dirty?`, `DIFF_CLASS` |
|
|
206
|
-
| `lib/.../model/diffable/diff_service.rb` | Main diff logic, `#diff`, `#diff_reduce` |
|
|
207
|
-
| `lib/.../model/diffable/classic_diff_service.rb` | Base: `#curr_doc`, `#prev_doc`, `change_diff` dispatch |
|
|
208
|
-
| `lib/.../model/diffable/hash_diff_nesting.rb` | `change_diff`, `patch_update/new/delete`, array diffing |
|
|
209
|
-
| `lib/.../model/as_input.rb` | `#as_input`, `.as_input(hash)` class method |
|
|
210
|
-
| `lib/.../interface/base_page.rb` | `passthrough :patchVer` — Page models carry this |
|
|
211
|
-
|
|
212
|
-
---
|
|
213
|
-
|
|
214
|
-
## Backend Reference (ecoPortal_master)
|
|
215
|
-
|
|
216
|
-
- `app/graphql/types/pages/interfaces/base_page_interface.rb` — `patchVer: Int!` (non-null)
|
|
217
|
-
- `app/graphql/mutations/pages/update.rb` — `argument :patch_ver, Integer, required: false`
|
|
218
|
-
- `app/graphql/types/fill_in_page/inputs/page_input.rb` — Page update input fields:
|
|
219
|
-
`locations`, `other_tags`, `time_zone`, `state`, `task_priority`, `name`, `external_id`
|
|
220
|
-
- `app/graphql/types/fill_in_page/inputs/data_field_input.rb` — DataField inputs (complex)
|
|
221
|
-
|
|
222
|
-
**UpdatePage mutation shape:**
|
|
223
|
-
```graphql
|
|
224
|
-
mutation UpdatePage($id: ID!, $patch_ver: Int, $page: PageInput, $data_fields: [DataFieldInput], ...) {
|
|
225
|
-
updatePage(id: $id, patchVer: $patch_ver, page: $page, dataFields: $data_fields, ...) {
|
|
226
|
-
item { ...BasePageInterface }
|
|
227
|
-
errors { ... }
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Note: `id`, `patchVer`, and `page` (the input object) are separate top-level arguments —
|
|
233
|
-
not nested under a single `input:` object like Action mutations. This is a structural
|
|
234
|
-
difference that the `from_model` pattern must account for per mutation type.
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
# Diff — Command Synthesis, Deploy & Pairing Engine
|
|
2
|
-
|
|
3
|
-
**Area:** `lib/ecoportal/api/graphql/diff/` · **Status:** built (gem branch
|
|
4
|
-
`feature/template-diff-deploy-pairing`) · **Domain reference (read first):**
|
|
5
|
-
`template_diff_pairing_domain.md`.
|
|
6
|
-
|
|
7
|
-
This doc covers the diff → command → deploy pipeline and the cross-object pairing engine + learning
|
|
8
|
-
ledger. `VersionDiff`/`Change` (the self-version structural diff) are covered inline in their files.
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## 1. The pipeline
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
pair (id | genome | type+label | ledger) Diff::Pairing::Engine (+ Ledger)
|
|
16
|
-
└─► diff on paired model Diff::VersionDiff → [Change]
|
|
17
|
-
└─► delta as WorkflowCommands Diff::CommandSynthesizer (+ IdResolver)
|
|
18
|
-
└─► apply (gated on unsupported) Diff::Deploy#execute!
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Self-version (same object, retained ids) needs NO pairing — `VersionDiff` matches by id and the
|
|
22
|
-
synthesiser replays as-is. Cross-object (UAT↔PROD) needs pairing to translate the ids first.
|
|
23
|
-
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
## 2. Classes
|
|
27
|
-
|
|
28
|
-
| Class | File | Role |
|
|
29
|
-
|---|---|---|
|
|
30
|
-
| `Diff::Strategy` | `diff/strategy.rb` | Composable diff config (pairing × scope × move-sensitivity × intent); `#filter(changes)` |
|
|
31
|
-
| `Diff::VersionDiff` | `diff/version_diff.rb` | Self-version (`:id` pairing) front-end → `[Change]`; accepts `strategy:` |
|
|
32
|
-
| `Diff::CrossObjectDiff` | `diff/cross_object_diff.rb` | Cross-object front-end: pairs fields via `Pairing::Engine`, emits same `[Change]` |
|
|
33
|
-
| `Diff::Change` | `diff/change.rb` | One change (op/kind/id/before/after/parent_id) (unchanged) |
|
|
34
|
-
| `Diff::CommandSynthesizer` | `diff/command_synthesizer.rb` | `[Change]` → ordered built `WorkflowCommand` batch + `unsupported` |
|
|
35
|
-
| `Diff::IdResolver` | `diff/id_resolver.rb` | Maps `resolve(kind, human-key) => id` (stage name / section heading → target id) |
|
|
36
|
-
| `Diff::Deploy` | `diff/deploy.rb` | Orchestrates diff → commands → apply; gates on `unsupported` |
|
|
37
|
-
| `Diff::Pairing::Signals` | `diff/pairing/signals.rb` | Weak signals (genome/type/label/options), each 0.0..1.0 or nil |
|
|
38
|
-
| `Diff::Pairing::Candidate` | `diff/pairing/candidate.rb` | Scored source↔target proposal (`score`, `signals`, `matched_by`) |
|
|
39
|
-
| `Diff::Pairing::Engine` | `diff/pairing/engine.rb` | Multi-signal equivalence matcher → accepted / ambiguous / unmatched |
|
|
40
|
-
| `Diff::Pairing::Ledger` | `diff/pairing/ledger.rb` | Persisted confirmed equivalences (JSON); consulted first |
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## 3. CommandSynthesizer — edit mode
|
|
45
|
-
|
|
46
|
-
`CommandSynthesizer.new(changes, resolver: nil)`. Emits in a dependency-safe order (stages → sections
|
|
47
|
-
→ fields → options; within a kind removes → adds → moves → edits). A `command_for` result may be a
|
|
48
|
-
single hash, an **Array** of hashes (a move that emits remove+add), or nil (unsupported); `build_all`
|
|
49
|
-
normalises via `wrap` (NOT `Kernel#Array`, which would splat a Hash).
|
|
50
|
-
|
|
51
|
-
| Change | Command(s) | Notes |
|
|
52
|
-
|---|---|---|
|
|
53
|
-
| stage added/removed/moved | `addStage`/`removeStage`/`moveStage` | |
|
|
54
|
-
| stage `name` changed | `editStage(stageId, name)` | |
|
|
55
|
-
| section added/removed | `addSection`/`removeSection` | |
|
|
56
|
-
| section `heading` changed | `editSectionHeader(sectionId, header)` | |
|
|
57
|
-
| **section `stage` moved** | `removeStageSection` + `addStageSection` | **needs resolver** for stage ids; else UNSUPPORTED |
|
|
58
|
-
| field added/removed | `addField`/`removeField` | |
|
|
59
|
-
| field `label` changed | `editFieldConfiguration(dataFieldId, label)` | |
|
|
60
|
-
| **field `section` moved** | `moveField(id, sectionId)` | **needs resolver** for section id; else UNSUPPORTED |
|
|
61
|
-
| field `type` changed | — | **UNSUPPORTED** (no `editFieldType`; a rebuild would drop data) |
|
|
62
|
-
| option added/removed/changed(label,weight) | `add/remove/editSelectFieldOption` | uses `change.parent_id` (field id); nil → UNSUPPORTED |
|
|
63
|
-
| **field_config changed** (typed byType) | `editFieldConfiguration(byType: { <key> => { <attr> => after } })` | `change.by_type` names the sub-hash (`:gauge`/`:select`/`:date`); nil by_type → UNSUPPORTED. Emitted only for confirmed read↔write-key matches (see VersionDiff) |
|
|
64
|
-
| **gauge_stop added** | `addGaugeFieldStop(dataFieldId, threshold, color)` | threshold+color carried on `change.after`; `parent_id` = gauge field id |
|
|
65
|
-
| **gauge_stop removed** | `removeGaugeFieldStop(dataFieldId, stopId)` | `parent_id` = gauge field id |
|
|
66
|
-
| **gauge_stop changed** (threshold\|color) | `editGaugeFieldStop(dataFieldId, stopId, <attr>)` | one command per changed attr |
|
|
67
|
-
|
|
68
|
-
**Resolver contract:** any object answering `resolve(kind, key) => id | nil`. Without one, moves stay
|
|
69
|
-
UNSUPPORTED — the synthesiser NEVER guesses a target id. `IdResolver.from_doc(target_doc)` builds one
|
|
70
|
-
by indexing stage names + section headings; **ambiguous keys (duplicates) resolve to nil** (safe).
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## 4. Deploy
|
|
75
|
-
|
|
76
|
-
```ruby
|
|
77
|
-
plan = Diff::Deploy.from_versions(before_doc, after_doc) # self-version replay
|
|
78
|
-
plan = Diff::Deploy.from_versions(uat_v1, uat_v2, target_doc: prod_doc) # cross-object: moves resolve vs PROD
|
|
79
|
-
plan.commands # ordered built command hashes, ready for executeWorkflowCommands
|
|
80
|
-
plan.unsupported # [Change] needing human handling (never guessed)
|
|
81
|
-
plan.changelog # human one-liners
|
|
82
|
-
plan.execute!(page) # sends via page.execute_workflow_commands; RAISES if unsupported unless allow_partial: true
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
Inert until `execute!` with an explicit executor. `from_versions` derives an `IdResolver` from
|
|
86
|
-
`target_doc:` (override with `resolver:`).
|
|
87
|
-
|
|
88
|
-
---
|
|
89
|
-
|
|
90
|
-
## 5. Pairing engine (equivalence matching)
|
|
91
|
-
|
|
92
|
-
Cross-object pairing is entity-resolution, not lookup (no shared Mongo ids). `Engine#pair(sources,
|
|
93
|
-
targets)` returns `Result(accepted, ambiguous, unmatched)`:
|
|
94
|
-
|
|
95
|
-
1. **Ledger first** — a previously-confirmed pair auto-resolves (`matched_by: :ledger`, score 1.0),
|
|
96
|
-
consuming the target so it can't be re-assigned. Pairing improves over time.
|
|
97
|
-
2. **Score the rest** — every remaining source×target via `Signals`, weighted mean over the signals
|
|
98
|
-
that APPLY (nil signals excluded so a select field isn't penalised for absent genome).
|
|
99
|
-
3. **Greedy one-to-one assignment**, strongest links first.
|
|
100
|
-
4. **Classify:** `accepted` (score ≥ `accept_threshold` 0.85 AND not within `tie_margin` 0.1 of the
|
|
101
|
-
runner-up) · `ambiguous` (0.5–0.85, or a near-tie) · `unmatched` (< 0.5 review_threshold).
|
|
102
|
-
|
|
103
|
-
**Signals + weights:** genome 0.5, type 0.2, label 0.2, options 0.1.
|
|
104
|
-
- **genome** — match 1.0, mismatch 0.0 (does NOT veto — a field may be re-purposed keeping its
|
|
105
|
-
genome, or genome may be absent on newer types), nil when neither side carries one. Strong but
|
|
106
|
-
**fallible** (6 documented failure modes — see domain ref §5).
|
|
107
|
-
- **type** — `__typename` equality (a type change disqualifies a data pairing).
|
|
108
|
-
- **label** — case/space-insensitive exact = 1.0, else token Jaccard (partial credit for a re-purpose
|
|
109
|
-
candidate like `Date logged` → `Date of sign-off`).
|
|
110
|
-
- **options** — select options overlap by **value** (primary) / label — the correct option identity,
|
|
111
|
-
never genome; nil for non-select fields.
|
|
112
|
-
|
|
113
|
-
**Never guesses:** only `accepted` is safe to auto-apply/record; `ambiguous`+`unmatched` are surfaced
|
|
114
|
-
for a human. A same-label/type field whose genome CONTRADICTS is pulled below accept (→ ambiguous or
|
|
115
|
-
unmatched), never auto-paired.
|
|
116
|
-
|
|
117
|
-
---
|
|
118
|
-
|
|
119
|
-
## 6. Learning ledger (first-class artifact)
|
|
120
|
-
|
|
121
|
-
`Ledger` records only CONFIRMED equivalences (auto-accepted high-confidence or human-adjudicated),
|
|
122
|
-
keyed by `(kind, source_id)`, storing `target_id` + `matched_by` + `confidence` + `signals` +
|
|
123
|
-
`recorded_at`. `Engine#confirm!(candidate, matched_by: :human)` writes back. JSON round-trips via
|
|
124
|
-
`Ledger.load(path)` / `#save`; a missing file yields an empty writable ledger; a later record
|
|
125
|
-
supersedes an earlier one (human corrects an auto-accept). The how-resolved log is the interim bridge
|
|
126
|
-
data Product's Field-ID / template-entity-id effort needs.
|
|
127
|
-
|
|
128
|
-
> Note the struct member is `matched_by`, NOT `method` — `method` would shadow `Object#method`
|
|
129
|
-
> (`Lint/StructNewOverride`). The `Ledger#record` / `Engine#confirm!` keyword is likewise `matched_by:`.
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
## 7. Gauge stops + typed byType config emission (round #3 — DONE)
|
|
134
|
-
|
|
135
|
-
`VersionDiff` now emits two new `Change` kinds (id-paired, self-version):
|
|
136
|
-
|
|
137
|
-
- **`:gauge_stop`** — diffs each Gauge field's `stops` list (`{ id, threshold, color }`, matched by
|
|
138
|
-
retained stop id): added (carries `after: { threshold:, color: }`), removed, changed
|
|
139
|
-
(`threshold`/`color`). `parent_id` = the gauge field id.
|
|
140
|
-
- **`:field_config`** — typed per-field configuration changes, `parent_id` = field id and `by_type`
|
|
141
|
-
= the `editFieldConfiguration.byType` sub-hash key. Emission is **data-driven + conservative**
|
|
142
|
-
(`VersionDiff::BYTYPE_CONFIG`): only where the READ doc property name matches the WRITE byType
|
|
143
|
-
input key EXACTLY. Currently confirmed against the schema + input classes:
|
|
144
|
-
- `Gauge` → `{ gauge: [max] }`
|
|
145
|
-
- `Select` → `{ select: [dataType, multiple, flat, other, otherDesc] }`
|
|
146
|
-
- `Date` → `{ date: [showTime, pastOnly, todayButton] }`
|
|
147
|
-
Anything else (e.g. PlainText `multiline` — not a `plainText` byType key; `required` — no
|
|
148
|
-
editFieldConfiguration key at all) is **not emitted** (unmappable → never fabricated).
|
|
149
|
-
|
|
150
|
-
`Stop` shape and the gauge byType `max` verified against
|
|
151
|
-
`.ai-assistance/tmp/20260605T101224_live_ep_graphql_schema.graphql.json` (`Stop { id, threshold: Float,
|
|
152
|
-
color: String }`; `WorkflowEditFieldConfigurationGaugeInput { max: Float }`).
|
|
153
|
-
|
|
154
|
-
## 7b. placeholderId threading (dependent intra-batch creates — round #3 — DONE)
|
|
155
|
-
|
|
156
|
-
`CommandSynthesizer.new(changes, thread_placeholders:)` (Deploy passes `true` by default). When on,
|
|
157
|
-
every ADDED structural node (stage/section/field) is assigned a deterministic client-chosen
|
|
158
|
-
`placeholderId` keyed by its source id; `addStage`/`addSection`/`addField` emit that placeholder, and
|
|
159
|
-
any later command in the SAME batch that references such a node (`addSelectFieldOption` /
|
|
160
|
-
`add/edit/removeGaugeFieldStop` / `editFieldConfiguration` dataFieldId) is rewritten to the
|
|
161
|
-
placeholder instead of the target-invalid source id (`ref(id)` helper). References to pre-existing
|
|
162
|
-
nodes keep their real id. Threading OFF → empty map → the source ids pass through unchanged (the
|
|
163
|
-
prior self-version behaviour). This makes add-then-reference sequences self-consistent within one
|
|
164
|
-
`executeWorkflowCommands` call. The placeholderId inputs are native to the command modules
|
|
165
|
-
(`addStage`/`addSection`/`addField` all list `placeholderId` in VALID_KEYS; `addGaugeFieldStop`
|
|
166
|
-
accepts an optional one too).
|
|
167
|
-
|
|
168
|
-
**Note on self-version scope:** a self-version `VersionDiff` subsumes a NEW field's options/config
|
|
169
|
-
into its single `added`-field change (children are not separately emitted when the parent is brand
|
|
170
|
-
new), so the natural self-version batch has no add-then-reference pair. Threading is therefore
|
|
171
|
-
latent for pure self-version replay and load-bearing for the CROSS-OBJECT / assembled change-set
|
|
172
|
-
replay (a field created in the batch referenced by later commands). Deploy is proven end-to-end
|
|
173
|
-
against such an assembled change-set.
|
|
174
|
-
|
|
175
|
-
## 7c. What remains UNSUPPORTED / deferred
|
|
176
|
-
|
|
177
|
-
- **Field type change** — no schema command; would need remove+add (drops data) → always UNSUPPORTED.
|
|
178
|
-
- **Moves without a resolver** — field/section moves need a target id; nil resolver → UNSUPPORTED.
|
|
179
|
-
- **Non-confirmed byType config props** — anything outside `BYTYPE_CONFIG` (PlainText `multiline`,
|
|
180
|
-
`required`, RichText `markdown`/`content`, People/Table/CrossReference/etc. bodies): the read
|
|
181
|
-
fragment and/or the byType input keys were not confirmed to line up 1:1, so they are NOT emitted
|
|
182
|
-
(never fabricated). Extend `BYTYPE_CONFIG` per type once the read shape is confirmed.
|
|
183
|
-
- **Gauge stop reordering** — stops are matched by id (add/remove/attr change) but a pure reorder is
|
|
184
|
-
not modelled (no ordering field surfaced on `Stop`); left UNSUPPORTED.
|
|
185
|
-
- **Structural cross-references in emitted adds** — `addField`/`addSection` still emit only
|
|
186
|
-
label/placeholder (not `stageId`/`sectionId` back-refs to their added parent). Once those refs are
|
|
187
|
-
emitted, placeholder threading will thread them too (the primitive is already in place).
|
|
188
|
-
- **Interactive assisted-resolution UX + `TypedFieldsPairing` as an extra signal** — eco-helpers layer.
|
|
189
|
-
|
|
190
|
-
## 8. Diff modalities — `Strategy` + `CrossObjectDiff` (Phase 4 — DONE)
|
|
191
|
-
|
|
192
|
-
Diffing is a COMPOSABLE FAMILY (domain ref §8), not one diff. `Diff::Strategy` is the value object
|
|
193
|
-
over four axes:
|
|
194
|
-
|
|
195
|
-
- **pairing** `:id` (self-version, default) · `:genome` · `:type_label` · `:assisted` (full engine).
|
|
196
|
-
`:id` needs no pairing map; the other three are `cross_object?` (need one).
|
|
197
|
-
- **scope** `:structural` (all) · `:config_only` (field_config/option/gauge_stop) · `:data_migration`
|
|
198
|
-
(field changes only).
|
|
199
|
-
- **move_sensitive** — false suppresses `:moved` changes; config_only/data_migration never emit moves.
|
|
200
|
-
- **intent** `:changelog` · `:deploy` · `:sync_readiness` (documentation; `:deploy` keeps threading on).
|
|
201
|
-
|
|
202
|
-
`Strategy#filter(changes)` applies scope + move-sensitivity to a computed change-set. `Strategy.default`
|
|
203
|
-
is `{id, structural, move-aware, changelog}` — EXACTLY the pre-Phase-4 `VersionDiff` behaviour (BC).
|
|
204
|
-
|
|
205
|
-
`VersionDiff.new(before, after, strategy:)` is the `:id` front-end: it computes the full id-paired
|
|
206
|
-
change-set once (`all_changes`) and returns `strategy.filter(all_changes)`. Default = unchanged.
|
|
207
|
-
|
|
208
|
-
`CrossObjectDiff.new(source, target, engine:, strategy:)` is the cross-object front-end for two docs
|
|
209
|
-
with NO shared ids:
|
|
210
|
-
1. flatten each doc's data-fields; `engine.pair(source_fields, target_fields)` (genome+type+label+
|
|
211
|
-
options, ledger-first);
|
|
212
|
-
2. for each ACCEPTED pair emit `:changed(label|type)` keyed by the **source** id; target-only field →
|
|
213
|
-
`:added`; a source with no accepted target AND not in the review set → `:removed`;
|
|
214
|
-
3. **never guesses** — ambiguous + unmatched sources/targets are held in `#unresolved` (surfaced for a
|
|
215
|
-
human), NOT auto-paired / auto-added / auto-removed. A same-genome RELABEL is ambiguous by design
|
|
216
|
-
(could be a re-purpose) → not auto-emitted unless the ledger/human confirms the pair.
|
|
217
|
-
|
|
218
|
-
The emitted `[Change]` feeds the EXISTING `CommandSynthesizer` / `Deploy` unchanged (one emission
|
|
219
|
-
layer). `Deploy.from_cross_object(source, target, engine:, strategy:)` + `Deploy#pairing` expose the
|
|
220
|
-
accepted/ambiguous/unmatched for adjudication before apply.
|
|
221
|
-
|
|
222
|
-
## 9. Unified BUILD emitter — `Builder::TemplateBuilder` (shares the emission layer)
|
|
223
|
-
|
|
224
|
-
`lib/ecoportal/api/graphql/builder/template_builder.rb`. Turns a declarative template spec
|
|
225
|
-
(stages→sections→fields→options→config→gauge-stops) into an ordered `WorkflowCommand` batch for
|
|
226
|
-
`Builder::Template#create/update(commands:)`. Order: addStage → addSection → addStageSection →
|
|
227
|
-
editSectionHeader → addField → addSelectFieldOption / addGaugeFieldStop / editFieldConfiguration(byType:).
|
|
228
|
-
|
|
229
|
-
It uses the SAME `placeholderId` id-threading primitive as `CommandSynthesizer`: every created node
|
|
230
|
-
gets a deterministic client-chosen `placeholderId` and later same-batch commands reference it (stage
|
|
231
|
-
placeholder ← addStageSection.stageId + addField.stageId; section placeholder ← addStageSection.sectionId
|
|
232
|
-
+ addField.sectionId; field placeholder ← option/stop/config dataFieldId). So BUILD (add everything)
|
|
233
|
-
and DIFF-DEPLOY (add/remove/edit the delta) emit through ONE layer. Only VALID command-input keys are
|
|
234
|
-
emitted (input classes slice + compact); config is passed through under its byType sub-hash exactly as
|
|
235
|
-
supplied — nothing is fabricated. UNSUPPORTED in a build spec (deferred): forces/strategies/callbacks/
|
|
236
|
-
tasks/recipients (shape unconfirmed); `field_type` enum not schema-validated (passed through as given).
|
|
237
|
-
|
|
238
|
-
## 10. Related
|
|
239
|
-
|
|
240
|
-
- `template_diff_pairing_domain.md` — the full problem space (READ FIRST).
|
|
241
|
-
- `lib/.../input/workflow_command.rb` — the command vocabulary both emitters target.
|
|
242
|
-
- `.ai-assistance/projects/template-automatic-build-maintenance/{INTENT,TODO}.md` (the merged plan;
|
|
243
|
-
`template-diff-deploy/` + `template-maintenance/` are now pointers).
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
# Code Spec: DiffService Deep Dive
|
|
2
|
-
|
|
3
|
-
**Scope:** `DiffService`, `ClassicDiffService`, `HashDiffNesting`, `AsInput` — bugs discovered
|
|
4
|
-
during 4.4 spec work, current state, and what still needs fixing.
|
|
5
|
-
**Last updated:** 2026-06-05
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Current State (post-4.2 / pre-4.4 completion)
|
|
10
|
-
|
|
11
|
-
### What works
|
|
12
|
-
- **Flat diffs on simple models** (no cascade): `change_data` produces correct output
|
|
13
|
-
after key normalisation fix (see below).
|
|
14
|
-
- **`patchVer` injection** in `AsInput#as_input`.
|
|
15
|
-
- **`from_model` delegation** via `Logic::Input.from_model`.
|
|
16
|
-
- **`ContractorEntity::Update.from_model`** IdDiff reshaping.
|
|
17
|
-
|
|
18
|
-
### What is still broken / incomplete
|
|
19
|
-
See TODO 4.4 for the spec gaps. Key issues:
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## Bug 1 — String vs Symbol Keys in `change_data` (ROOT CAUSE OF MOST FAILURES)
|
|
24
|
-
|
|
25
|
-
`ClassicDiffService#prev_doc` returns `model.original_doc` — a hash with **string keys**
|
|
26
|
-
(set via `JSON.parse` in `DoubleModel#initialize`).
|
|
27
|
-
|
|
28
|
-
`HashDiffNesting#change_data` converts each key to symbol via `key.to_sym` and then
|
|
29
|
-
looks up the value in `b` (prev_doc) with:
|
|
30
|
-
```ruby
|
|
31
|
-
if b&.key?(key) # key is now :name (symbol)
|
|
32
|
-
b_value = b[key]
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Since `b` has `'name'` (string), `b.key?(:name)` is **always false**. Every field appears
|
|
36
|
-
as "changed" (new value vs nil previous). The diff is wrong.
|
|
37
|
-
|
|
38
|
-
### Fix needed in `DiffService#classic_diff`
|
|
39
|
-
|
|
40
|
-
Normalise both docs to symbol keys before comparison:
|
|
41
|
-
```ruby
|
|
42
|
-
def classic_diff(flat: flat?, ignore: [])
|
|
43
|
-
curr = keys_to_sym_deep(curr_doc(flat: flat) || {})
|
|
44
|
-
prev = keys_to_sym_deep(prev_doc(flat: flat) || {})
|
|
45
|
-
|
|
46
|
-
data = change_data(curr, prev, ignore: to_a(ignored, ignore))
|
|
47
|
-
return nil if data == NO_CHANGES
|
|
48
|
-
|
|
49
|
-
id = get_id(curr, exception: false)
|
|
50
|
-
id ? { id: id }.merge(data) : data
|
|
51
|
-
end
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
`keys_to_sym_deep` is available as an instance method via `include HashHelpers`.
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## Bug 2 — `dig_path?` Returns True for Any Single-Element Path
|
|
59
|
-
|
|
60
|
-
The `DoubleModel::HashHelpers` implementation:
|
|
61
|
-
```ruby
|
|
62
|
-
def dig_path?(obj, keys)
|
|
63
|
-
return false unless obj.respond_to?(:[])
|
|
64
|
-
return true if keys.length == 1 # ← BUG: doesn't check key existence
|
|
65
|
-
dig_path?(obj[keys.first], keys[1..])
|
|
66
|
-
end
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
This always returns `true` for single-element key paths regardless of whether the key
|
|
70
|
-
actually exists. For `diff_reduce`, this causes:
|
|
71
|
-
- `dig_path?({id:, name:}, ['actionCategory'])` → **true** (wrong! actionCategory not present)
|
|
72
|
-
- `dig_set!(result, ['actionCategory'], nil)` adds spurious nil entries to the diff
|
|
73
|
-
|
|
74
|
-
**Fix in `HashDiffNesting#dig_path?`:**
|
|
75
|
-
```ruby
|
|
76
|
-
def dig_path?(obj, keys)
|
|
77
|
-
return false unless obj.is_a?(Hash)
|
|
78
|
-
return obj.key?(keys.first) if keys.length == 1 # check actual existence
|
|
79
|
-
return false unless obj.key?(keys.first)
|
|
80
|
-
dig_path?(obj[keys.first], keys[1..])
|
|
81
|
-
end
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
## Bug 3 — `diff_reduce` Cannot Add New Cascade Keys
|
|
87
|
-
|
|
88
|
-
`diff_reduce` was designed to REPLACE existing cascade keys in the flat diff. But
|
|
89
|
-
cascade attributes are always STRIPPED from the flat diff (`doc_with_non_cascaded_attributes`).
|
|
90
|
-
So the keys are never in the flat diff, and `diff_reduce` never adds them.
|
|
91
|
-
|
|
92
|
-
### Current broken flow for nested change only:
|
|
93
|
-
1. `dueDate` changed on an Action, nothing else changed
|
|
94
|
-
2. `classic_diff(flat: true)` → `nil` (dueDate stripped, no flat-level changes)
|
|
95
|
-
3. `diff_reduce(nil)` → all `dig_path?(nil, ...)` return `false` → cascade never processed
|
|
96
|
-
4. Result: `nil` — the nested change is LOST
|
|
97
|
-
|
|
98
|
-
### Current broken flow for mixed change:
|
|
99
|
-
1. `name` changed AND `dueDate` changed
|
|
100
|
-
2. `classic_diff(flat: true)` → `{id:, name: 'Updated'}` (flat change only)
|
|
101
|
-
3. `diff_reduce({id:, name: 'Updated'})`:
|
|
102
|
-
- `dig_path?({id:, name:}, ['dueDate'])` → `false` (dueDate not in flat diff) → skip
|
|
103
|
-
4. Result: `{id:, name: 'Updated'}` — dueDate change LOST
|
|
104
|
-
|
|
105
|
-
### Fix needed in `diff_reduce`:
|
|
106
|
-
|
|
107
|
-
Remove the `dig_path?` gate. Instead, ADD cascade diffs directly:
|
|
108
|
-
```ruby
|
|
109
|
-
def diff_reduce(init, ignore: [])
|
|
110
|
-
subject.cascaded_reduce(init, recurs: false) do |result, obj, _key, key_path, _trace|
|
|
111
|
-
next result if key_path.empty?
|
|
112
|
-
next result if obj == subject
|
|
113
|
-
next result if root?(obj)
|
|
114
|
-
next result unless obj.is_a?(Ecoportal::API::Common::GraphQL::Model)
|
|
115
|
-
|
|
116
|
-
value = obj.as_update(ignore: ignore)
|
|
117
|
-
next result if value.nil?
|
|
118
|
-
|
|
119
|
-
result ||= {}
|
|
120
|
-
dig_set!(result, key_path, value)
|
|
121
|
-
result
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
Additionally, `diff` must ensure `id:` is present even when init was nil:
|
|
127
|
-
```ruby
|
|
128
|
-
def diff(flat: flat?, ignore: [])
|
|
129
|
-
flat_result = classic_diff(flat: true, ignore: ignore)
|
|
130
|
-
return flat_result if flat
|
|
131
|
-
|
|
132
|
-
result = diff_reduce(flat_result, ignore: ignore)
|
|
133
|
-
return nil if result.nil?
|
|
134
|
-
|
|
135
|
-
# Guarantee id: is in result when cascade added something
|
|
136
|
-
result[:id] ||= get_id(keys_to_sym_deep(curr_doc(flat: true) || {}), exception: false)
|
|
137
|
-
result
|
|
138
|
-
end
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
## Bug 4 — `ecoportal-api-v2` `_cascaded_attributes_trace` (FIXED in 6a2b1b5)
|
|
144
|
-
|
|
145
|
-
Block params were `|out, (attribute, obj_k)|` (swapped) — `attribute` received the
|
|
146
|
-
trace hash instead of the attribute symbol, causing `TypeError: Hash is not a symbol`.
|
|
147
|
-
Fixed to `|(attribute, obj_k), out|`. Requires rebuild of ecoportal-api-v2 gem.
|
|
148
|
-
|
|
149
|
-
---
|
|
150
|
-
|
|
151
|
-
## Missing Methods in `DiffService` (FIXED in 0b68a6b)
|
|
152
|
-
|
|
153
|
-
`diff_reduce` calls `dig_path?`, `dig_delete!`, `dig_set!` which are defined in
|
|
154
|
-
`DoubleModel::HashHelpers` (ecoportal-api-v2) but NOT included in `DiffService`.
|
|
155
|
-
|
|
156
|
-
Added these three methods to `HashDiffNesting::InstanceMethods`. See
|
|
157
|
-
`lib/ecoportal/api/common/graphql/model/diffable/hash_diff_nesting.rb`.
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
## `diff_reduce` — ArrayModel Safety (FIXED in 0b68a6b)
|
|
162
|
-
|
|
163
|
-
`passarray :fieldName` creates `ArrayModel` subclasses inheriting from `DoubleModel`.
|
|
164
|
-
These respond to `as_update` but use the ecoportal-api-v2 signature (no kwargs).
|
|
165
|
-
|
|
166
|
-
Added guard: `next result unless obj.is_a?(Ecoportal::API::Common::GraphQL::Model)`.
|
|
167
|
-
This limits cascade-diffing to GraphQL model objects only.
|
|
168
|
-
|
|
169
|
-
---
|
|
170
|
-
|
|
171
|
-
## Recommended Implementation Order for 4.4 Completion
|
|
172
|
-
|
|
173
|
-
1. Fix `DiffService#classic_diff` key normalisation (Bug 1 — most impactful)
|
|
174
|
-
2. Fix `HashDiffNesting#dig_path?` to check key existence (Bug 2)
|
|
175
|
-
3. Redesign `diff_reduce` to ADD rather than REPLACE cascade keys (Bug 3)
|
|
176
|
-
4. Update `diff` to ensure `id:` presence and proper no-change return
|
|
177
|
-
5. Update specs to pass for all scenarios
|
|
178
|
-
|
|
179
|
-
---
|
|
180
|
-
|
|
181
|
-
## File Locations
|
|
182
|
-
|
|
183
|
-
| File | Role |
|
|
184
|
-
|------|------|
|
|
185
|
-
| `lib/.../model/diffable/diff_service.rb` | Main DiffService — `classic_diff`, `diff`, `diff_reduce` |
|
|
186
|
-
| `lib/.../model/diffable/classic_diff_service.rb` | Base: `curr_doc`, `prev_doc`, `change_diff` dispatch |
|
|
187
|
-
| `lib/.../model/diffable/hash_diff_nesting.rb` | `change_data`, `patch_*`, `dig_*` helpers |
|
|
188
|
-
| `lib/.../model/as_input.rb` | `as_input` instance + class methods, `patchVer` injection |
|
|
189
|
-
| `lib/.../graphql/logic/input.rb` | `Logic::Input.from_model` base |
|
|
190
|
-
| `lib/.../input/contractor_entity/update.rb` | `from_model` override with IdDiff reshaping |
|
|
191
|
-
| `spec/.../model/diff_service_spec.rb` | DiffService specs (WIP — partial failures) |
|
|
192
|
-
| `spec/.../model/as_input_spec.rb` | AsInput + from_model specs (WIP) |
|