Repository navigation
Make bulk write paths ADR-078 extends-chain aware - #2927
Conversation
bulk.rs's three write paths never re-bucketed properties by declaring owner, unlike crud.rs's rebucket_and_validate (used at all four of its own call sites): an extending type's inherited field stayed in the node's own bucket instead of moving to its declaring ancestor's, so a base-scoped reader never found it. - bulk_create / bulk_update now call NodeService::rebucket_and_validate (widened from private to pub(crate)) instead of the non-bucketing validate_node_against_schema. bulk_update computes changed_properties after rebucketing so the diff matches what actually lands in storage. - prepare_bulk_hierarchy_nodes (shared by bulk_create_hierarchy and bulk_create_hierarchy_in_tx) now resolves each unique type's full extends chain via resolve_field_owners and re-buckets before validating, replacing the own-schema-only fetch that never bucketed and validated with no chain. bulk_create_hierarchy_root_notify's ~60-line duplicate of that same preamble is replaced with a call to the now-fixed prepare_bulk_hierarchy_nodes. - The malformed-schema case (a present "fields" array that fails to deserialize) now returns a real NodeServiceError instead of serde_json::from_value(...).ok() silently collapsing it to "no schema, skip validation" for every row of that type. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, default on type change Two gaps in the initial bulk_update fix, both parity breaks against the single-node update paths it was meant to match: - rebucket_and_validate mutates `updated.properties` whenever the type has extends-chain fields, regardless of whether this update's NodeUpdate included `properties` at all. The store write only sent `Some(updated.properties.clone())` when the caller's update touched properties, so a content-only/lifecycle-only update could rebucket a stale wrong-bucket layout in memory, emit the rebucketed node in the NodeUpdated event, and never persist it — storage and event payload disagreed. properties are now always re-persisted (matching crud.rs's update paths, which always send the current value), and changed_properties is always computed from the pre-update baseline. - rebucket_and_validate was called with apply_defaults hardcoded to false, so a bulk_update that changes node_type into an extends-chain type never defaulted the new type's missing fields before validating — unlike every crud.rs update path, which passes node_type_changed. bulk_update now tracks and passes it too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Second review round (post address-review fixes) found no correctness bugs — three efficiency/architecture observations, none acted on:
All three are pre-existing patterns or inherent to already-accepted design elsewhere in the codebase, not regressions introduced by this PR. No further changes made; no re-review needed. |
Summary
bulk.rs's three write paths bypassedcrud.rs'srebucket_and_validatesequence, which every other write path (all four of its own call sites) goes through: chain-aware validation viaresolve_field_owners, then re-bucketing viabucket_properties_by_owner. Without it, an extending type's inherited field stayed in the node's own bucket instead of moving to its declaring ancestor's — e.g. anissuenode (extendingtask) storing{"issue": {"status": ...}}instead of{"task": {"status": ...}}— which a base-scoped reader never looks in (percrud.rs's own comment on the read side of this gap).bulk_create/bulk_update: now callNodeService::rebucket_and_validate(widened from private topub(crate)sobulk.rscan call it directly) instead of the non-bucketingvalidate_node_against_schema.bulk_updatecomputeschanged_propertiesfrom the post-rebucket state instead of a pre-rebucket snapshot, so the emitted diff matches what actually lands in storage (a no-op change for an unextended type, sincebucket_properties_by_owneris a no-op reshuffle there).prepare_bulk_hierarchy_nodes(the shared preamble forbulk_create_hierarchyandbulk_create_hierarchy_in_tx): now resolves each unique type's fullextendschain viaresolve_field_ownersand re-buckets before validating, replacing the own-schema-onlyget_schema_for_typefetch that never bucketed and validated withNonefor the chain (so an inherited field was reported as required-but-missing instead of being found in an ancestor's bucket).bulk_create_hierarchy_root_notifypreviously duplicated ~60 lines of that same (buggy) preamble inline instead of sharingprepare_bulk_hierarchy_nodes; it now delegates to the fixed function, so all three call sites (bulk_create_hierarchy,_in_tx,_root_notify) share one corrected implementation.bulk_create_hierarchy_trustedis intentionally unchanged — it deliberately skips all schema validation by design (trusted parser input), which is a separate, pre-existing posture this issue doesn't touch.prepare_bulk_hierarchy_nodes's own-type schema fetch — a schema whosefieldsarray is present but fails to deserialize — previously went throughserde_json::from_value(fields_json).ok(), collapsing a parse failure toNone, indistinguishable from "this type has no schema at all," so every row of that type was silently inserted unvalidated and un-bucketed. It now returns a realNodeServiceError::bulk_operation_failednaming the type and the parse error. (This check is scoped to the exact code this issue flagged —bulk_create/bulk_updateand a malformed ancestor schema mid-chain still resolve throughresolve_field_owners→get_schema_node, which has its own pre-existing "log a warning, treat as absent" posture shared by every ADR-078 write path,crud.rs's fourrebucket_and_validatecall sites included; widening that shared posture is a separate, larger change out of this issue's scope.)Address-review fixes (second commit)
Review caught two real parity breaks in the first
bulk_updatepass, both fixed with new regression tests (mutation-verified — each test fails when its specific fix is reverted):rebucket_and_validatemutatesupdated.propertieswhenever the type hasextends-chain fields, regardless of whether the caller'sNodeUpdateincludedpropertiesat all. The store write only sent the rebucketed value whenupdate.propertieswasSome, so a content-only/lifecycle-only update could rebucket a stale wrong-bucket layout in memory, emit it in theNodeUpdatedevent, and never persist it.bulk_updatenow always re-persists the current properties value (matchingcrud.rs's update paths, which always do the same) and always diffschanged_propertiesfrom the pre-update baseline.rebucket_and_validatewas called withapply_defaultshardcoded tofalse, so abulk_updatethat changesnode_typeinto anextends-chain type never defaulted the new type's missing fields before validating — unlike everycrud.rsupdate path, which passesnode_type_changed.bulk_updatenow tracks and passes it too.Two further review findings were evaluated and not acted on:
bulk_create's validation became stricter (full chain vs. own-type-only) doesn't hold:validate_node_against_schema(whatbulk_createcalled before this PR) already resolved the full chain viaresolve_field_owners— the issue itself says so. This PR only adds bucketing on top of already-chain-aware validation; validation strictness is unchanged.bulk_create/bulk_updateresolve each row's chain individually (no per-unique-type cache, unlikeprepare_bulk_hierarchy_nodes's newchain_cache) is accurate but describes pre-existing behavior (the priorvalidate_node_against_schemacall already resolved per-row) — a genuine but separate performance improvement, not a correctness fix this issue asked for.Out of scope (flagged, not fixed)
While writing a test against
bulk_create_hierarchywith a schema created mid-test, I foundSqliteStore::bulk_create_hierarchy's row-levelvalidate_node_typecheck gates on avalid_node_typescache that's built once atSqliteStore::newtime from the schema nodes already in the database, and isn't refreshed byhandle_create_schemaafterwards. In today's real usage this is masked becausebulk_create_hierarchy's only live callers (markdown import) only ever create core types — but it means the same method can't insert a custom extends-chain type created after daemon startup, independent of this issue's bucketing bug. Filing as a separate follow-up rather than fixing here, since it's a different mechanism (a stale in-memory cache, not property bucketing) and touching it risked growing this fix beyond its scope.Test plan
bulk_createre-buckets an inherited field into the ancestor's bucket (bulk_create_rebuckets_an_inherited_field_into_the_ancestors_bucket)bulk_updatere-buckets an inherited field, diffingchanged_propertiesagainst the post-rebucket state (bulk_update_rebuckets_an_inherited_field_into_the_ancestors_bucket)bulk_create_hierarchyre-buckets an inherited field for a bulk-imported row (bulk_create_hierarchy_rebuckets_an_inherited_field_into_the_ancestors_bucket)fieldsarray fails the bulk-hierarchy write loudly instead of silently skipping validation (bulk_create_hierarchy_fails_loudly_on_a_malformed_schema_fields_array)bulk_updatestill self-heals a stale wrong-bucket layout, and doesn't drop the fix silently (bulk_update_self_heals_a_stale_wrong_bucket_even_on_a_content_only_change)node_typechange viabulk_updatedefaults the new type's missing inherited field (bulk_update_applies_the_new_types_defaults_on_a_node_type_change)cargo test -p nodespace-core --lib— 1720 passed, 0 failedcargo clippy --all-targets -- -D warnings -D clippy::unused-async— cleanbun run quality:fix— cleantest:all,cargo build --bin nodespaced, Tauri-seam integration tests) — green, on both commitsCloses #2881.
🤖 Generated with Claude Code