diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6de150b..ad59d51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,6 @@ jobs: - name: Compile run: rebar3 compile + + - name: Cross reference analysis (undefined function calls) + run: rebar3 xref diff --git a/.gitignore b/.gitignore index 5cc8f02..201f4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ rebar3 CLAUDE.local.md resume ttb_last_config +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 57d74cb..7ce0c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,11 @@ nref spaces: - **Environment**: scaffold nrefs 1–35; permanent tier `[?LABEL_START, ?NREF_START)` = `[10001, 1000000)` holds English (10000), loader-assigned atom-labeled bootstrap nodes, and worker `init/1` seeds (graphdb_attr, graphdb_language sub-groups); runtime allocations ≥ `?NREF_START` (1000000). Boundaries are macros in `apps/graphdb/include/graphdb_nrefs.hrl` — **not** directives in `bootstrap.terms`. All graphdb node-nref allocation goes through `graphdb_nref` (first child of `graphdb_sup`): permanent phase during init, runtime phase after the `graphdb:start/2` flip. - **Project**: allocator starts at **1** — no pre-assigned nrefs, no bootstrap file, no floor needed -Cross-database nref resolution: `characterization` and `reciprocal` fields always reference environment nrefs; `target_nref` is routed to environment or project based on the arc label's `target_kind` AVP stored in the environment attribute library. This routing is encoded by the pure `graphdb_ns` module (SP1). A project is an anchor node under `Projects` (nref 5); project write operations require a `graphdb_project` session (SP1 — see `docs/designs/project-env-reference-namespace-model-design.md` and `TASKS.md` → *Multi-project sessions*). SP1 is behaviour-preserving against today's single store; physical per-project storage is SP2. +Cross-database nref resolution is **home-relative**: `characterization` and `reciprocal` (arc labels), class nodes, and attribute nodes always resolve to the environment; `source_nref` and `target_nref` route through the operation's `Home :: environment | Project`. This routing is encoded by the pure `graphdb_ns` module (`namespace_of/2`, `target_namespace/2`, `node_table/1`, `rel_table/1`). + +A project is an anchor node under `Projects` (nref 5). `graphdb_project:register_project/1` creates that anchor **and** the project's own three Mnesia tables (`nodes_`, `relationships_`, `counters_`); `open/1` resolves a project nref into a `Project` handle `#{anchor, nodes, rels, counters}`, validated by `require_project/1`. Project nref and rel-id allocation is project-local, starting at 1. Project write operations and instance reads take a `Project` handle as their first argument and reject a missing/invalid one with `{error, invalid_project}`. + +Because each project's allocator restarts at 1, project nrefs collide numerically with environment bootstrap nrefs 1–35 — a bare nref is only unique within a `Home`. SP1 (reference & namespace model) and SP2 (physical project store) are both implemented; SP3 (distribution/residency + proxy dereference) and SP4 (migration) remain. See `docs/designs/project-env-reference-namespace-model-design.md`, `docs/designs/sp2-physical-project-store-design.md`, and `TASKS.md` → *Multi-project sessions*. ### Bootstrap Nref Quick-Reference (BFS, nrefs 1–35) diff --git a/TASKS.md b/TASKS.md index 345e490..ef2370b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -155,7 +155,8 @@ Tracked follow-ups (not in the seam spec): nrefs once and allocates one rel-id pair per `add_relationship` outside the transaction; phase 3 folds the prepared list in order. Required one behaviour-preserving extraction — - `graphdb_instance:add_relationship_in_txn/9`. Design + `graphdb_instance:add_relationship_in_txn/9` (now `/10` — SP2 added a + leading `Home`). Design `docs/designs/batch-mutate-design.md`; plan `docs/superpowers/plans/2026-06-24-batch-mutate.md`. @@ -229,26 +230,26 @@ Follow-ups this design adds: (and `update_node_avps`') should refuse the whole permanent tier, consistent with `retire_node`'s `permanent_node_immutable`. -### Project boundary (architectural; prerequisite for the delete hard-delete fast-path) +### Project boundary — RESOLVED by SP1 + SP2 -The environment/project split described in the knowledge model is not -physically realized. Today there is a single shared `nodes` / -`relationships` pair; instances draw nrefs from the environment runtime -allocator (`graphdb_nref`); and the Projects category (`nref` 5) is a bare -scaffold with nothing attached. Consequently a project instance is not -reliably distinguishable from an environment instance-kind node (e.g. a -rule), and there is no project-local identity space. +This entry recorded that the environment/project split was not physically +realized: a single shared `nodes` / `relationships` pair, instances drawing +nrefs from the environment allocator, and the Projects category (`nref` 5) +a bare scaffold. **SP1 (reference & namespace model) and SP2 (physical +project store) closed this** — see *Multi-project sessions* below. Each +registered project now has its own `nodes_` / +`relationships_` / `counters_` tables and its own +allocators starting at 1, so a project instance is physically +distinguishable and there is a real project-local identity space. -Until this exists, several things stay blocked or degraded: +What this unblocks, and where each stands now: -- the delete hard-delete fast-path for project instances (slice A above); -- project-scoped rules (`graphdb_rules` returns - `project_rules_not_yet_supported`); -- any per-project isolation, addressing, or lifecycle. - -How projects are separated, identified, and addressed is an **open -architectural question to be brainstormed** — this entry records the need -and what it unblocks, not the solution. +- the delete hard-delete fast-path for project instances (slice A above) — + now unblocked, still to be done; +- project-scoped rules (`graphdb_rules` still returns + `project_rules_not_yet_supported`) — now unblocked, still to be done; +- per-project isolation and addressing — delivered by SP2. Per-project + *lifecycle* (residency, distribution, migration) is SP3/SP4. ### Retired-node purge (deferred; depends on the history/versioning feature) @@ -265,7 +266,7 @@ design — recorded here as a need, not a solution. `graphdb_mgr:update_node_avps/2` merges a list of AVP updates onto a node atomically. Tier-2 wrapper owns one `transaction/1`; tier-1 -`update_node_avps_in_txn/3` does the in-txn work. Wired as the fourth +`update_node_avps_in_txn/4` does the in-txn work. Wired as the fourth `{update_node_avps, Nref, AVPs}` kind in `mutate/1`. Design `docs/designs/slice-b-update-node-avps-design.md`. @@ -289,7 +290,7 @@ Set via `graphdb_class:add_qualifying_characteristic/3` (`#{instance_only => true}`) or a `create_class/3` initial AVP. Enforced at three class-level value-binding gates — `bind_qc_value/3`, `create_class/3`, and `update_node_avps/2` (the last covers `mutate/1`, both composing -`update_node_avps_in_txn/3`) — each returning `{error, +`update_node_avps_in_txn/4`) — each returning `{error, {instance_only_attribute, AttrNref}}`. Enforcement is local to the class node written. Design `docs/designs/slice-c-instance-only-qc-design.md`. @@ -426,8 +427,10 @@ resolver is supplied via `create_instance/4`. ## Multi-project sessions This is a four-sub-project program (design: -`docs/designs/project-env-reference-namespace-model-design.md`). SP1 (the -reference & namespace model) is done; SP2–SP4 remain. +`docs/designs/project-env-reference-namespace-model-design.md`, amended for +SP2 by `docs/designs/sp2-physical-project-store-design.md`). SP1 (the +reference & namespace model) and SP2 (the physical project store) are done; +SP3 (distribution & residency) and SP4 (migration) remain. ### SP1 — reference & namespace model — IMPLEMENTED @@ -460,19 +463,153 @@ At the API/code layer only, no `node`/`relationship` record changes: consumed by `graphdb_query`, so gating them would force the deferred query-session unification. Their per-namespace routing lands in SP2. - `proxy_coordinates/1` assumes a well-formed proxy (both AVPs present); it can - badmatch on a malformed proxy. Harmless until SP2 adds proxy **creation**; - handle the missing-AVP case there. + badmatch on a malformed proxy. Harmless until proxy **creation** lands; + handle the missing-AVP case there. SP2 did not add proxy creation — still + open, see below. - Proxy-node creation API and dereference; private environment overlays (a private overlay hiding a project's nref-5 anchor); session unification with the `graphdb_query` session. -### SP2+ — turning project scope on +**Resolved by SP2 (below):** the reads' per-namespace routing, and the +`Session`→`Project` rename (`open_session/1`/`session_project/1`/ +`require_session/1` are gone; `open/1`/`require_project/1` replace them). +**Still open after SP2:** proxy-node creation API and dereference — carried +forward to SP3 below. + +### SP2 — physical project store — IMPLEMENTED + +Plan: `.superpowers/sdd/2026-08-05-sp2-physical-project-store/`; design: +`docs/designs/sp2-physical-project-store-design.md`. + +- **Per-project physical store** — `graphdb_project:register_project/1` now + also creates the project's three Mnesia tables: `nodes_`, + `relationships_`, `counters_` (`` = the project's + environment anchor nref). `open/1` resolves a registered project nref into + a `Project` handle `#{anchor, nodes, rels, counters}`; `require_project/1` + replaces SP1's `require_session/1`. `session_project/1` is removed — the + handle itself is the only lookup, there is no ambient project context. +- **Per-project allocators from 1** — `next_nref/1` / `next_rel_id_pair/1` + via `mnesia:dirty_update_counter/3` on the project's own `counters_` + table. This closes the SP1-era nref-collision hazard: every project's + nrefs now restart at 1 in an isolated table instead of colliding + numerically with environment bootstrap nrefs 1–35 (or with any other + project's nrefs) inside a shared table. +- **Home-relative namespace routing** — `graphdb_ns:namespace_of/2` / + `target_namespace/2` take a leading `Home :: environment | Project`; + `node_table/1` / `rel_table/1` map a `Home` to its physical table atom. + `characterization`/`reciprocal` (arc labels), class nodes, and attribute + nodes always resolve to the environment; `source_nref`/`target_nref` + route through `Home`. Instance↔class membership arcs (both directions, + including the class→instance row whose `source_nref` is an environment + class nref) are written in the project's own table. +- **`graphdb_instance` fully Project-routed** — the entire write API and the + instance reads (`get_instance`, `children`, `compositional_ancestors`, + `class_of`, `class_memberships`, `resolve_value`) take a leading + `Project`, no longer namespace-agnostic. SP1's `Session` parameter is + renamed `Project` throughout and is now load-bearing (SP1 validated it + but it was inert against the single store). +- **`graphdb_mgr` Project-taking twins** — `get_node/2`, `retire_node/2`, + `unretire_node/2`, `update_node_avps/3`, `delete_node/2`, `mutate/2`, all + gated by a private `with_project/2`. `mutate/1` stays environment-only by + design (a batch mixing environment and project mutations has no single + physical table set to run against once the store is split). +- **`graphdb_query` sessions bind a Project** — `new_session/1`; + `resolve_home/2` resolves a bare nref by trying the bound project first, + falling back to the environment, logging on a genuine collision. + +**Scope addition beyond the original design** — Task 10 added Project-taking +twins for `retire_node`/`unretire_node`/`update_node_avps`/`delete_node`, +which were not in the original design's API table. Their absence would have +been a live nref-collision hazard post-SP2: a bare-Nref call against these +would resolve against the shared environment table, silently missing or +clobbering project data sitting at the same numeric nref. The twins close +that gap. + +**Known gap** — `graphdb_mgr:get_relationships/1,2` has no Project-taking +twin (the only one of the `graphdb_mgr` read/write operations touched by +SP1/SP2 without one). 15 call sites across 11 test functions in +`graphdb_instance_SUITE` / `graphdb_mgr_SUITE` work around it with direct +`mnesia:index_read(graphdb_ns:rel_table(Project), ...)`. A repo-wide grep +found zero production callers, so this is an API-completeness gap, not a +live bug — pick it up when a project-side relationship-read caller appears. + +**Open defect (Important) — query traversal silently truncates +environment-only paths under a project-bound session.** +`graphdb_query:session_read_arcs/4` and `is_scaffold_node/2` push every +*arc-discovered* nref through `resolve_home/2`, which was designed for +entry-point nrefs (where no characterization context exists). Mid-traversal +it re-guesses Home per frontier node, so a bootstrap nref that a project +happens to shadow is read from the project instead of the environment. +Reproduced: a `#q_find_path{}` between two runtime-tier environment +attributes whose only path runs through bootstrap nref 6 ("Names") returns +`{ok, no_path}` under a session bound to a project with 21 instances, while +returning the correct 2-hop path under an environment session and under a +1-instance project. Both endpoints are unshadowed, so this is **not** the +documented, tested "endpoint resolves to the project by caller intent" +behaviour of `resolve_home/2` — it is mid-traversal re-guessing. +Because project allocators start at 1, any project with ≥6 instances +shadows nref 6 and any with ≥35 shadows the whole bootstrap scaffold, so +this is high-likelihood, not theoretical. The result is silently wrong: no +error, only a collision warning in the log. + +Fix needs a design decision, not a local patch: arc-discovered nrefs *do* +have characterization context, so they should route via +`graphdb_ns:target_namespace/2` on the arc's `target_kind` rather than +guessing — with the 29/30 class↔instance membership pair as an explicit +exception, since its source and target deliberately live in different +Homes. Deferred out of SP2 as a scoped design task. + +**Open defect (Important, pre-existing, unrelated to SP2) — +`rel_id_server:seed_from_mnesia/0` calls a nonexistent function.** +`rel_id_server.erl:207` calls `mnesia:dirty_foldl/3`, which does not exist +in OTP 28 (should be `mnesia:foldl/3`). Invisible to the compiler; caught +by the `xref` gate added alongside SP2 and currently suppressed by a +single-MFA `xref_ignores` entry in `rebar.config` — **remove that entry +when this is fixed.** `initialize/0` calls it only when the DETS `counter` +key is absent, so a normal restart with an intact DETS file never hits it. +The real failure mode: if the DETS file is lost while the Mnesia +`relationships` table survives (restore, data-dir move, partial recovery), +the counter restarts at 1 and hands out ids colliding with existing primary +keys, and `mnesia:write` then **silently overwrites existing relationship +rows**. + +**Open design question — `TheKnowledgeNetwork.md` §3 says identity is +uniform; SP2 makes that false at the physical layer.** The canonical spec +states ontology nodes and project nodes "share the same identity space." +Post-SP2 each project's allocator restarts at 1, so a bare nref is unique +only within a `Home` — nref 7 in project A, nref 7 in project B, and +bootstrap nref 7 are three different nodes. The SP2 design doc does not +resolve this either; it only notes the *anchor* nref needs no new identity +scheme. Per this repo's rule that `TheKnowledgeNetwork.md` is conceptual +and does not track code, SP2 flagged rather than edited it. Needs a +deliberate call: either the spec records that identity is now +`Home`-relative, or the model asserts a globally-unique identity that the +implementation must eventually restore (e.g. via the deferred non-integer +nref indirection). Not a code defect — a spec/model decision. + +**Accepted consequence of `resolve_home/2` (documented, tested, recorded +here so it is a choice and not a surprise).** A project-bound +`graphdb_query` session resolves a bare nref to the project when it exists +in both stores — so environment nodes numerically below the project's +allocator high-water mark become unaddressable through that session. +`#q_get_node{nref = 3}` under a project with ≥3 instances returns the +project's instance 3, not the `Classes` category. This is deliberate +(caller intent) and covered by +`resolve_home_prefers_project_and_logs_on_collision`; it is called out +because the shadowed range grows with every instance created. Distinct +from the traversal defect above, which is *not* intentional. + +**Minor — `graphdb_mgr:do_get_node/1` and `/2` diverge on the retired +marker.** The `/1` (environment) form applies a retired-marker check; the +`/2` (Project) form does not. Documented at `graphdb_mgr.erl:186-188`. +Harmless today, but the asymmetry will surprise someone — make them +consistent, or document why they should differ. + +### SP3+ — distribution, residency, migration -- **Physical project store (SP2)** — separate Mnesia table set / schema per - project; per-project allocator from 1; the resolution seam gains real - env-vs-project routing; the session binds to physical storage. - **Distribution & residency (SP3)** — projects on separate nodes / locations; - environment reachability or replication at each location; proxy dereference. + environment reachability or replication at each location; proxy-node + creation API and dereference (still open post-SP2, see above). - **Migration (SP4)** — move existing instances out of the shared environment tables into project storage; reassign their nrefs. - Session state may carry multiple `{ProjectId, AnchorNref}` and a @@ -490,6 +627,33 @@ considered and rejected; see `docs/designs/f4-phase-b4-connection-firing-design. --- +## Client session + +A concept named but deliberately not yet designed. + +`Session` is intended as a higher-level, per-user or per-client container +for the state needed to disambiguate that client's operations — a primary +project, a primary language, and whatever else later proves to need +disambiguation. + +The intent is that a `Session` is used only at a high level and is +decomposed there into the concrete handles the lower-level functions take: +functions that touch a store receive a `Project`; functions that need a +specific language receive that language. Nothing below the top layer sees a +`Session`. + +No design work has been done, and none should be inferred from the name. +The container's content is driven by consumers that do not exist yet, so +its shape is deliberately left open. The existing `graphdb_query` session +is the closest thing in the tree today and is the natural candidate for +`Session` to absorb whenever this is picked up. + +Note that the parameter SP1 shipped under the name `Session` was in fact a +project handle; SP2 renames it `Project` and frees the name for this +concept. + +--- + ## Operational and lifecycle No feature dependencies; interleave at any point. diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 290ad61..d2b9f1d 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -17,8 +17,8 @@ SPDX-License-Identifier: GPL-2.0-or-later | `graphdb_sup.erl` | OTP `supervisor` behaviour callback module | | `graphdb_nref.erl` | Switchable node-nref allocation facade gen_server (first child; permanent during init) | | `graphdb_bootstrap.erl` | Bootstrap file loader + Mnesia schema creator (implemented) | -| `graphdb_ns.erl` | Pure namespace-resolution module (SP1) — `namespace_of/1`, `target_namespace/1`; the code expression of the field-role namespace map | -| `graphdb_project.erl` | Project registry + project session (SP1) — `register_project/1`, `is_project/1`, `open_session/1`, `session_project/1`, `require_session/1`; canonical project-scoped relationship API surface | +| `graphdb_ns.erl` | Pure namespace-resolution module (SP1+SP2) — `namespace_of/2`, `target_namespace/2` (home-relative), `node_table/1`, `rel_table/1`; the code expression of the field-role namespace map | +| `graphdb_project.erl` | Project registry + physical store (SP1+SP2) — `register_project/1` (also creates the project's three tables), `is_project/1`, `open/1`, `require_project/1`, `next_nref/1`, `next_rel_id_pair/1`; canonical project-scoped relationship API surface | | `graphdb_mgr.erl` | Primary coordinator gen_server (implemented — bootstrap init, read API, category guard) | | `graphdb_rules.erl` | Graph rules gen_server (implemented — F4 Phase A+B1+B2+B3+B4+B5: rule meta-ontology, create/retrieve, taxonomy walk, composition firing, propose mode, connection firing, conflict precedence) | | `graphdb_attr.erl` | Attribute library gen_server (implemented) | @@ -70,39 +70,59 @@ nref spaces: - **Environment**: scaffold nrefs 1–35; permanent tier `[?LABEL_START, ?NREF_START)` = `[10001, 1000000)` holds English (10000), loader-assigned atom-labeled nodes, and worker `init/1` seeds (graphdb_attr, graphdb_language sub-groups); runtime nrefs ≥ `?NREF_START` (1000000). Boundaries are macros in `apps/graphdb/include/graphdb_nrefs.hrl` — not directives in `bootstrap.terms`. All node-nref allocation goes through `graphdb_nref`. - **Project**: allocator starts at **1** — no pre-assigned nrefs, no bootstrap file, no floor needed -Cross-database nref resolution: `characterization` and `reciprocal` fields always reference environment nrefs; `target_nref` is routed to environment or project based on the arc label's `target_kind` AVP. +Cross-database nref resolution is **home-relative**: `characterization`/ +`reciprocal` (arc labels), class nodes, and attribute nodes always resolve to +the environment; `source_nref`/`target_nref` route through the record's +`Home :: environment | Project` (see below). -### Reference & namespace model (SP1) +### Reference & namespace model (SP1+SP2) -The environment/project separation is being built as a four-sub-project -program (design: `../../docs/designs/project-env-reference-namespace-model-design.md`; +The environment/project separation is a four-sub-project program (design: +`../../docs/designs/project-env-reference-namespace-model-design.md`; tracking: `../../TASKS.md` → *Multi-project sessions*). SP1 (reference & -namespace model) is implemented at the **API/code layer only — no -`node`/`relationship` record changes**: - -- **Namespace map** — `graphdb_ns:namespace_of/1` / `target_namespace/1` - encode which store each nref field resolves against - (`environment | project | home`). Pure; the code expression of the design's - field-role table. -- **Project identity + session** — a project is an anchor node under `Projects` - (nref 5), created by `graphdb_project:register_project/1`. A project - operation carries a `Session` (`graphdb_project:open_session/1`), validated - by `require_session/1`. Sessions are opaque values threaded as data (the - workers are shared singletons, so project context cannot be ambient). -- **Required session on the project write path** — `create_instance`, +namespace model) and SP2 (physical project store) are both implemented: + +- **Namespace map** — `graphdb_ns:namespace_of/2` / `target_namespace/2` + take a leading `Home :: environment | Project` and encode which store each + nref field resolves against. `node_table/1` / `rel_table/1` map a `Home` + to its physical Mnesia table atom. Pure; the code expression of the + design's field-role table. +- **Project identity + physical store** — a project is an anchor node under + `Projects` (nref 5). `graphdb_project:register_project/1` creates that + anchor **and** the project's own three Mnesia tables: `nodes_`, + `relationships_`, `counters_` (`` = the anchor's + environment nref). `open/1` resolves a registered project nref into a + `Project` handle `#{anchor, nodes, rels, counters}`; `require_project/1` + validates that handle. `next_nref/1` / `next_rel_id_pair/1` are the + project-local allocators — `mnesia:dirty_update_counter/3` on the + project's own `counters_` table, starting at 1. A `Project` handle is + an opaque value threaded as data (the workers are shared singletons, so + project context cannot be ambient); there is no `session_project/1` — + the handle itself is the only lookup. +- **Project write path takes `Project`, not `Session`** — `create_instance`, `add_relationship`, `remove_relationship`, `update_relationship`(`_both`), - and `add_class_membership` take `Session` as the first argument and reject a - missing/invalid one with `{error, invalid_session}`. + and `add_class_membership` take a `Project` handle as the first argument + and reject a missing/invalid one with `{error, invalid_project}`. Instance + reads (`get_instance`, `children`, `compositional_ancestors`, `class_of`, + `class_memberships`, `resolve_value`) now also take a leading `Project` — + the whole of `graphdb_instance` is Project-routed, no longer + namespace-agnostic. +- **`graphdb_mgr` Project-taking twins** — `get_node/2`, `retire_node/2`, + `unretire_node/2`, `update_node_avps/3`, `delete_node/2`, and `mutate/2` + route through a private `with_project/2` gate. The `/1` forms (and `/2` for + `update_node_avps`) stay environment-only; `mutate/1` in particular stays + environment-only by design (a batch is inherently single-table once the + physical split exists). +- **`graphdb_query` sessions bind a `Project`** — `new_session/1`. Every + bare-nref read resolves its `Home` via `resolve_home/2`: try the bound + project's table first, fall back to the environment, and log if the nref + genuinely exists in both (a real collision, resolved in the project's + favor on the theory that a project-bound session is evidence of intent). - **Proxy contract** — cross-project links are local nodes of the seeded "Remote Reference" class carrying `remote_project` / `remote_nref` AVP payload; no structural reference crosses a project boundary. Recognized by - `graphdb_instance:is_proxy/1` / `proxy_coordinates/1`. Representation only — - creation/dereference are SP2/SP3. -- **Namespace-agnostic in SP1** — `mutate/1` and the instance reads - (`get_instance` / `children` / `compositional_ancestors` / `resolve_value`), - like `get_node` / `get_relationships`, are NOT session-gated: `mutate/1` is - mixed env/project, and the reads are consumed by `graphdb_query`. Their - routing lands in SP2. Behaviour is unchanged against today's single store. + `graphdb_instance:is_proxy/1` / `proxy_coordinates/1`. Representation + only — creation/dereference remain SP3 work. --- @@ -308,38 +328,41 @@ Manages the "is a" hierarchy of class nodes in the ontology. ### `graphdb_instance` — Instance & Compositional Hierarchy -Creates and manages instance nodes in the project (instance space). +Creates and manages instance nodes in the project (instance space). Fully +Project-routed as of SP2 — every public function, reads included, takes a +leading `Project` handle (`graphdb_project:open/1`). -- `create_instance/4,5,6` (**session**, name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — requires a valid project session (SP1); atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/5` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5). -- `add_relationship/5,6,7` (**session**, source_nref, characterization_nref, target_nref, reciprocal_nref [, template_nref [, {FwdAVPs, RevAVPs}]]) — requires a valid project session (SP1); validates endpoints, resolves source/target class and template scope, and writes the two directed `kind=connection` rows in a **single** `graphdb_mgr:transaction/1` (TOCTOU-isolated). The rel-id pair is allocated up-front (outside the transaction) via `rel_id_server:get_id_pair/0`. `/4` uses the source class's default template; `/5` takes an explicit template nref; `/6` adds per-direction AVPs. -- `add_relationship_in_txn/9` (IdPair, S, C, T, R, TemplateSpec, AVPSpec, +- `create_instance/4,5,6` (**Project**, name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — requires a valid `Project` handle; atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30) into the project's own tables, then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/5` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/4` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/6` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/4` and `/5` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5). +- `add_relationship/5,6,7` (**Project**, source_nref, characterization_nref, target_nref, reciprocal_nref [, template_nref [, {FwdAVPs, RevAVPs}]]) — requires a valid `Project` handle; validates endpoints, resolves source/target class and template scope, and writes the two directed `kind=connection` rows in a **single** `graphdb_mgr:transaction/1` (TOCTOU-isolated). The rel-id pair is allocated up-front (outside the transaction) via `Project`'s own `graphdb_project:next_rel_id_pair/1`. `/5` uses the source class's default template; `/6` takes an explicit template nref; `/7` adds per-direction AVPs. +- `add_relationship_in_txn/10` (Home, IdPair, S, C, T, R, TemplateSpec, AVPSpec, TkAttr, RetAttr) — tier-1 **in-transaction** primitive (bare-mnesia twin of `add_relationship`'s transaction body; aborts on failure, never opens - its own txn). The caller allocates the rel-id pair up-front. - `do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1` (tier-3) both + its own txn). `Home :: environment | Project` picks the physical table via + `graphdb_ns`; the caller allocates the rel-id pair up-front. + `do_add_relationship/7` (tier-2) and `graphdb_mgr:mutate/1,2` (tier-3) both compose it into their single transaction. -- `remove_relationship/4,5` (**session**, source, char, target [, template]) — deletes +- `remove_relationship/4,5` (**Project**, source, char, target [, template]) — deletes **both** directed rows of a logical connection edge atomically - (connection-arcs only; no cache work). `/3` ignores template; `/4` narrows + (connection-arcs only; no cache work). `/4` ignores template; `/5` narrows by it. Identity contract: zero matches → `{error, relationship_not_found}`, more than one (duplicate edges — nothing dedups at write time) → `{error, {ambiguous_relationship, Templates}}`; a missing symmetric partner aborts `{error, {dangling_half_edge, Id}}` (never deletes a half-edge). - Tier-1 `remove_relationship_in_txn/4` + shared `resolve_forward_connection/4` - resolver are the in-txn primitives (slice E). -- `update_relationship/5,6` + `update_relationship_both/5,6` (**session**, + Tier-1 `remove_relationship_in_txn/5` + shared `resolve_forward_connection/5` + resolver (both `Home`-first) are the in-txn primitives (slice E). +- `update_relationship/5,6` + `update_relationship_both/5,6` (**Project**, source, char, target [, template], Updates | {Fwd, Rev}) — AVP-only edit of an existing connection edge, reusing slice B's `validate_avp_updates/1` + `apply_avp_updates/2`. **Remove is edge-level; AVP update is directed-row-level**: `update_relationship` edits the single row named by `(S, C, T)` (edit the reverse by naming `(T, R, S)`); `*_both` edits both directions with independent `{Fwd, Rev}` lists, composing the single tier-1 - primitive `update_relationship_avps_in_txn/5` twice. The `?ARC_TEMPLATE` - scope AVP is protected from edit. Same not-found/ambiguity arms as remove - (slice E). -- `add_class_membership/3` (**session**, instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9) + primitive `update_relationship_avps_in_txn/6` (`Home`-first) twice. The + `?ARC_TEMPLATE` scope AVP is protected from edit. Same not-found/ambiguity + arms as remove (slice E). +- `add_class_membership/3` (**Project**, instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9) - `is_proxy/1`, `proxy_coordinates/1` (SP1) — recognize a Remote Reference proxy node and extract its `{remote_project, remote_nref}` coordinates; `remote_reference_class/0` returns the seeded class nref -- `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2` — reads; **not** session-gated in SP1 (namespace-agnostic, consumed by `graphdb_query`; routing deferred to SP2) +- `get_instance/2`, `children/2`, `compositional_ancestors/2`, `class_of/2`, `class_memberships/2`, `resolve_value/3` — reads, all Project-first as of SP2 (no longer namespace-agnostic); consumed by `graphdb_query` ### `graphdb_rules` — Graph Rules (F4 Phase A + B1 + B2 + B3 + B4 + B5) @@ -411,7 +434,7 @@ translation hooks. Parses and executes graph queries. Public API: - `parse_query/1` — identity until a text DSL lands -- `new_session/0`, `refresh/1` — snapshot-semantics session lifecycle +- `new_session/0`, `new_session/1`, `refresh/1` — snapshot-semantics session lifecycle. `new_session/1` binds the session to a `Project`; `new_session/0` stays environment-only. - `execute_query/1`, `execute_query/2` — ephemeral and session-threaded - `resume/2` — continue a `#cont_path{}` (returns `{error, snapshot_expired}` if the session has been refreshed since) @@ -420,29 +443,43 @@ Parses and executes graph queries. Public API: Queries are represented as records defined in `apps/graphdb/include/graphdb_query.hrl`. Every Mnesia read goes through `session_read_node/2` or `session_read_arcs/4`; direct -`mnesia:dirty_*` calls outside those helpers are a code smell. +`mnesia:dirty_*` calls outside those helpers are a code smell. Every +bare-nref read resolves its physical table via `resolve_home/2` (SP2): +try the session's bound `Project` first, fall back to the environment, +and log a warning if the nref genuinely exists in both — the project's +copy wins on the theory that a project-bound session is caller intent. See `docs/designs/f3-graphdb-query-design.md` for the architectural contract. ### `graphdb_mgr` — Primary Coordinator -Single public entry point; delegates to the five specialized workers. - -- `create_instance/4` and `add_relationship/5` take a project `Session` first - arg (SP1) and reject a missing/invalid one via - `graphdb_project:require_session/1`, delegating to `graphdb_instance`. +Single public entry point; delegates to the specialized workers. + +- `create_instance/4` and `add_relationship/5` take a `Project` handle as the + first arg and delegate straight to `graphdb_instance` (which itself + validates the handle via `graphdb_project:require_project/1`). +- **Project-taking twins (SP2)** — `get_node/2`, `retire_node/2`, + `unretire_node/2`, `update_node_avps/3`, `delete_node/2`, and `mutate/2` + all route through a private `with_project/2` gate (validates the handle, + then runs the operation against the project's own tables). The `/1` forms + (and `/2` for `update_node_avps`) stay environment-only — they are not + aliases, they read/write the shared `nodes`/`relationships` tables. + `get_relationships/1,2` has **no** Project-taking twin yet (see + `TASKS.md` → *Multi-project sessions*). - In `init/1`: checks if `nodes` table is empty; if so, calls `graphdb_bootstrap:load/0` - Rejects any runtime request to create, modify, or delete a `category` node with `{error, category_nodes_are_immutable}` - Sequences Nref allocation → record write → Nref confirmation -- `mutate/1` — tier-3 batch entry point. Applies an ordered list of +- `mutate/1,2` — tier-3 batch entry point. Applies an ordered list of `add_relationship` / `retire_node` / `unretire_node` / `update_node_avps` / `remove_relationship` / `update_relationship` / `update_relationship_both` mutations atomically in one `transaction/1` (all commit or none). Tagged-tuple grammar; opaque bare-reason contract `{ok, [ok, ...]}` | `{error, Reason}` with whole-batch rollback; `mutate([]) -> {ok, []}`. A **plain function**, not a `gen_server:call` — it owns the transaction in the caller's process. - **Not session-gated in SP1** (a batch is mixed env/project; a project session - would over-constrain env-only batches — routing deferred to SP2). See + `mutate/2` routes an entire batch against one `Project`'s tables via + `with_project/2`; `mutate/1` stays environment-only by design — a batch + mixing environment and project mutations has no single physical table set + to run against once the store is split (SP2). See `docs/designs/batch-mutate-design.md`. - `update_node_avps/2` — merges a list of AVP updates onto a node atomically through the transaction seam (tier-2 wrapper owning one `transaction/1`; diff --git a/apps/graphdb/src/graphdb_attr.erl b/apps/graphdb/src/graphdb_attr.erl index 70963f7..fab3051 100644 --- a/apps/graphdb/src/graphdb_attr.erl +++ b/apps/graphdb/src/graphdb_attr.erl @@ -139,7 +139,9 @@ %% Seeded nref accessors seeded_nrefs/0, %% Cross-worker stamping - retro_stamp_attribute_types/0 + retro_stamp_attribute_types/0, + %% Cross-worker lookup (graphdb_language, graphdb_rules init/1 seeding) + find_attribute_by_name/2 ]). %%--------------------------------------------------------------------- @@ -159,8 +161,7 @@ %%--------------------------------------------------------------------- -ifdef(TEST). -export([ - valid_target_kind/1, - find_attribute_by_name/2 + valid_target_kind/1 ]). -endif. diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index f3973db..52d8831 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -31,8 +31,8 @@ %%--------------------------------------------------------------------- %% Rev A Date: April 2026 Author: (completion of Dallas Noyes's design) %% Initial implementation: compositional hierarchy over Mnesia. -%% Provides create_instance/3,4, add_relationship/4, get_instance/1, -%% children/1, compositional_ancestors/1, resolve_value/2. +%% Provides create_instance/4,5,6, add_relationship/5,6,7, get_instance/2, +%% children/2, compositional_ancestors/2, resolve_value/3. %%--------------------------------------------------------------------- -module(graphdb_instance). -behaviour(gen_server). @@ -130,27 +130,27 @@ add_relationship/7, add_class_membership/3, %% Tier-1 in-transaction primitive (write-path seam) - add_relationship_in_txn/9, + add_relationship_in_txn/10, remove_relationship/4, remove_relationship/5, - remove_relationship_in_txn/4, - resolve_forward_connection/4, + remove_relationship_in_txn/5, + resolve_forward_connection/5, template_of/1, update_relationship/5, update_relationship/6, - update_relationship_avps_in_txn/5, + update_relationship_avps_in_txn/6, has_template_update/1, update_relationship_both/5, update_relationship_both/6, - update_relationship_both_in_txn/6, + update_relationship_both_in_txn/7, %% Lookups - get_instance/1, - children/1, - compositional_ancestors/1, - class_of/1, - class_memberships/1, + get_instance/2, + children/2, + compositional_ancestors/2, + class_of/2, + class_memberships/2, %% Inheritance - resolve_value/2, + resolve_value/3, %% Proxy recognizer remote_reference_class/0, is_proxy/1, @@ -204,8 +204,8 @@ start_link() -> %% - instance→class membership arc pair (char=29/30) %% - compositional parent→child arc pair (char=28/27) %%----------------------------------------------------------------------------- -create_instance(Session, Name, ClassNref, ParentNref) -> - create_instance(Session, Name, ClassNref, ParentNref, fun report_only/1). +create_instance(Project, Name, ClassNref, ParentNref) -> + create_instance(Project, Name, ClassNref, ParentNref, fun report_only/1). %%----------------------------------------------------------------------------- %% create_instance(Name, ClassNref, ParentNref, ConnResolver) -> @@ -216,9 +216,9 @@ create_instance(Session, Name, ClassNref, ParentNref) -> %% outcome and nothing is connected. /4 supplies the built-in default %% conflict resolver. %%----------------------------------------------------------------------------- -create_instance(Session, Name, ClassNref, ParentNref, ConnResolver) +create_instance(Project, Name, ClassNref, ParentNref, ConnResolver) when is_function(ConnResolver, 1) -> - create_instance(Session, Name, ClassNref, ParentNref, ConnResolver, + create_instance(Project, Name, ClassNref, ParentNref, ConnResolver, graphdb_rules:default_conflict_resolver()). %%----------------------------------------------------------------------------- @@ -229,12 +229,12 @@ create_instance(Session, Name, ClassNref, ParentNref, ConnResolver) %% in the CALLER's process (where seeded_nrefs/0 is safe) and applied per %% cascade level for composition rules and per plan node for connection rules. %%----------------------------------------------------------------------------- -create_instance(Session, Name, ClassNref, ParentNref, ConnResolver, +create_instance(Project, Name, ClassNref, ParentNref, ConnResolver, ConflictResolver) when is_function(ConnResolver, 1), is_function(ConflictResolver, 1) -> - with_session(Session, fun() -> + with_project(Project, fun(P) -> gen_server:call(?MODULE, - {create_instance, Name, ClassNref, ParentNref, ConnResolver, + {create_instance, P, Name, ClassNref, ParentNref, ConnResolver, ConflictResolver}) end). @@ -256,10 +256,10 @@ report_only(_Ctx) -> defer. %% its default template removed; the caller must then use /5 to provide %% an explicit template. %%----------------------------------------------------------------------------- -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> - with_session(Session, fun() -> +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + with_project(Project, fun(P) -> gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, + {add_relationship, P, SourceNref, CharNref, TargetNref, ReciprocalNref, default, {[], []}}) end). @@ -276,11 +276,11 @@ add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> %% whose parent class is in the taxonomic ancestry of the source's %% class or the target's class. %%----------------------------------------------------------------------------- -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref) when is_integer(TemplateNref) -> - with_session(Session, fun() -> + with_project(Project, fun(P) -> gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, + {add_relationship, P, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, {[], []}}) end). @@ -297,69 +297,90 @@ add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, %% The Template AVP (#{attribute => 31, value => TemplateNref}) is %% prepended to each direction's user-supplied AVP list. %%----------------------------------------------------------------------------- -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, {FwdAVPs, RevAVPs} = AVPSpec) when is_integer(TemplateNref), is_list(FwdAVPs), is_list(RevAVPs) -> - with_session(Session, fun() -> + with_project(Project, fun(P) -> gen_server:call(?MODULE, - {add_relationship, SourceNref, CharNref, TargetNref, + {add_relationship, P, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec}) end). %%----------------------------------------------------------------------------- -%% get_instance(Nref) -> {ok, #node{}} | {error, not_found | not_an_instance} +%% get_instance(Project, Nref) -> {ok, #node{}} | {error, not_found | not_an_instance} +%% +%% Gated by with_home/2 (SP2 review wave B): Project can be `environment` +%% (graphdb_query's resolve_home/2 legitimately resolves a bare nref to the +%% environment) or a well-formed Project handle -- either way the guard runs +%% BEFORE the gen_server:call, so a malformed handle never reaches the +%% singleton. See with_home/2's own header for the full rationale. %%----------------------------------------------------------------------------- -get_instance(Nref) -> - gen_server:call(?MODULE, {get_instance, Nref}). +get_instance(Project, Nref) -> + with_home(Project, fun(P) -> + gen_server:call(?MODULE, {get_instance, P, Nref}) + end). %%----------------------------------------------------------------------------- -%% children(Nref) -> {ok, [#node{}]} | {error, term()} +%% children(Project, Nref) -> {ok, [#node{}]} | {error, term()} %% %% Returns all direct instance-kind children of the given node (uses -%% Mnesia index on parent). +%% Mnesia index on parent). Gated by with_home/2 -- see get_instance/2. %%----------------------------------------------------------------------------- -children(Nref) -> - gen_server:call(?MODULE, {children, Nref}). +children(Project, Nref) -> + with_home(Project, fun(P) -> + gen_server:call(?MODULE, {children, P, Nref}) + end). %%----------------------------------------------------------------------------- -%% compositional_ancestors(Nref) -> {ok, [#node{}]} | {error, term()} +%% compositional_ancestors(Project, Nref) -> {ok, [#node{}]} | {error, term()} %% %% Returns the ancestor chain of the given instance, starting from its %% immediate parent up through the compositional hierarchy. Stops at %% a non-instance node or the end of the chain. Returns nearest-first. -%%----------------------------------------------------------------------------- -compositional_ancestors(Nref) -> - gen_server:call(?MODULE, {compositional_ancestors, Nref}). +%% Gated by with_home/2 -- see get_instance/2. graphdb_query threads +%% resolve_home/2's result straight into this function, and that result can +%% be the atom `environment`, so this MUST accept environment as well as a +%% Project handle (plain with_project/2 would wrongly reject it). +%%----------------------------------------------------------------------------- +compositional_ancestors(Project, Nref) -> + with_home(Project, fun(P) -> + gen_server:call(?MODULE, {compositional_ancestors, P, Nref}) + end). %%----------------------------------------------------------------------------- -%% class_of(InstanceNref) -> +%% class_of(Project, InstanceNref) -> %% {ok, ClassNref} | not_found | {error, term()} %% %% Resolves the class membership of an instance via the membership arc %% (characterization=29). Returns the class nref, or `not_found` if %% the instance has no class membership arc. When an instance belongs -%% to multiple classes (see `class_memberships/1`), returns whichever +%% to multiple classes (see `class_memberships/2`), returns whichever %% Mnesia surfaces first; callers needing the full set must use -%% `class_memberships/1`. +%% `class_memberships/2`. Gated by with_home/2 -- see get_instance/2. %%----------------------------------------------------------------------------- -class_of(InstanceNref) -> - gen_server:call(?MODULE, {class_of, InstanceNref}). +class_of(Project, InstanceNref) -> + with_home(Project, fun(P) -> + gen_server:call(?MODULE, {class_of, P, InstanceNref}) + end). %%----------------------------------------------------------------------------- -%% class_memberships(InstanceNref) -> +%% class_memberships(Project, InstanceNref) -> %% {ok, [ClassNref]} | {error, term()} %% %% Returns every class the instance belongs to. Read from the %% `node.classes` cache (kept consistent with the 29-characterized %% outgoing arcs by the cache invariant — see `graphdb_mgr:verify_caches/0`). +%% Gated by with_home/2 -- see get_instance/2. %%----------------------------------------------------------------------------- -class_memberships(InstanceNref) -> - gen_server:call(?MODULE, {class_memberships, InstanceNref}). +class_memberships(Project, InstanceNref) -> + with_home(Project, fun(P) -> + gen_server:call(?MODULE, {class_memberships, P, InstanceNref}) + end). %%----------------------------------------------------------------------------- @@ -371,15 +392,15 @@ class_memberships(InstanceNref) -> %% a class already present returns ok without writing. Validates that %% the subject is an instance and the target is a class. %%----------------------------------------------------------------------------- -add_class_membership(Session, InstanceNref, ClassNref) -> - with_session(Session, fun() -> +add_class_membership(Project, InstanceNref, ClassNref) -> + with_project(Project, fun(P) -> gen_server:call(?MODULE, - {add_class_membership, InstanceNref, ClassNref}) + {add_class_membership, P, InstanceNref, ClassNref}) end). %%----------------------------------------------------------------------------- -%% resolve_value(InstanceNref, AttrNref) -> +%% resolve_value(Project, InstanceNref, AttrNref) -> %% {ok, Value, Source} | not_found | {error, term()} %% %% Full inheritance resolution following priority order: @@ -394,9 +415,15 @@ add_class_membership(Session, InstanceNref, ClassNref) -> %% direct class memberships). For Priority 3, AncNref is the %% compositional-ancestor instance node. For Priority 4, NodeNref is %% the directly-connected node (one level deep). +%% +%% Gated by with_home/2 -- see get_instance/2. graphdb_query threads +%% resolve_home/2's result straight into this function (same as +%% compositional_ancestors/2 above), so this too MUST accept `environment`. %%----------------------------------------------------------------------------- -resolve_value(InstanceNref, AttrNref) -> - gen_server:call(?MODULE, {resolve_value, InstanceNref, AttrNref}). +resolve_value(Project, InstanceNref, AttrNref) -> + with_home(Project, fun(P) -> + gen_server:call(?MODULE, {resolve_value, P, InstanceNref, AttrNref}) + end). %%----------------------------------------------------------------------------- @@ -477,48 +504,49 @@ init([]) -> %%----------------------------------------------------------------------------- %% handle_call/3 -- Creators %%----------------------------------------------------------------------------- -handle_call({create_instance, Name, ClassNref, ParentNref, Resolver, +handle_call({create_instance, Project, Name, ClassNref, ParentNref, Resolver, ConflictResolver}, _From, #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> Ctx = #{inst_attr => InstAttr, ret_attr => RetAttr, on_path => [], resolver => Resolver, conflict_resolver => ConflictResolver, - root_parent => ParentNref, root_source => undefined}, + project => Project, root_parent => ParentNref, + root_source => undefined}, {reply, do_create_instance(Name, ClassNref, ParentNref, Ctx), State}; -handle_call({add_relationship, S, C, T, R, TemplateSpec, AVPSpec}, +handle_call({add_relationship, Home, S, C, T, R, TemplateSpec, AVPSpec}, _From, State) -> {reply, - do_add_relationship(S, C, T, R, TemplateSpec, AVPSpec, State), + do_add_relationship(Home, S, C, T, R, TemplateSpec, AVPSpec, State), State}; -handle_call({add_class_membership, InstanceNref, ClassNref}, _From, +handle_call({add_class_membership, Project, InstanceNref, ClassNref}, _From, #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> - {reply, do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr), - State}; + {reply, do_add_class_membership(Project, InstanceNref, ClassNref, InstAttr, + RetAttr), State}; %%----------------------------------------------------------------------------- %% handle_call/3 -- Lookups %%----------------------------------------------------------------------------- -handle_call({get_instance, Nref}, _From, State) -> - {reply, do_get_instance(Nref), State}; +handle_call({get_instance, Project, Nref}, _From, State) -> + {reply, do_get_instance(Project, Nref), State}; -handle_call({children, Nref}, _From, State) -> - {reply, do_children(Nref), State}; +handle_call({children, Project, Nref}, _From, State) -> + {reply, do_children(Project, Nref), State}; -handle_call({compositional_ancestors, Nref}, _From, State) -> - {reply, do_compositional_ancestors(Nref), State}; +handle_call({compositional_ancestors, Project, Nref}, _From, State) -> + {reply, do_compositional_ancestors(Project, Nref), State}; -handle_call({class_of, Nref}, _From, State) -> - {reply, do_class_of(Nref), State}; +handle_call({class_of, Project, Nref}, _From, State) -> + {reply, do_class_of(Project, Nref), State}; -handle_call({class_memberships, Nref}, _From, State) -> - {reply, do_class_memberships(Nref), State}; +handle_call({class_memberships, Project, Nref}, _From, State) -> + {reply, do_class_memberships(Project, Nref), State}; %%----------------------------------------------------------------------------- %% handle_call/3 -- Inheritance %%----------------------------------------------------------------------------- -handle_call({resolve_value, InstNref, AttrNref}, _From, State) -> - {reply, do_resolve_value(InstNref, AttrNref), State}; +handle_call({resolve_value, Project, InstNref, AttrNref}, _From, State) -> + {reply, do_resolve_value(Project, InstNref, AttrNref), State}; %%----------------------------------------------------------------------------- %% handle_call/3 -- Proxy accessor @@ -655,9 +683,10 @@ class_has_name(#node{attribute_value_pairs = AVPs}, Name) -> do_create_instance(Name, ClassNref, ParentNref, Ctx) -> InstAttr = maps:get(inst_attr, Ctx), RetAttr = maps:get(ret_attr, Ctx), + Project = maps:get(project, Ctx), case do_validate_class(ClassNref, InstAttr, RetAttr) of ok -> - case do_validate_parent(ParentNref, RetAttr) of + case do_validate_parent(Project, ParentNref, RetAttr) of ok -> fire_create(Name, ClassNref, ParentNref, Ctx); {error, _} = Err -> @@ -724,9 +753,10 @@ bind_root_source(Ctx, RootNref) -> %% (the post-commit auto-connection write list — empty in this task). %%----------------------------------------------------------------------------- execute(RootName, _RootClass, RootParent, Ctx, PlanTree) -> + Project = maps:get(project, Ctx), %% Annotate the plan tree with allocated nrefs (root uses caller's Name). - InstPlan = allocate_plan(PlanTree#{name => RootName}), - {Writes, CompOutcomes} = plan_writes(InstPlan, RootParent), + InstPlan = allocate_plan(PlanTree#{name => RootName}, Project), + {Writes, CompOutcomes} = plan_writes(InstPlan, RootParent, Project), RootNref = maps:get(nref, InstPlan), Ctx1 = bind_root_source(Ctx, RootNref), case resolve_connections(InstPlan, Ctx1) of @@ -835,8 +865,9 @@ resolve_rules([{Rule, Deploy, Spec} | Rest], SourceNref, Ctx, Acc) -> %%----------------------------------------------------------------------------- connect_targets(mandatory, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, {Rows, Auto, Rep}) -> + Project = maps:get(project, Ctx), TClass = maps:get(target_class, Spec), - case partition_targets(List, TClass, SourceNref) of + case partition_targets(List, TClass, SourceNref, Project) of {error, Reason} -> %% an invalid target on a committed mandatory rule aborts the create {error, {invalid_connection_target, Reason}, @@ -851,7 +882,8 @@ connect_targets(mandatory, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, ToWrite = cap(Valid, Max), Template = maps:get(template, Deploy), {NewRows, NewOuts} = - mandatory_rows(ToWrite, SourceNref, Spec, Template), + mandatory_rows(ToWrite, SourceNref, Spec, Template, + Project), Rep1 = lists:foldl( fun(O, R) -> add_outcome(R, Rule, Deploy, O) end, Rep, NewOuts), @@ -862,8 +894,9 @@ connect_targets(mandatory, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, connect_targets(auto, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, {Rows, Auto, Rep}) -> + Project = maps:get(project, Ctx), TClass = maps:get(target_class, Spec), - {Valid, Invalid} = split_valid(List, TClass, SourceNref), + {Valid, Invalid} = split_valid(List, TClass, SourceNref, Project), %% auto does NOT enforce the floor -- Min is ignored; only Max caps. {_Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), ToConnect = cap(Valid, Max), @@ -879,30 +912,31 @@ connect_targets(auto, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, %% valid targets are queued for the post-commit writer AutoEntry = #{rule => Rule, deploy => Deploy, spec => Spec, source => SourceNref, template => maps:get(template, Deploy), - targets => ToConnect}, + targets => ToConnect, project => Project}, resolve_rules(Rest, SourceNref, Ctx, {Rows, Auto ++ [AutoEntry], Rep1}). -%% split_valid(List, TClass, SourceNref) -> +%% split_valid(List, TClass, SourceNref, Project) -> %% {Valid :: [Target], Invalid :: [{Target, Reason}]} %% For AUTO: partition rather than abort -- invalids are reported, valids written. -split_valid(List, TClass, SourceNref) -> +split_valid(List, TClass, SourceNref, Project) -> lists:foldr( fun(T, {Vs, Is}) -> - case validate_target(T, TClass, SourceNref) of + case validate_target(T, TClass, SourceNref, Project) of ok -> {[T | Vs], Is}; {error, Reason} -> {Vs, [{T, Reason} | Is]} end end, {[], []}, List). -%% partition_targets(List, TargetClass, SourceNref) -> {ok, [Target]} | {error, R} +%% partition_targets(List, TargetClass, SourceNref, Project) -> +%% {ok, [Target]} | {error, R} %% For a MANDATORY rule: the first invalid target aborts with its reason; an %% all-valid list returns the (order-preserved) valid targets. -partition_targets([], _TClass, _SourceNref) -> +partition_targets([], _TClass, _SourceNref, _Project) -> {ok, []}; -partition_targets([T | Rest], TClass, SourceNref) -> - case validate_target(T, TClass, SourceNref) of +partition_targets([T | Rest], TClass, SourceNref, Project) -> + case validate_target(T, TClass, SourceNref, Project) of ok -> - case partition_targets(Rest, TClass, SourceNref) of + case partition_targets(Rest, TClass, SourceNref, Project) of {ok, Vs} -> {ok, [T | Vs]}; {error, _} = Err -> Err end; @@ -914,18 +948,18 @@ partition_targets([T | Rest], TClass, SourceNref) -> cap(List, unbounded) -> List; cap(List, Max) -> lists:sublist(List, Max). -%% mandatory_rows(Targets, SourceNref, Spec, Template) -> {Rows, Outcomes} +%% mandatory_rows(Targets, SourceNref, Spec, Template, Project) -> {Rows, Outcomes} %% Builds the connection rows for each target plus a (tentative) `connected` %% outcome indexed 1..N. Outcomes are returned to the report only on commit. -mandatory_rows(Targets, SourceNref, Spec, Template) -> +mandatory_rows(Targets, SourceNref, Spec, Template, Project) -> Char = maps:get(characterization, Spec), Recip = maps:get(reciprocal, Spec), TClass = maps:get(target_class, Spec), {Rows, Outs, _} = lists:foldl( fun(T, {RAcc, OAcc, I}) -> TNref = target_nref(T), - Rows0 = build_connection_rows(SourceNref, Char, TNref, Recip, - Template, target_avps(T)), + Rows0 = build_connection_rows(Project, SourceNref, Char, TNref, + Recip, Template, target_avps(T)), Out = #{source => SourceNref, index => I, status => connected, target => TNref, characterization => Char, target_class => TClass}, @@ -947,16 +981,16 @@ conn_fail(Reason, CulpritRule, Spec, RepAcc) -> target_class => maps:get(target_class, Spec)}). %%----------------------------------------------------------------------------- -%% validate_target(Target, TargetClass, SourceNref) -> ok | {error, Reason} +%% validate_target(Target, TargetClass, SourceNref, Project) -> ok | {error, Reason} %% %% Target is a bare nref or {Nref, {Fwd, Rev}}. Valid iff the nref exists, is a %% kind=instance node, and is an instance of TargetClass or a subclass of it. %% No self-check is needed: the source is uncommitted at RESOLVE, so a readable %% instance is necessarily distinct from it. %%----------------------------------------------------------------------------- -validate_target(Target, TargetClass, _SourceNref) -> +validate_target(Target, TargetClass, _SourceNref, Project) -> Nref = target_nref(Target), - case mnesia:dirty_read(nodes, Nref) of + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of [#node{kind = instance, classes = Classes}] -> case lists:any( fun(C) -> graphdb_class:class_in_ancestry(TargetClass, C) end, @@ -1014,15 +1048,16 @@ fire_connections(AutoConnPlan) -> %% fire_auto_connection(AutoEntry, Acc) -> report() fire_auto_connection(#{rule := Rule, deploy := Deploy, spec := Spec, source := SourceNref, template := Template, - targets := Targets}, Acc) -> + targets := Targets, project := Project}, Acc) -> Char = maps:get(characterization, Spec), Recip = maps:get(reciprocal, Spec), TClass = maps:get(target_class, Spec), {_I, Acc1} = lists:foldl( fun(T, {I, A}) -> TNref = target_nref(T), - Outcome = case write_connection_arcs(SourceNref, Char, TNref, Recip, - Template, target_avps(T)) of + Outcome = case write_connection_arcs(Project, SourceNref, Char, + TNref, Recip, Template, + target_avps(T)) of ok -> #{source => SourceNref, index => I, status => connected, target => TNref, characterization => Char, @@ -1037,47 +1072,49 @@ fire_auto_connection(#{rule := Rule, deploy := Deploy, spec := Spec, Acc1. %%----------------------------------------------------------------------------- -%% allocate_plan(PlanNode) -> InstPlanNode (same tree + nref per node) +%% allocate_plan(PlanNode, Project) -> InstPlanNode (same tree + nref per node) %% -%% Depth-first pre-order walk: allocates one nref per node OUTSIDE the -%% Mnesia transaction. +%% Depth-first pre-order walk: allocates one nref per node from Project's +%% own counter, OUTSIDE the Mnesia transaction. %%----------------------------------------------------------------------------- -allocate_plan(#{mandatory_children := Kids} = Node) -> - Nref = graphdb_nref:get_next(), +allocate_plan(#{mandatory_children := Kids} = Node, Project) -> + Nref = graphdb_project:next_nref(Project), Node#{nref => Nref, - mandatory_children => [allocate_plan(K) || K <- Kids]}. + mandatory_children => [allocate_plan(K, Project) || K <- Kids]}. %%----------------------------------------------------------------------------- -%% plan_writes(InstPlan, RootParent) -> {Writes, Outcomes} +%% plan_writes(InstPlan, RootParent, Project) -> {Writes, Outcomes} %% %% Pre-order DFS over the instantiated plan tree. The root emits only its %% own five records. Each mandated descendant emits its records plus one %% `fired` outcome under its rule, indexed 1..N within that rule. %%----------------------------------------------------------------------------- plan_writes(#{nref := RootNref, class := Class, name := Name, - mandatory_children := Kids}, RootParent) -> - Acc0 = {instance_records(RootNref, Class, Name, RootParent), []}, - write_children(Kids, RootNref, Acc0). + mandatory_children := Kids}, RootParent, Project) -> + Acc0 = {instance_records(RootNref, Class, Name, RootParent, Project), []}, + write_children(Kids, RootNref, Acc0, Project). %%----------------------------------------------------------------------------- -%% write_children(Siblings, OwnerNref, {Writes, Outcomes}) -> {Writes, Outcomes} +%% write_children(Siblings, OwnerNref, {Writes, Outcomes}, Project) -> +%% {Writes, Outcomes} %% %% Numbers siblings within their mandating rule (1-based), emits each %% child's records + fired outcome (with real `deploy` map), then recurses %% into the child's own mandatory children. %%----------------------------------------------------------------------------- -write_children(Siblings, OwnerNref, Acc) -> +write_children(Siblings, OwnerNref, Acc, Project) -> {_Counts, Result} = lists:foldl( fun(#{nref := CNref, class := CClass, name := CName, rule := Rule, deploy := Deploy, mandatory_children := GKids}, {Counts, {W, O}}) -> Idx = maps:get(rule_key(Rule), Counts, 0) + 1, - W1 = W ++ instance_records(CNref, CClass, CName, OwnerNref), + W1 = W ++ instance_records(CNref, CClass, CName, OwnerNref, + Project), O1 = add_outcome(O, Rule, Deploy, #{owner => OwnerNref, index => Idx, status => fired, child => CNref}), - {W2, O2} = write_children(GKids, CNref, {W1, O1}), + {W2, O2} = write_children(GKids, CNref, {W1, O1}, Project), {Counts#{rule_key(Rule) => Idx}, {W2, O2}} end, {#{}, Acc}, Siblings), Result. @@ -1085,16 +1122,42 @@ write_children(Siblings, OwnerNref, Acc) -> rule_key(#node{nref = N}) -> N. %%----------------------------------------------------------------------------- -%% instance_records(Nref, ClassNref, Name, ParentNref) -> [{Tab, Rec}] -%% -%% Builds the five Mnesia records for one instance node. Rel-IDs are -%% allocated here (outside the transaction by the allocate_plan caller -%% chain; this function is called from plan_writes/write_children which -%% are invoked in execute/5 before the transaction). -%%----------------------------------------------------------------------------- -instance_records(Nref, ClassNref, Name, ParentNref) -> - {MembId1, MembId2} = rel_id_server:get_id_pair(), - {CompId1, CompId2} = rel_id_server:get_id_pair(), +%% instance_records(Nref, ClassNref, Name, ParentNref, Project) -> [{Tab, Rec}] +%% +%% Builds the five Mnesia records for one instance node. Rel-IDs come from +%% Project's own counter (allocated here, outside the transaction, same as +%% before -- only the source changed from rel_id_server to +%% graphdb_project:next_rel_id_pair/1). Node record and both composition rows +%% tag their table via graphdb_ns:node_table/rel_table; the instantiation +%% pair's class-side row (C2I) still writes to Project's own relationships +%% table -- the row lives wherever its SOURCE lives (source_nref = ClassNref +%% would suggest environment, but per the design's arc-shape table the +%% class->instance membership row's source_nref routes environment while its +%% home store is still recorded with the instance -- see Task 5's +%% add_relationship_in_txn for the general rule; membership rows are written +%% here directly rather than through that general primitive, and both rows +%% of this specific arc pair are written to the SAME table as the instance +%% they describe, matching how SP1/pre-SP2 always wrote them together). +%% +%% Design note: the class->instance membership row (C2I, whose source_nref is +%% the environment ClassNref) is written to the PROJECT's relationships +%% table, not split across two stores. This is a deliberate, narrow +%% exception to "route by source's home": SP2 keeps both directions of one +%% arc-write co-located with the instance they describe so a project remains +%% a genuinely single relocatable unit (design §2's stated goal) -- a +%% project's full membership history lives with it. Reads of this row still +%% resolve correctly under the home-relative rule because a reader who +%% already knows the row is a char=30 reciprocal reaches it via target_nref +%% from the project side, never by scanning the environment's relationships +%% table by source_nref=ClassNref for this purpose. add_relationship_in_txn +%% (Task 5) does NOT follow this exception -- it is the general +%% connection-arc primitive and routes each row by its own endpoints. +%%----------------------------------------------------------------------------- +instance_records(Nref, ClassNref, Name, ParentNref, Project) -> + {MembId1, MembId2} = graphdb_project:next_rel_id_pair(Project), + {CompId1, CompId2} = graphdb_project:next_rel_id_pair(Project), + NodesTab = graphdb_ns:node_table(Project), + RelsTab = graphdb_ns:rel_table(Project), NameAVP = #{attribute => ?NAME_ATTR_INSTANCE, value => Name}, Node = #node{nref = Nref, kind = instance, parents = [ParentNref], classes = [ClassNref], attribute_value_pairs = [NameAVP]}, @@ -1114,8 +1177,8 @@ instance_records(Nref, ClassNref, Name, ParentNref) -> C2P = #relationship{id = CompId2, kind = composition, source_nref = Nref, characterization = ?ARC_INST_PARENT, target_nref = ParentNref, reciprocal = ?ARC_INST_CHILD, avps = []}, - [{nodes, Node}, {relationships, I2C}, {relationships, C2I}, - {relationships, P2C}, {relationships, C2P}]. + [{NodesTab, Node}, {RelsTab, I2C}, {RelsTab, C2I}, + {RelsTab, P2C}, {RelsTab, C2P}]. %%----------------------------------------------------------------------------- %% fire_auto(InstPlan, Ctx) -> report() @@ -1318,13 +1381,15 @@ is_retired(AVPs, RetAttr) -> %%----------------------------------------------------------------------------- -%% do_validate_parent(ParentNref, RetAttr) -> ok | {error, term()} +%% do_validate_parent(Project, ParentNref, RetAttr) -> ok | {error, term()} %% %% Validates that ParentNref references an existing node and is not -%% retired (retired => true AVP under RetAttr). +%% retired (retired => true AVP under RetAttr). The compositional parent is +%% always another instance in the same project (design §6, "node.parents ... +%% home-relative"), so this reads Project's own nodes table. %%----------------------------------------------------------------------------- -do_validate_parent(ParentNref, RetAttr) -> - case mnesia:dirty_read(nodes, ParentNref) of +do_validate_parent(Project, ParentNref, RetAttr) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), ParentNref) of [#node{attribute_value_pairs = AVPs}] -> case is_retired(AVPs, RetAttr) of true -> {error, {parent_retired, ParentNref}}; @@ -1335,29 +1400,33 @@ do_validate_parent(ParentNref, RetAttr) -> %%----------------------------------------------------------------------------- -%% do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, +%% do_add_relationship(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, %% TemplateSpec, AVPSpec, State) -> ok | {error, term()} %% %% Validates endpoints, resolves class membership and template scope, then %% writes the two directed connection rows -- all in one graphdb_mgr:transaction/1 %% (TOCTOU-isolated). The rel-id pair is allocated up-front, outside the -%% transaction: get_id_pair is a gen_server call and must never run inside an -%% mnesia fun. A validation abort orphans that pair -- harmless (allocate- -%% outside-transaction doctrine). Phase order: validate endpoints -> -%% resolve classes -> resolve template -> validate scope -> write. +%% transaction: get_id_pair/next_rel_id_pair is a gen_server call and must +%% never run inside an mnesia fun. A validation abort orphans that pair -- +%% harmless (allocate-outside-transaction doctrine). Phase order: validate +%% endpoints -> resolve classes -> resolve template -> validate scope -> write. %%----------------------------------------------------------------------------- -do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, +do_add_relationship(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, State) -> TkAttr = State#state.target_kind_avp_nref, RetAttr = State#state.retired_nref, - %% Allocate the rel-id pair up-front, OUTSIDE the transaction: get_id_pair - %% is a gen_server call and must never run inside an mnesia fun. A - %% validation abort inside the primitive orphans this pair -- harmless - %% (allocate-outside-transaction doctrine). - IdPair = rel_id_server:get_id_pair(), + %% Allocate the rel-id pair up-front, OUTSIDE the transaction: + %% get_id_pair/next_rel_id_pair is a gen_server call and must never run + %% inside an mnesia fun. A validation abort inside the primitive orphans + %% this pair -- harmless (allocate-outside-transaction doctrine). + IdPair = case Home of + environment -> rel_id_server:get_id_pair(); + _ -> graphdb_project:next_rel_id_pair(Home) + end, case graphdb_mgr:transaction(fun() -> - add_relationship_in_txn(IdPair, SourceNref, CharNref, TargetNref, - ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) + add_relationship_in_txn(Home, IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, + RetAttr) end) of {ok, ok} -> ok; {error, _} = Err -> Err @@ -1365,47 +1434,53 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, %%----------------------------------------------------------------------------- -%% add_relationship_in_txn(IdPair, Source, Char, Target, Reciprocal, +%% add_relationship_in_txn(Home, IdPair, Source, Char, Target, Reciprocal, %% TemplateSpec, AVPSpec, TkAttr, RetAttr) -> ok %% %% Tier-1 write-path primitive. Must run inside an active mnesia transaction; %% never opens its own. Validates endpoints, resolves source/target class and %% template scope, then writes the two directed connection rows -- all with %% bare mnesia ops, signalling any domain failure via mnesia:abort/1. The -%% rel-id pair must be allocated by the caller (get_id_pair is a gen_server -%% call and must never run inside an mnesia fun). Composes into a caller's -%% single transaction (the write-path seam's tier-1 contract); used by both -%% do_add_relationship/7 (tier-2) and graphdb_mgr:mutate/1 (tier-3). -%% Phase order: validate endpoints -> resolve classes -> resolve template -> -%% validate scope -> write. -%%----------------------------------------------------------------------------- -add_relationship_in_txn({_Id1, _Id2} = IdPair, SourceNref, CharNref, +%% rel-id pair must be allocated by the caller (get_id_pair/next_rel_id_pair +%% is a gen_server call and must never run inside an mnesia fun). Composes +%% into a caller's single transaction (the write-path seam's tier-1 +%% contract); used by both do_add_relationship/8 (tier-2) and +%% graphdb_mgr:mutate/1,2 (tier-3). Home routes SourceNref/TargetNref +%% (via graphdb_ns:node_table/1, rel_table/1); CharNref/ReciprocalNref always +%% read from the literal environment (they are always environment attribute +%% nrefs). Phase order: validate endpoints -> resolve classes -> resolve +%% template -> validate scope -> write. +%%----------------------------------------------------------------------------- +add_relationship_in_txn(Home, {_Id1, _Id2} = IdPair, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) -> - ok = validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref, + ok = validate_arc_endpoints_in_txn(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, TkAttr, RetAttr), {SourceClass, TargetClass} = - resolve_arc_classes_in_txn(SourceNref, TargetNref), + resolve_arc_classes_in_txn(Home, SourceNref, TargetNref), TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass), - Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, + Rows = build_connection_rows(Home, IdPair, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec), lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, Rows). %%----------------------------------------------------------------------------- -%% resolve_forward_connection(SourceNref, CharNref, TargetNref, TemplateSpec) -%% -> {ok, #relationship{}} | not_found | {ambiguous, [TemplateNref]} +%% resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, +%% TemplateSpec) -> {ok, #relationship{}} | not_found +%% | {ambiguous, [TemplateNref]} %% %% Tier-1 in-transaction helper. Finds the directed connection row(s) whose %% (source, characterization, target) match, narrowed by TemplateSpec %% (`any` = ignore template; an integer = match that template AVP). Classifies %% none / exactly-one / many; the ambiguous case carries each matching row's %% template so a /3 caller can re-issue as /4. Reads only; never aborts. +%% SourceNref routes through Home's relationships table (SP2); CharNref and +%% TargetNref are only compared in-memory against already-read rows. %%----------------------------------------------------------------------------- -resolve_forward_connection(SourceNref, CharNref, TargetNref, TemplateSpec) -> - Rows = mnesia:index_read(relationships, SourceNref, +resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, TemplateSpec) -> + Rows = mnesia:index_read(graphdb_ns:rel_table(Home), SourceNref, #relationship.source_nref), Matches = [R || R <- Rows, R#relationship.kind =:= connection, @@ -1431,8 +1506,8 @@ template_of(#relationship{avps = AVPs}) -> end. %%----------------------------------------------------------------------------- -%% remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec) -%% -> ok (aborts the enclosing transaction on any failure) +%% remove_relationship_in_txn(Home, SourceNref, CharNref, TargetNref, +%% TemplateSpec) -> ok (aborts the enclosing transaction on any failure) %% %% Tier-1 primitive. Must run inside an active mnesia transaction; never opens %% its own. Resolves the forward row (relationship_not_found / @@ -1440,10 +1515,11 @@ template_of(#relationship{avps = AVPs}) -> %% (T, R, S) under the same concrete template, and deletes both rows. A %% missing partner is an integrity violation -- aborts {dangling_half_edge, Id} %% rather than deleting a half-edge. Used by remove_relationship/3,4 (tier-2) -%% and graphdb_mgr:mutate/1 (tier-3). +%% and graphdb_mgr:mutate/1 (tier-3). Both rows live in Home's relationships +%% table (SP2). %%----------------------------------------------------------------------------- -remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec) -> - case resolve_forward_connection(SourceNref, CharNref, TargetNref, +remove_relationship_in_txn(Home, SourceNref, CharNref, TargetNref, TemplateSpec) -> + case resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, TemplateSpec) of not_found -> mnesia:abort(relationship_not_found); @@ -1452,11 +1528,12 @@ remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec) -> {ok, Fwd} -> Recip = Fwd#relationship.reciprocal, Tmpl = template_of(Fwd), - case resolve_forward_connection(TargetNref, Recip, SourceNref, + case resolve_forward_connection(Home, TargetNref, Recip, SourceNref, Tmpl) of {ok, Rev} -> - ok = mnesia:delete_object(relationships, Fwd, write), - ok = mnesia:delete_object(relationships, Rev, write); + RelsTab = graphdb_ns:rel_table(Home), + ok = mnesia:delete_object(RelsTab, Fwd, write), + ok = mnesia:delete_object(RelsTab, Rev, write); _ -> mnesia:abort({dangling_half_edge, Fwd#relationship.id}) end @@ -1472,18 +1549,18 @@ remove_relationship_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec) -> %% narrows by an explicit template. Plain functions owning one %% graphdb_mgr:transaction/1 in the caller's process (no gen_server state). %%----------------------------------------------------------------------------- -remove_relationship(Session, SourceNref, CharNref, TargetNref) -> - with_session(Session, fun() -> +remove_relationship(Project, SourceNref, CharNref, TargetNref) -> + with_project(Project, fun(P) -> txn_ok(fun() -> - remove_relationship_in_txn(SourceNref, CharNref, TargetNref, any) + remove_relationship_in_txn(P, SourceNref, CharNref, TargetNref, any) end) end). -remove_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref) +remove_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref) when is_integer(TemplateNref) -> - with_session(Session, fun() -> + with_project(Project, fun(P) -> txn_ok(fun() -> - remove_relationship_in_txn(SourceNref, CharNref, TargetNref, + remove_relationship_in_txn(P, SourceNref, CharNref, TargetNref, TemplateNref) end) end). @@ -1495,18 +1572,45 @@ txn_ok(Fun) -> {error, _} = Err -> Err end. -%% Gate a project operation on a valid project session (SP1). A missing or -%% malformed session short-circuits with {error, invalid_session}; a valid -%% one runs Fun. The session is required but otherwise inert against today's -%% single store (SP2 gives it physical routing). -with_session(Session, Fun) -> - case graphdb_project:require_session(Session) of - ok -> Fun(); +%% Gate a project operation on a valid Project handle. A missing or +%% malformed handle short-circuits with {error, invalid_project}; a valid +%% one runs Fun(Project). SP2: Fun now receives Project so it can route. +with_project(Project, Fun) when is_function(Fun, 1) -> + case graphdb_project:require_project(Project) of + ok -> Fun(Project); {error, _} = Err -> Err end. %%----------------------------------------------------------------------------- -%% update_relationship_avps_in_txn(S, C, T, TemplateSpec, Updates) -> ok +%% with_home(Home, Fun) -> term() +%% +%% Read-path twin of with_project/2 (SP2 review wave B fix). The write path +%% is Project-only, so with_project/2's require_project/1 gate is correct +%% there. Reads are different: graphdb_query:resolve_home/2 is the only +%% caller that hands this module a bare-nref-resolved Home, and its result +%% can legitimately be the atom `environment` (an ordinary environment- +%% resident nref) as well as a Project handle -- graphdb_project: +%% require_project/1 REJECTS `environment` outright, so gating the six reads +%% (get_instance/2, children/2, compositional_ancestors/2, class_of/2, +%% class_memberships/2, resolve_value/3) with plain with_project/2 would +%% break every graphdb_query call that resolves to the environment. +%% +%% with_home/2 accepts environment OR a well-formed Project map and rejects +%% only genuine garbage (an unregistered nref, a bare atom that isn't +%% `environment`, a malformed map, etc.) with {error, invalid_project} -- +%% BEFORE the gen_server:call, so a bad handle never reaches the singleton +%% and trips graphdb_ns:node_table/1's bare two-clause match from inside the +%% worker process (the exact hazard with_project/2 already guards against on +%% the write path; see graphdb_mgr.erl's with_project/2 header for the same +%% reasoning applied there). +%%----------------------------------------------------------------------------- +with_home(environment, Fun) when is_function(Fun, 1) -> + Fun(environment); +with_home(Project, Fun) when is_function(Fun, 1) -> + with_project(Project, Fun). + +%%----------------------------------------------------------------------------- +%% update_relationship_avps_in_txn(Home, S, C, T, TemplateSpec, Updates) -> ok %% (aborts the enclosing transaction on any failure) %% %% Tier-1 primitive: edits the AVPs of the SINGLE directed connection row named @@ -1514,16 +1618,16 @@ with_session(Session, Fun) -> %% apply_avp_updates/2 (merge/upsert/delete). The ?ARC_TEMPLATE scope AVP is %% protected -- any update targeting it aborts. Same not-found / ambiguity %% arms as remove. The Template AVP at index 0 survives because no update may -%% reference it. +%% reference it. The row lives in Home's relationships table (SP2). %%----------------------------------------------------------------------------- -update_relationship_avps_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, - Updates) -> +update_relationship_avps_in_txn(Home, SourceNref, CharNref, TargetNref, + TemplateSpec, Updates) -> case has_template_update(Updates) of true -> mnesia:abort({protected_relationship_avp, ?ARC_TEMPLATE}); false -> - case resolve_forward_connection(SourceNref, CharNref, TargetNref, - TemplateSpec) of + case resolve_forward_connection(Home, SourceNref, CharNref, + TargetNref, TemplateSpec) of not_found -> mnesia:abort(relationship_not_found); {ambiguous, Templates} -> @@ -1531,7 +1635,7 @@ update_relationship_avps_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, {ok, Row} -> New = graphdb_mgr:apply_avp_updates( Row#relationship.avps, Updates), - mnesia:write(relationships, + mnesia:write(graphdb_ns:rel_table(Home), Row#relationship{avps = New}, write) end end. @@ -1548,24 +1652,24 @@ has_template_update(Updates) -> %% (S, C, T). Validates the update grammar client-side (slice B), then owns %% one transaction. %%----------------------------------------------------------------------------- -update_relationship(Session, SourceNref, CharNref, TargetNref, Updates) -> - with_session(Session, fun() -> - do_update_relationship(SourceNref, CharNref, TargetNref, any, Updates) +update_relationship(Project, SourceNref, CharNref, TargetNref, Updates) -> + with_project(Project, fun(P) -> + do_update_relationship(P, SourceNref, CharNref, TargetNref, any, Updates) end). -update_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref, +update_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref, Updates) when is_integer(TemplateNref) -> - with_session(Session, fun() -> - do_update_relationship(SourceNref, CharNref, TargetNref, TemplateNref, + with_project(Project, fun(P) -> + do_update_relationship(P, SourceNref, CharNref, TargetNref, TemplateNref, Updates) end). -do_update_relationship(SourceNref, CharNref, TargetNref, TemplateSpec, +do_update_relationship(Home, SourceNref, CharNref, TargetNref, TemplateSpec, Updates) -> case graphdb_mgr:validate_avp_updates(Updates) of ok -> txn_ok(fun() -> - update_relationship_avps_in_txn(SourceNref, CharNref, + update_relationship_avps_in_txn(Home, SourceNref, CharNref, TargetNref, TemplateSpec, Updates) end); {error, _} = Err -> @@ -1573,18 +1677,19 @@ do_update_relationship(SourceNref, CharNref, TargetNref, TemplateSpec, end. %%----------------------------------------------------------------------------- -%% update_relationship_both_in_txn(S, C, T, TemplateSpec, FwdUpdates, +%% update_relationship_both_in_txn(Home, S, C, T, TemplateSpec, FwdUpdates, %% RevUpdates) -> ok (aborts the enclosing transaction on any failure) %% %% Tier-1 composite: resolves the forward row to discover the reciprocal label %% and the concrete template, then edits both directed rows -- FwdUpdates on %% (S, C, T), RevUpdates on (T, R, S) -- EACH through the single-edge primitive -%% (update_relationship_avps_in_txn/5). Reused by the tier-2 wrappers and by -%% graphdb_mgr:mutate/1. The two directions' updates are independent. +%% (update_relationship_avps_in_txn/6). Reused by the tier-2 wrappers and by +%% graphdb_mgr:mutate/1. The two directions' updates are independent. Both +%% rows live in Home's relationships table (SP2). %%----------------------------------------------------------------------------- -update_relationship_both_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, - FwdUpdates, RevUpdates) -> - case resolve_forward_connection(SourceNref, CharNref, TargetNref, +update_relationship_both_in_txn(Home, SourceNref, CharNref, TargetNref, + TemplateSpec, FwdUpdates, RevUpdates) -> + case resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, TemplateSpec) of not_found -> mnesia:abort(relationship_not_found); @@ -1597,13 +1702,13 @@ update_relationship_both_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, %% so a corrupt half-edge surfaces {dangling_half_edge, Id} (the %% same arm as remove) rather than a misleading relationship_not_found %% from the second single-edge edit. - case resolve_forward_connection(TargetNref, Recip, SourceNref, + case resolve_forward_connection(Home, TargetNref, Recip, SourceNref, Tmpl) of {ok, _Rev} -> - ok = update_relationship_avps_in_txn(SourceNref, CharNref, - TargetNref, Tmpl, FwdUpdates), - ok = update_relationship_avps_in_txn(TargetNref, Recip, - SourceNref, Tmpl, RevUpdates); + ok = update_relationship_avps_in_txn(Home, SourceNref, + CharNref, TargetNref, Tmpl, FwdUpdates), + ok = update_relationship_avps_in_txn(Home, TargetNref, + Recip, SourceNref, Tmpl, RevUpdates); _ -> mnesia:abort({dangling_half_edge, Fwd#relationship.id}) end @@ -1619,24 +1724,24 @@ update_relationship_both_in_txn(SourceNref, CharNref, TargetNref, TemplateSpec, %% transaction. The two update lists are independent (forward need not mirror %% reverse). Both lists are validated client-side (slice B grammar). %%----------------------------------------------------------------------------- -update_relationship_both(Session, SourceNref, CharNref, TargetNref, +update_relationship_both(Project, SourceNref, CharNref, TargetNref, {Fwd, Rev}) -> - with_session(Session, fun() -> - do_update_both(SourceNref, CharNref, TargetNref, any, Fwd, Rev) + with_project(Project, fun(P) -> + do_update_both(P, SourceNref, CharNref, TargetNref, any, Fwd, Rev) end). -update_relationship_both(Session, SourceNref, CharNref, TargetNref, TemplateNref, +update_relationship_both(Project, SourceNref, CharNref, TargetNref, TemplateNref, {Fwd, Rev}) when is_integer(TemplateNref) -> - with_session(Session, fun() -> - do_update_both(SourceNref, CharNref, TargetNref, TemplateNref, Fwd, Rev) + with_project(Project, fun(P) -> + do_update_both(P, SourceNref, CharNref, TargetNref, TemplateNref, Fwd, Rev) end). -do_update_both(SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) -> +do_update_both(Home, SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) -> case {graphdb_mgr:validate_avp_updates(Fwd), graphdb_mgr:validate_avp_updates(Rev)} of {ok, ok} -> txn_ok(fun() -> - update_relationship_both_in_txn(SourceNref, CharNref, + update_relationship_both_in_txn(Home, SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) end); {{error, _} = Err, _} -> Err; @@ -1645,17 +1750,20 @@ do_update_both(SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) -> %%----------------------------------------------------------------------------- -%% validate_arc_endpoints_in_txn(Source, Char, Target, Reciprocal, TkAttr, -%% RetAttr) -> ok (aborts the enclosing transaction on any violation) +%% validate_arc_endpoints_in_txn(Home, Source, Char, Target, Reciprocal, +%% TkAttr, RetAttr) -> ok (aborts the enclosing transaction on violation) %% %% In-transaction endpoint validation. Assumes it runs inside an active mnesia %% activity; reads the four nodes with bare mnesia:read and signals every %% violation via mnesia:abort/1 (same Reason terms as the prior own-txn form). -%%----------------------------------------------------------------------------- -validate_arc_endpoints_in_txn(SourceNref, CharNref, TargetNref, ReciprocalNref, - TkAttr, RetAttr) -> - Source = mnesia:read(nodes, SourceNref), - Target = mnesia:read(nodes, TargetNref), +%% Source/Target route through Home; Char/Recip always read from the literal +%% environment `nodes` table (characterization/reciprocal are always +%% environment attribute nrefs). +%%----------------------------------------------------------------------------- +validate_arc_endpoints_in_txn(Home, SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, RetAttr) -> + Source = mnesia:read(graphdb_ns:node_table(Home), SourceNref), + Target = mnesia:read(graphdb_ns:node_table(Home), TargetNref), Char = mnesia:read(nodes, CharNref), Recip = mnesia:read(nodes, ReciprocalNref), case {Source, Target, Char, Recip} of @@ -1717,19 +1825,19 @@ check_target_kind(#node{attribute_value_pairs = AVPs}, ActualKind, TkAttr) -> %%----------------------------------------------------------------------------- -%% resolve_arc_classes_in_txn(SourceNref, TargetNref) -> +%% resolve_arc_classes_in_txn(Home, SourceNref, TargetNref) -> %% {SourceClass, TargetClass} (aborts on a missing class) %% %% In-transaction class resolution. class_of_in_txn returns only {ok,_} | %% not_found inside a txn (a read error aborts the txn directly), so the %% no-class arms abort with the same Reason terms the prior form returned. %%----------------------------------------------------------------------------- -resolve_arc_classes_in_txn(SourceNref, TargetNref) -> - SourceClass = case class_of_in_txn(SourceNref) of +resolve_arc_classes_in_txn(Home, SourceNref, TargetNref) -> + SourceClass = case class_of_in_txn(Home, SourceNref) of {ok, SC} -> SC; not_found -> mnesia:abort({source_has_no_class, SourceNref}) end, - TargetClass = case class_of_in_txn(TargetNref) of + TargetClass = case class_of_in_txn(Home, TargetNref) of {ok, TC} -> TC; not_found -> mnesia:abort({target_has_no_class, TargetNref}) end, @@ -1751,28 +1859,37 @@ resolve_template_in_txn(TemplateNref, _SourceClass) %%----------------------------------------------------------------------------- -%% build_connection_rows(S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) -%% -> [{relationships, #relationship{}}] +%% build_connection_rows(Home, S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) +%% -> [{RelsTable, #relationship{}}] %% %% Builds the two directed connection rows (Template AVP at index 0). Rel-ids %% are allocated here, OUTSIDE any transaction. No write -- the caller %% decides which transaction the rows land in (mandatory connections ride the %% composition root txn; auto connections are written post-commit). %%----------------------------------------------------------------------------- -build_connection_rows(SourceNref, CharNref, TargetNref, ReciprocalNref, +build_connection_rows(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec) -> - IdPair = rel_id_server:get_id_pair(), - build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, + IdPair = case Home of + environment -> rel_id_server:get_id_pair(); + _ -> graphdb_project:next_rel_id_pair(Home) + end, + build_connection_rows(Home, IdPair, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec). -%% build_connection_rows({Id1, Id2}, S, C, T, R, TemplateNref, {FwdAVPs,RevAVPs}) -%% -> [{relationships, #relationship{}}] +%% build_connection_rows(Home, {Id1, Id2}, S, C, T, R, TemplateNref, +%% {FwdAVPs, RevAVPs}) -> [{RelsTable, #relationship{}}] %% %% Pure builder: no allocation. The caller supplies the rel-id pair (allocated %% up-front, outside any transaction) so the rows can be built inside a caller's -%% transaction. Template AVP rides index 0 of each direction. -build_connection_rows({Id1, Id2}, SourceNref, CharNref, TargetNref, +%% transaction. Template AVP rides index 0 of each direction. Both rows land +%% in the SAME RelsTab: Source/Target are always both-project or +%% both-environment for a connection arc between two instances of the same +%% Home (connection rules never cross project boundaries per SP1's +%% proxy-indirection contract, and this module never builds a connection row +%% between an instance and an environment node). +build_connection_rows(Home, {Id1, Id2}, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, {FwdAVPs, RevAVPs}) -> + RelsTab = graphdb_ns:rel_table(Home), TemplateAVP = #{attribute => ?ARC_TEMPLATE, value => TemplateNref}, Fwd = #relationship{ id = Id1, kind = connection, @@ -1790,17 +1907,16 @@ build_connection_rows({Id1, Id2}, SourceNref, CharNref, TargetNref, reciprocal = CharNref, avps = [TemplateAVP | RevAVPs] }, - [{relationships, Fwd}, {relationships, Rev}]. + [{RelsTab, Fwd}, {RelsTab, Rev}]. -%% write_connection_arcs(S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) -> +%% write_connection_arcs(Home, S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) -> %% ok | {error, term()} %% %% Builds the two connection rows and writes them in their OWN transaction. -%% Used by add_relationship/4,5,6 and by the post-commit auto-connection pass. %%----------------------------------------------------------------------------- -write_connection_arcs(SourceNref, CharNref, TargetNref, ReciprocalNref, +write_connection_arcs(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec) -> - Rows = build_connection_rows(SourceNref, CharNref, TargetNref, + Rows = build_connection_rows(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec), Txn = fun() -> lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, @@ -1813,31 +1929,40 @@ write_connection_arcs(SourceNref, CharNref, TargetNref, ReciprocalNref, %%----------------------------------------------------------------------------- -%% do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr) -> -%% ok | {error, term()} +%% do_add_class_membership(Project, InstanceNref, ClassNref, InstAttr, RetAttr) +%% -> ok | {error, term()} %% %% Validates the subject (must be an instance) and the target (must be %% a class, not retired, and instantiable), then atomically writes the %% 29/30 arc pair and appends ClassNref to the instance's classes cache. %% Idempotent. +%% +%% Project-routed: the instance node lives in the Project's node table and +%% the 29/30 membership arc pair is written to the Project's relationship +%% table -- including the class->instance direction, whose source_nref is +%% an environment class nref (per the knowledge model, membership arcs are +%% project-database writes, not environment writes). ClassNref itself is +%% validated against the literal environment via do_validate_class/3. %%----------------------------------------------------------------------------- -do_add_class_membership(InstanceNref, ClassNref, InstAttr, RetAttr) -> - case do_get_instance(InstanceNref) of +do_add_class_membership(Project, InstanceNref, ClassNref, InstAttr, RetAttr) -> + case do_get_instance(Project, InstanceNref) of {ok, _} -> case do_validate_class(ClassNref, InstAttr, RetAttr) of - ok -> do_write_class_membership(InstanceNref, - ClassNref); + ok -> do_write_class_membership(Project, + InstanceNref, ClassNref); {error, _} = Err -> Err end; {error, _} = Err -> Err end. -do_write_class_membership(InstanceNref, ClassNref) -> - {Id1, Id2} = rel_id_server:get_id_pair(), +do_write_class_membership(Project, InstanceNref, ClassNref) -> + {Id1, Id2} = graphdb_project:next_rel_id_pair(Project), + NodesTab = graphdb_ns:node_table(Project), + RelsTab = graphdb_ns:rel_table(Project), Txn = fun() -> [#node{kind = instance, classes = Classes} = Node] = - mnesia:read(nodes, InstanceNref), + mnesia:read(NodesTab, InstanceNref), case lists:member(ClassNref, Classes) of true -> already_exists; @@ -1859,9 +1984,9 @@ do_write_class_membership(InstanceNref, ClassNref) -> avps = [] }, Updated = Node#node{classes = Classes ++ [ClassNref]}, - ok = mnesia:write(nodes, Updated, write), - ok = mnesia:write(relationships, I2C, write), - ok = mnesia:write(relationships, C2I, write), + ok = mnesia:write(NodesTab, Updated, write), + ok = mnesia:write(RelsTab, I2C, write), + ok = mnesia:write(RelsTab, C2I, write), ok end end, @@ -1873,26 +1998,26 @@ do_write_class_membership(InstanceNref, ClassNref) -> %%----------------------------------------------------------------------------- -%% do_class_memberships(InstanceNref) -> +%% do_class_memberships(Project, InstanceNref) -> %% {ok, [ClassNref]} | {error, term()} %% %% Reads the instance's `classes` cache (authoritative-equivalent to the %% 29-characterized outgoing arcs by the cache invariant). %%----------------------------------------------------------------------------- -do_class_memberships(InstanceNref) -> - case do_get_instance(InstanceNref) of +do_class_memberships(Project, InstanceNref) -> + case do_get_instance(Project, InstanceNref) of {ok, #node{classes = Classes}} -> {ok, Classes}; {error, _} = Err -> Err end. %%----------------------------------------------------------------------------- -%% do_class_of(InstanceNref) -> +%% do_class_of(Project, InstanceNref) -> %% {ok, ClassNref} | not_found | {error, term()} %%----------------------------------------------------------------------------- -do_class_of(InstanceNref) -> +do_class_of(Project, InstanceNref) -> F = fun() -> - Rels = mnesia:index_read(relationships, InstanceNref, + Rels = mnesia:index_read(graphdb_ns:rel_table(Project), InstanceNref, #relationship.source_nref), lists:search( fun(R) -> @@ -1908,14 +2033,15 @@ do_class_of(InstanceNref) -> %%----------------------------------------------------------------------------- -%% class_of_in_txn(InstanceNref) -> {ok, ClassNref} | not_found +%% class_of_in_txn(Home, InstanceNref) -> {ok, ClassNref} | not_found %% -%% Tier-1 in-transaction twin of do_class_of/1. Assumes it runs inside an -%% active mnesia activity; uses a bare index_read. do_class_of/1 keeps its -%% own transaction for its public class_of caller. +%% Tier-1 in-transaction twin of do_class_of/2. Assumes it runs inside an +%% active mnesia activity; uses a bare index_read against Home's relationship +%% table. do_class_of/2 keeps its own transaction for its public class_of +%% caller. %%----------------------------------------------------------------------------- -class_of_in_txn(InstanceNref) -> - Rels = mnesia:index_read(relationships, InstanceNref, +class_of_in_txn(Home, InstanceNref) -> + Rels = mnesia:index_read(graphdb_ns:rel_table(Home), InstanceNref, #relationship.source_nref), case lists:search( fun(R) -> @@ -1927,11 +2053,11 @@ class_of_in_txn(InstanceNref) -> %%----------------------------------------------------------------------------- -%% do_get_instance(Nref) -> +%% do_get_instance(Project, Nref) -> %% {ok, #node{}} | {error, not_found | not_an_instance | term()} %%----------------------------------------------------------------------------- -do_get_instance(Nref) -> - case mnesia:dirty_read(nodes, Nref) of +do_get_instance(Project, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of [#node{kind = instance} = Node] -> {ok, Node}; [_Other] -> {error, not_an_instance}; [] -> {error, not_found} @@ -1939,13 +2065,13 @@ do_get_instance(Nref) -> %%----------------------------------------------------------------------------- -%% do_children(Nref) -> {ok, [#node{}]} | {error, term()} +%% do_children(Project, Nref) -> {ok, [#node{}]} | {error, term()} %% %% Returns all direct instance-kind children of the given node. %%----------------------------------------------------------------------------- -do_children(Nref) -> +do_children(Project, Nref) -> F = fun() -> - Children = downward_children_by_arc(Nref, ?ARC_INST_CHILD, + Children = downward_children_by_arc(Project, Nref, ?ARC_INST_CHILD, composition), [N || N <- Children, N#node.kind =:= instance] end, @@ -1953,28 +2079,28 @@ do_children(Nref) -> %%----------------------------------------------------------------------------- -%% do_compositional_ancestors(Nref) -> {ok, [#node{}]} | {error, term()} +%% do_compositional_ancestors(Project, Nref) -> {ok, [#node{}]} | {error, term()} %% %% Walks the parent chain from the instance's parent. Collects only %% instance-kind ancestors. Stops at a non-instance node or missing %% node. Returns nearest-first order. %%----------------------------------------------------------------------------- -do_compositional_ancestors(Nref) -> - case mnesia:dirty_read(nodes, Nref) of +do_compositional_ancestors(Project, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of [#node{kind = instance, parents = Parents}] -> - do_walk_ancestors(head_parent(Parents), []); + do_walk_ancestors(Project, head_parent(Parents), []); [_] -> {error, not_an_instance}; [] -> {error, not_found} end. -do_walk_ancestors(undefined, Acc) -> +do_walk_ancestors(_Project, undefined, Acc) -> {ok, lists:reverse(Acc)}; -do_walk_ancestors(Nref, Acc) -> - case mnesia:dirty_read(nodes, Nref) of +do_walk_ancestors(Project, Nref, Acc) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of [#node{kind = instance, parents = Parents} = Node] -> - do_walk_ancestors(head_parent(Parents), [Node | Acc]); + do_walk_ancestors(Project, head_parent(Parents), [Node | Acc]); [_] -> %% Hit a non-instance node (e.g., category anchor) — stop {ok, lists:reverse(Acc)}; @@ -1988,7 +2114,7 @@ do_walk_ancestors(Nref, Acc) -> %%============================================================================= %%----------------------------------------------------------------------------- -%% do_resolve_value(InstNref, AttrNref) -> +%% do_resolve_value(Project, InstNref, AttrNref) -> %% {ok, Value, Source} | not_found | {error, term()} %% %% Full four-level inheritance resolution. Source identifies the @@ -1998,40 +2124,53 @@ do_walk_ancestors(Nref, Acc) -> %% - `{compositional, Nref}` (Priority 3, the ancestor instance) %% - `{connected, Nref}` (Priority 4, the directly-connected node) %%----------------------------------------------------------------------------- -do_resolve_value(InstNref, AttrNref) -> - case do_get_instance(InstNref) of +do_resolve_value(Project, InstNref, AttrNref) -> + case do_get_instance(Project, InstNref) of {ok, Node} -> %% Priority 1: Local values case find_avp_value(Node#node.attribute_value_pairs, AttrNref) of {ok, V} -> {ok, V, local}; not_found -> - %% Priority 2: Class-level bound values - case resolve_from_class(InstNref, AttrNref) of - {ok, V, ClassNref} -> - {ok, V, {class, ClassNref}}; + resolve_value_priority_2_and_below(Project, Node, AttrNref) + end; + {error, _} = Err -> + Err + end. + +%%----------------------------------------------------------------------------- +%% resolve_value_priority_2_and_below(Project, Node, AttrNref) -> +%% {ok, Value, Source} | not_found | {error, term()} +%% +%% Priorities 2-4, threaded with Project for the home-relative reads +%% (compositional ancestors and directly-connected nodes both live in +%% Project; class-level lookups stay environment-bound inside +%% resolve_from_class/3). +%%----------------------------------------------------------------------------- +resolve_value_priority_2_and_below(Project, Node, AttrNref) -> + InstNref = Node#node.nref, + %% Priority 2: Class-level bound values + case resolve_from_class(Project, InstNref, AttrNref) of + {ok, V, ClassNref} -> + {ok, V, {class, ClassNref}}; + not_found -> + %% Priority 3: Compositional ancestors + case resolve_from_ancestors(Project, + head_parent(Node#node.parents), + AttrNref) of + {ok, V, AncNref} -> + {ok, V, {compositional, AncNref}}; + not_found -> + %% Priority 4: Directly connected nodes + case resolve_from_connected(Project, + InstNref, AttrNref) of + {ok, V, ConnNref} -> + {ok, V, {connected, ConnNref}}; not_found -> - %% Priority 3: Compositional ancestors - case resolve_from_ancestors( - head_parent(Node#node.parents), - AttrNref) of - {ok, V, AncNref} -> - {ok, V, {compositional, AncNref}}; - not_found -> - %% Priority 4: Directly connected nodes - case resolve_from_connected( - InstNref, AttrNref) of - {ok, V, ConnNref} -> - {ok, V, {connected, ConnNref}}; - not_found -> - not_found - end; - {error, _} = Err -> - Err - end; - {error, _} = Err -> - Err - end + not_found + end; + {error, _} = Err -> + Err end; {error, _} = Err -> Err @@ -2039,12 +2178,14 @@ do_resolve_value(InstNref, AttrNref) -> %%----------------------------------------------------------------------------- -%% resolve_from_class(InstNref, AttrNref) -> +%% resolve_from_class(Project, InstNref, AttrNref) -> %% {ok, Value, ClassNref} | not_found | %% {error, {ambiguous_class_value, AttrNref, [{ClassNref, Value}]}} %% %% Reads every class membership and, for each one, walks the class node -%% plus its taxonomy ancestors (nearest-first) for an AVP match. +%% plus its taxonomy ancestors (nearest-first) for an AVP match. Class +%% membership is read from Project (`do_class_memberships/2`); the class +%% nodes themselves are always environment (`graphdb_class:search_class_taxonomy/2`). %% %% - 0 hits across all memberships -> not_found (caller falls through %% to Priority 3). @@ -2057,8 +2198,8 @@ do_resolve_value(InstNref, AttrNref) -> %% AttrNref, [{ClassNref, Value}]}}, where ClassNref is the class %% where the value was actually found. %%----------------------------------------------------------------------------- -resolve_from_class(InstNref, AttrNref) -> - case do_class_memberships(InstNref) of +resolve_from_class(Project, InstNref, AttrNref) -> + case do_class_memberships(Project, InstNref) of {ok, []} -> not_found; {ok, Classes} -> @@ -2088,7 +2229,7 @@ classify_class_hits([{ClassNref, _} | _] = Hits, AttrNref) -> end. %%----------------------------------------------------------------------------- -%% resolve_from_ancestors(ParentNref, AttrNref) -> +%% resolve_from_ancestors(Project, ParentNref, AttrNref) -> %% {ok, Value, AncestorNref} | not_found | {error, term()} %% %% Walks up the compositional parent chain, checking each instance @@ -2096,15 +2237,15 @@ classify_class_hits([{ClassNref, _} | _] = Hits, AttrNref) -> %% match is found, returns the nref of the ancestor instance that held %% the value. %%----------------------------------------------------------------------------- -resolve_from_ancestors(undefined, _AttrNref) -> +resolve_from_ancestors(_Project, undefined, _AttrNref) -> not_found; -resolve_from_ancestors(ParentNref, AttrNref) -> - case mnesia:dirty_read(nodes, ParentNref) of +resolve_from_ancestors(Project, ParentNref, AttrNref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), ParentNref) of [#node{kind = instance, parents = GrandParents, attribute_value_pairs = AVPs}] -> case find_avp_value(AVPs, AttrNref) of {ok, V} -> {ok, V, ParentNref}; - not_found -> resolve_from_ancestors( + not_found -> resolve_from_ancestors(Project, head_parent(GrandParents), AttrNref) end; [_] -> @@ -2126,24 +2267,25 @@ head_parent([P | _]) -> P. %%----------------------------------------------------------------------------- -%% downward_children_by_arc(ParentNref, ChildArc, RelKind) -> [#node{}] +%% downward_children_by_arc(Project, ParentNref, ChildArc, RelKind) -> [#node{}] %% %% Replaces the retired #node.parent secondary index. Reads outgoing %% arcs from ParentNref of the given Kind/characterization and %% dereferences each target nref to a node record. Must run inside an %% active mnesia transaction. %%----------------------------------------------------------------------------- -downward_children_by_arc(ParentNref, ChildArc, RelKind) -> - Arcs = mnesia:index_read(relationships, ParentNref, +downward_children_by_arc(Project, ParentNref, ChildArc, RelKind) -> + Arcs = mnesia:index_read(graphdb_ns:rel_table(Project), ParentNref, #relationship.source_nref), Nrefs = [A#relationship.target_nref || A <- Arcs, A#relationship.kind =:= RelKind, A#relationship.characterization =:= ChildArc], - lists:flatmap(fun(N) -> mnesia:read(nodes, N) end, Nrefs). + lists:flatmap(fun(N) -> mnesia:read(graphdb_ns:node_table(Project), N) end, + Nrefs). %%----------------------------------------------------------------------------- -%% resolve_from_connected(InstNref, AttrNref) -> +%% resolve_from_connected(Project, InstNref, AttrNref) -> %% {ok, Value, NodeNref} | not_found %% %% Checks all directly connected nodes (one level deep). Only @@ -2153,9 +2295,9 @@ downward_children_by_arc(ParentNref, ChildArc, RelKind) -> %% connected node that held the AVP; the caller wraps it as %% {connected, NodeNref} for the Source tag. %%----------------------------------------------------------------------------- -resolve_from_connected(InstNref, AttrNref) -> +resolve_from_connected(Project, InstNref, AttrNref) -> F = fun() -> - mnesia:index_read(relationships, InstNref, + mnesia:index_read(graphdb_ns:rel_table(Project), InstNref, #relationship.source_nref) end, case graphdb_mgr:transaction(F) of @@ -2163,30 +2305,30 @@ resolve_from_connected(InstNref, AttrNref) -> TargetNrefs = lists:usort( [R#relationship.target_nref || R <- Rels, R#relationship.kind =:= connection]), - search_targets(TargetNrefs, AttrNref); + search_targets(Project, TargetNrefs, AttrNref); {error, _} -> not_found end. %%----------------------------------------------------------------------------- -%% search_targets(Nrefs, AttrNref) -> +%% search_targets(Project, Nrefs, AttrNref) -> %% {ok, Value, NodeNref} | not_found %% %% Checks each target node's AVPs for the attribute. Returns the %% first match together with the nref of the node that held the value. %%----------------------------------------------------------------------------- -search_targets([], _AttrNref) -> +search_targets(_Project, [], _AttrNref) -> not_found; -search_targets([Nref | Rest], AttrNref) -> - case mnesia:dirty_read(nodes, Nref) of +search_targets(Project, [Nref | Rest], AttrNref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of [#node{attribute_value_pairs = AVPs}] -> case find_avp_value(AVPs, AttrNref) of {ok, V} -> {ok, V, Nref}; - not_found -> search_targets(Rest, AttrNref) + not_found -> search_targets(Project, Rest, AttrNref) end; _ -> - search_targets(Rest, AttrNref) + search_targets(Project, Rest, AttrNref) end. diff --git a/apps/graphdb/src/graphdb_mgr.erl b/apps/graphdb/src/graphdb_mgr.erl index 98a7803..f535ad1 100644 --- a/apps/graphdb/src/graphdb_mgr.erl +++ b/apps/graphdb/src/graphdb_mgr.erl @@ -109,6 +109,7 @@ start_link/0, %% Read operations get_node/1, + get_node/2, get_relationships/1, get_relationships/2, %% Write operations (delegate to workers) @@ -117,18 +118,28 @@ create_instance/4, add_relationship/5, delete_node/1, + delete_node/2, retire_node/1, + retire_node/2, unretire_node/1, + unretire_node/2, update_node_avps/2, + update_node_avps/3, %% Batch write (tier-3 entry point) mutate/1, + mutate/2, %% Tier-1 in-txn write primitive (composed by mutate/1) - update_node_avps_in_txn/3, + update_node_avps_in_txn/4, %% Transaction helper (write-path seam) transaction/1, %% Cache invariant audit / repair verify_caches/0, - rebuild_caches/0 + rebuild_caches/0, + verify_caches/1, + rebuild_caches/1, + %% Cross-worker AVP-update helpers (graphdb_instance production callers) + validate_avp_updates/1, + apply_avp_updates/2 ]). %%--------------------------------------------------------------------- @@ -150,8 +161,6 @@ -export([ validate_direction/1, check_category_guard/1, - validate_avp_updates/1, - apply_avp_updates/2, check_instance_only/2 ]). -endif. @@ -174,6 +183,22 @@ get_node(Nref) -> gen_server:call(?MODULE, {get_node, Nref}). +%%----------------------------------------------------------------------------- +%% get_node(Project, Nref) -> {ok, #node{}} | {error, not_found | term()} +%% +%% Reads a single node from Project's own nodes table. Unlike get_node/1, +%% no retired-marker check -- SP1/SP2 have not extended the retired-read +%% guard to the project write path; project reads return the raw node. +%% Gated on a well-formed Project handle (graphdb_project:require_project/1) +%% BEFORE the gen_server:call, so a malformed handle never reaches the +%% singleton -- mirrors graphdb_instance:with_project/2. +%%----------------------------------------------------------------------------- +get_node(Project, Nref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, {get_node, P, Nref}) + end). + + %%----------------------------------------------------------------------------- %% get_relationships(Nref) -> {ok, [#relationship{}]} | {error, term()} %% @@ -222,38 +247,34 @@ create_class(Name, ParentClassNref) -> %%----------------------------------------------------------------------------- -%% create_instance(Session, Name, ClassNref, ParentNref) -> +%% create_instance(Project, Name, ClassNref, ParentNref) -> %% {ok, Nref, report()} | {error, Reason, report()} | {error, Reason} %% -%% Creates a new instance node in the project named by Session and fires -%% mandatory composition rules. A project operation requires a valid session -%% (SP1). Delegates to graphdb_instance; propagates the 3-tuple return verbatim. -%%----------------------------------------------------------------------------- -create_instance(Session, Name, ClassNref, ParentNref) -> - case graphdb_project:require_session(Session) of - {error, _} = Err -> Err; - ok -> - gen_server:call(?MODULE, - {create_instance, Session, Name, ClassNref, ParentNref}) - end. +%% Creates a new instance node in Project and fires mandatory composition +%% rules. A project operation requires a valid Project handle (SP2). +%% Delegates to graphdb_instance; propagates the 3-tuple return verbatim. +%%----------------------------------------------------------------------------- +create_instance(Project, Name, ClassNref, ParentNref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {create_instance, P, Name, ClassNref, ParentNref}) + end). %%----------------------------------------------------------------------------- -%% add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> +%% add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref) -> %% ok | {error, term()} %% -%% Creates a bidirectional relationship (two directed rows) in the project -%% named by Session. A project operation requires a valid session (SP1); -%% delegates to graphdb_instance. -%%----------------------------------------------------------------------------- -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> - case graphdb_project:require_session(Session) of - {error, _} = Err -> Err; - ok -> - gen_server:call(?MODULE, - {add_relationship, Session, SourceNref, CharNref, TargetNref, - ReciprocalNref}) - end. +%% Creates a bidirectional relationship (two directed rows) in Project. A +%% project operation requires a valid Project handle (SP2); delegates to +%% graphdb_instance. +%%----------------------------------------------------------------------------- +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {add_relationship, P, SourceNref, CharNref, TargetNref, + ReciprocalNref}) + end). %%----------------------------------------------------------------------------- @@ -267,6 +288,17 @@ delete_node(Nref) -> gen_server:call(?MODULE, {delete_node, Nref}). +%%----------------------------------------------------------------------------- +%% delete_node(Project, Nref) -> ok | {error, term()} +%% Project-scoped twin of delete_node/1. Actual deletion not yet implemented. +%% Gated on a well-formed Project handle before the gen_server:call. +%%----------------------------------------------------------------------------- +delete_node(Project, Nref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, {delete_node, P, Nref}) + end). + + %%----------------------------------------------------------------------------- %% retire_node(Nref) -> ok | {error, Reason} %% Soft-retires a runtime node (sets the boolean `retired` marker AVP). @@ -281,6 +313,25 @@ unretire_node(Nref) -> gen_server:call(?MODULE, {unretire_node, Nref}). +%%----------------------------------------------------------------------------- +%% retire_node(Project, Nref) -> ok | {error, Reason} +%% unretire_node(Project, Nref) -> ok | {error, Reason} +%% +%% Project-scoped twins. No permanent-tier guard: a project's allocator has +%% no permanent tier (design §4) -- every project nref is mutable. Gated on +%% a well-formed Project handle before the gen_server:call. +%%----------------------------------------------------------------------------- +retire_node(Project, Nref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, {retire_node, P, Nref}) + end). + +unretire_node(Project, Nref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, {unretire_node, P, Nref}) + end). + + %%----------------------------------------------------------------------------- %% update_node_avps(Nref, AVPs) -> ok | {error, term()} %% @@ -301,6 +352,23 @@ update_node_avps(Nref, AVPs) -> end. +%%----------------------------------------------------------------------------- +%% update_node_avps(Project, Nref, AVPs) -> ok | {error, term()} +%% Project-scoped twin of update_node_avps/2. Gated on a well-formed Project +%% handle before the gen_server:call, after client-side AVP validation. +%%----------------------------------------------------------------------------- +-spec update_node_avps(map(), integer(), [map()]) -> ok | {error, term()}. +update_node_avps(Project, Nref, AVPs) -> + case validate_avp_updates(AVPs) of + ok -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, {update_node_avps, P, Nref, AVPs}) + end); + {error, _} = Err -> + Err + end. + + %%----------------------------------------------------------------------------- %% transaction(Fun) -> {ok, Result} | {error, Reason} %% @@ -327,11 +395,22 @@ transaction(Fun) -> %%----------------------------------------------------------------------------- %% mutate([Mutation]) -> {ok, [Result]} | {error, Reason} +%% mutate(Project, [Mutation]) -> {ok, [Result]} | {error, Reason} %% %% Tier-3 batch write entry point: applies an ordered list of mutations %% ATOMICALLY in one graphdb_mgr:transaction/1, composing the write-path %% seam's tier-1 primitives directly. All commit or none do. %% +%% mutate/1 resolves Home = environment (its grammar and behaviour are +%% UNCHANGED from before mutate/2 existed). mutate/2 is the Project-aware +%% twin: it resolves Home = Project, so add_relationship / remove_relationship +%% / update_relationship(_both) / update_node_avps / retire_node / +%% unretire_node all touch Project's own tables. A batch may still mix +%% environment and project references (an add_relationship whose Char/Recip +%% are environment attribute nrefs, as always) but spans at most one project +%% plus the environment (design doc7). mutate/2 is gated on a well-formed +%% Project handle via with_project/2, same as the other Project-taking twins. +%% %% Mutation grammar (tagged tuples mirroring the public arities): %% {add_relationship, S, C, T, R} default template, no AVPs %% {add_relationship, S, C, T, R, Template} explicit template nref @@ -364,56 +443,71 @@ transaction(Fun) -> %%----------------------------------------------------------------------------- -spec mutate([tuple()]) -> {ok, [term()]} | {error, term()}. mutate(Mutations) -> - case validate_mutations(Mutations) of - ok -> run_mutations(Mutations); + do_mutate(environment, Mutations). + +-spec mutate(map(), [tuple()]) -> {ok, [term()]} | {error, term()}. +mutate(Project, Mutations) -> + with_project(Project, fun(P) -> do_mutate(P, Mutations) end). + +do_mutate(Home, Mutations) -> + case validate_mutations(Home, Mutations) of + ok -> run_mutations(Home, Mutations); {error, _} = Err -> Err end. %% Phase 1: static validation. No DB access, no allocation. A malformed term %% -> {error, {bad_mutation, M}}; a permanent-tier retire/unretire -> %% {error, permanent_node_immutable} (the same static guard set_retired/3 -%% applies in the solo path). -validate_mutations([]) -> +%% applies in the solo path). Home threads into tier_guard/2 only -- every +%% other clause ignores it (pure shape check, unaffected by Home). +validate_mutations(_Home, []) -> ok; -validate_mutations([M | Rest]) -> - case validate_mutation(M) of - ok -> validate_mutations(Rest); +validate_mutations(Home, [M | Rest]) -> + case validate_mutation(Home, M) of + ok -> validate_mutations(Home, Rest); {error, _} = Err -> Err end. -validate_mutation({add_relationship, _S, _C, _T, _R}) -> +validate_mutation(_Home, {add_relationship, _S, _C, _T, _R}) -> ok; -validate_mutation({add_relationship, _S, _C, _T, _R, _Template}) -> +validate_mutation(_Home, {add_relationship, _S, _C, _T, _R, _Template}) -> ok; -validate_mutation({add_relationship, _S, _C, _T, _R, _Template, {_Fwd, _Rev}}) -> +validate_mutation(_Home, + {add_relationship, _S, _C, _T, _R, _Template, {_Fwd, _Rev}}) -> ok; -validate_mutation({retire_node, Nref}) when is_integer(Nref) -> - tier_guard(Nref); -validate_mutation({unretire_node, Nref}) when is_integer(Nref) -> - tier_guard(Nref); -validate_mutation({update_node_avps, Nref, AVPs}) when is_integer(Nref) -> +validate_mutation(Home, {retire_node, Nref}) when is_integer(Nref) -> + tier_guard(Home, Nref); +validate_mutation(Home, {unretire_node, Nref}) when is_integer(Nref) -> + tier_guard(Home, Nref); +validate_mutation(Home, {update_node_avps, Nref, AVPs}) when is_integer(Nref) -> case validate_avp_updates(AVPs) of - ok -> tier_guard(Nref); + ok -> tier_guard(Home, Nref); {error, _} = Err -> Err end; -validate_mutation({remove_relationship, _S, _C, _T}) -> +validate_mutation(_Home, {remove_relationship, _S, _C, _T}) -> ok; -validate_mutation({remove_relationship, _S, _C, _T, _Template}) -> +validate_mutation(_Home, {remove_relationship, _S, _C, _T, _Template}) -> ok; -validate_mutation({update_relationship, _S, _C, _T, Updates}) -> +validate_mutation(_Home, {update_relationship, _S, _C, _T, Updates}) -> validate_avp_updates(Updates); -validate_mutation({update_relationship, _S, _C, _T, _Template, Updates}) -> +validate_mutation(_Home, {update_relationship, _S, _C, _T, _Template, Updates}) -> validate_avp_updates(Updates); -validate_mutation({update_relationship_both, _S, _C, _T, {Fwd, Rev}}) -> +validate_mutation(_Home, {update_relationship_both, _S, _C, _T, {Fwd, Rev}}) -> validate_both_avp_updates(Fwd, Rev); -validate_mutation({update_relationship_both, _S, _C, _T, _Template, +validate_mutation(_Home, {update_relationship_both, _S, _C, _T, _Template, {Fwd, Rev}}) -> validate_both_avp_updates(Fwd, Rev); -validate_mutation(M) -> +validate_mutation(_Home, M) -> {error, {bad_mutation, M}}. -tier_guard(Nref) when Nref >= ?NREF_START -> ok; -tier_guard(_Nref) -> {error, permanent_node_immutable}. +%% tier_guard(Home, Nref) -> ok | {error, permanent_node_immutable} +%% A project's allocator has no permanent tier (design4): any Home other +%% than environment is unconditionally ok. Only the literal environment +%% tier is guarded against Nref < ?NREF_START, matching mutate/1's original +%% (Home-less) behaviour exactly. +tier_guard(Home, _Nref) when Home =/= environment -> ok; +tier_guard(environment, Nref) when Nref >= ?NREF_START -> ok; +tier_guard(environment, _Nref) -> {error, permanent_node_immutable}. validate_both_avp_updates(Fwd, Rev) -> case validate_avp_updates(Fwd) of @@ -421,91 +515,99 @@ validate_both_avp_updates(Fwd, Rev) -> {error, _} = Err -> Err end. -%% Phases 2 + 3. Precondition: Mutations already passed validate_mutations/1. +%% Phases 2 + 3. Precondition: Mutations already passed validate_mutations/2. %% Empty batch short-circuits with no transaction. -run_mutations([]) -> +run_mutations(_Home, []) -> {ok, []}; -run_mutations(Mutations) -> +run_mutations(Home, Mutations) -> %% Phase 2 (outside the transaction): resolve the seeded attr nrefs once, %% and allocate one rel-id pair per add_relationship. {ok, #{target_kind := TkAttr, retired := RetAttr}} = graphdb_attr:seeded_nrefs(), - Prepared = [prepare(M) || M <- Mutations], + Prepared = [prepare(Home, M) || M <- Mutations], %% Phase 3: one transaction folding the prepared list in order. graphdb_mgr:transaction(fun() -> - [dispatch(P, TkAttr, RetAttr) || P <- Prepared] + [dispatch(Home, P, TkAttr, RetAttr) || P <- Prepared] end). %% Phase 2 per-mutation prep. Allocates one rel-id pair per add_relationship -%% via rel_id_server (a gen_server call -- MUST stay outside the transaction) -%% and normalises each add_relationship to the explicit +%% (a gen_server call -- MUST stay outside the transaction), routed to +%% rel_id_server for the environment or graphdb_project's own counter for a +%% project, and normalises each add_relationship to the explicit %% (TemplateSpec, AVPSpec) form. retire/unretire need no resources. %% Prepared add_relationship shape: %% {add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec} -prepare({add_relationship, S, C, T, R}) -> - {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, - default, {[], []}}; -prepare({add_relationship, S, C, T, R, Template}) -> - {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, - Template, {[], []}}; -prepare({add_relationship, S, C, T, R, Template, AVPSpec}) -> - {add_relationship, rel_id_server:get_id_pair(), S, C, T, R, - Template, AVPSpec}; -prepare({retire_node, _Nref} = M) -> +prepare(Home, {add_relationship, S, C, T, R}) -> + {add_relationship, alloc_rel_id_pair(Home), S, C, T, R, default, {[], []}}; +prepare(Home, {add_relationship, S, C, T, R, Template}) -> + {add_relationship, alloc_rel_id_pair(Home), S, C, T, R, Template, {[], []}}; +prepare(Home, {add_relationship, S, C, T, R, Template, AVPSpec}) -> + {add_relationship, alloc_rel_id_pair(Home), S, C, T, R, Template, AVPSpec}; +prepare(_Home, {retire_node, _Nref} = M) -> M; -prepare({unretire_node, _Nref} = M) -> +prepare(_Home, {unretire_node, _Nref} = M) -> M; -prepare({update_node_avps, _Nref, _AVPs} = M) -> +prepare(_Home, {update_node_avps, _Nref, _AVPs} = M) -> M; -prepare({remove_relationship, _S, _C, _T} = M) -> +prepare(_Home, {remove_relationship, _S, _C, _T} = M) -> M; -prepare({remove_relationship, _S, _C, _T, _Template} = M) -> +prepare(_Home, {remove_relationship, _S, _C, _T, _Template} = M) -> M; -prepare({update_relationship, _S, _C, _T, _U} = M) -> +prepare(_Home, {update_relationship, _S, _C, _T, _U} = M) -> M; -prepare({update_relationship, _S, _C, _T, _Template, _U} = M) -> +prepare(_Home, {update_relationship, _S, _C, _T, _Template, _U} = M) -> M; -prepare({update_relationship_both, _S, _C, _T, _Pair} = M) -> +prepare(_Home, {update_relationship_both, _S, _C, _T, _Pair} = M) -> M; -prepare({update_relationship_both, _S, _C, _T, _Template, _Pair} = M) -> +prepare(_Home, {update_relationship_both, _S, _C, _T, _Template, _Pair} = M) -> M. +%% Duplicated 2-clause Home-dispatch helper (same YAGNI precedent as +%% is_retired/2's per-module duplication) -- graphdb_instance has its own +%% copy inline in do_add_relationship/8. +alloc_rel_id_pair(environment) -> rel_id_server:get_id_pair(); +alloc_rel_id_pair(Project) -> graphdb_project:next_rel_id_pair(Project). + %% Phase 3 dispatch. Runs INSIDE the transaction: no gen_server calls, no %% transaction/1, no rel-id allocation here (all done in phase 2). Each %% tier-1 primitive returns ok or calls mnesia:abort/1. -dispatch({add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}, +dispatch(Home, {add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}, TkAttr, RetAttr) -> - graphdb_instance:add_relationship_in_txn(IdPair, S, C, T, R, TemplateSpec, - AVPSpec, TkAttr, RetAttr); -dispatch({retire_node, Nref}, _TkAttr, RetAttr) -> - set_retired_(Nref, true, RetAttr); -dispatch({unretire_node, Nref}, _TkAttr, RetAttr) -> - set_retired_(Nref, false, RetAttr); -dispatch({update_node_avps, Nref, AVPs}, _TkAttr, RetAttr) -> - update_node_avps_in_txn(Nref, AVPs, RetAttr); -dispatch({remove_relationship, S, C, T}, _TkAttr, _RetAttr) -> - graphdb_instance:remove_relationship_in_txn(S, C, T, any); -dispatch({remove_relationship, S, C, T, Template}, _TkAttr, _RetAttr) -> - graphdb_instance:remove_relationship_in_txn(S, C, T, Template); -dispatch({update_relationship, S, C, T, U}, _TkAttr, _RetAttr) -> - graphdb_instance:update_relationship_avps_in_txn(S, C, T, any, U); -dispatch({update_relationship, S, C, T, Template, U}, _TkAttr, _RetAttr) -> - graphdb_instance:update_relationship_avps_in_txn(S, C, T, Template, U); -dispatch({update_relationship_both, S, C, T, {Fwd, Rev}}, _TkAttr, _RetAttr) -> - graphdb_instance:update_relationship_both_in_txn(S, C, T, any, Fwd, Rev); -dispatch({update_relationship_both, S, C, T, Template, {Fwd, Rev}}, _TkAttr, + graphdb_instance:add_relationship_in_txn(Home, IdPair, S, C, T, R, + TemplateSpec, AVPSpec, TkAttr, RetAttr); +dispatch(Home, {retire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Home, Nref, true, RetAttr); +dispatch(Home, {unretire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Home, Nref, false, RetAttr); +dispatch(Home, {update_node_avps, Nref, AVPs}, _TkAttr, RetAttr) -> + update_node_avps_in_txn(Home, Nref, AVPs, RetAttr); +dispatch(Home, {remove_relationship, S, C, T}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(Home, S, C, T, any); +dispatch(Home, {remove_relationship, S, C, T, Template}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(Home, S, C, T, Template); +dispatch(Home, {update_relationship, S, C, T, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(Home, S, C, T, any, U); +dispatch(Home, {update_relationship, S, C, T, Template, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(Home, S, C, T, Template, + U); +dispatch(Home, {update_relationship_both, S, C, T, {Fwd, Rev}}, _TkAttr, _RetAttr) -> - graphdb_instance:update_relationship_both_in_txn(S, C, T, Template, Fwd, - Rev). + graphdb_instance:update_relationship_both_in_txn(Home, S, C, T, any, Fwd, + Rev); +dispatch(Home, {update_relationship_both, S, C, T, Template, {Fwd, Rev}}, + _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(Home, S, C, T, Template, + Fwd, Rev). %%----------------------------------------------------------------------------- %% verify_caches() -> ok | {error, [{Nref, Field, Expected, Actual}, ...]} %% -%% Scans every node and compares its hierarchy cache fields (`parents`, -%% `classes`) against the corresponding arcs in the relationships table. -%% Returns ok when every cache matches its arcs; otherwise returns the -%% complete list of mismatches. Order-insensitive comparison. +%% Scans every node in the environment and compares its hierarchy cache +%% fields (`parents`, `classes`) against the corresponding arcs in the +%% environment relationships table. Returns ok when every cache matches +%% its arcs; otherwise returns the complete list of mismatches. +%% Order-insensitive comparison. %% %% A failed verify is a fatal error in the "arcs authoritative; lists %% cached" invariant -- it indicates a write path bug, not correctable @@ -513,9 +615,24 @@ dispatch({update_relationship_both, S, C, T, Template, {Fwd, Rev}}, _TkAttr, %% testcase. %%----------------------------------------------------------------------------- verify_caches() -> + verify_caches_(environment). + +%%----------------------------------------------------------------------------- +%% verify_caches(Project) -> ok | {error, [...]} | {error, invalid_project} +%% +%% Project-scoped twin of verify_caches/0 (SP2). Scans Project's own +%% nodes_/relationships_ tables instead of the shared +%% environment tables. Gated on a well-formed Project handle. +%%----------------------------------------------------------------------------- +verify_caches(Project) -> + with_project(Project, fun verify_caches_/1). + +verify_caches_(Home) -> Txn = fun() -> - Nrefs = mnesia:all_keys(nodes), - lists:flatmap(fun verify_one/1, Nrefs) + NodesTab = graphdb_ns:node_table(Home), + RelTab = graphdb_ns:rel_table(Home), + Nrefs = mnesia:all_keys(NodesTab), + lists:flatmap(fun(N) -> verify_one(NodesTab, RelTab, N) end, Nrefs) end, case graphdb_mgr:transaction(Txn) of {ok, []} -> ok; @@ -527,15 +644,31 @@ verify_caches() -> %%----------------------------------------------------------------------------- %% rebuild_caches() -> ok | {error, term()} %% -%% Rewrites every node's `parents` and `classes` cache fields from the -%% authoritative relationships table. Used as the post-load tail of -%% the bootstrap loader (Option B, H0d) and as a diagnostic repair tool. -%% After a successful rebuild, verify_caches/0 must return ok. +%% Rewrites every environment node's `parents` and `classes` cache fields +%% from the authoritative environment relationships table. Used as the +%% post-load tail of the bootstrap loader (Option B, H0d) and as a +%% diagnostic repair tool. After a successful rebuild, verify_caches/0 +%% must return ok. %%----------------------------------------------------------------------------- rebuild_caches() -> + rebuild_caches_(environment). + +%%----------------------------------------------------------------------------- +%% rebuild_caches(Project) -> ok | {error, term()} | {error, invalid_project} +%% +%% Project-scoped twin of rebuild_caches/0 (SP2). Rewrites Project's own +%% nodes_ cache fields from its own relationships_ table. +%% Gated on a well-formed Project handle. +%%----------------------------------------------------------------------------- +rebuild_caches(Project) -> + with_project(Project, fun rebuild_caches_/1). + +rebuild_caches_(Home) -> Txn = fun() -> - Nrefs = mnesia:all_keys(nodes), - lists:foreach(fun rebuild_one/1, Nrefs), + NodesTab = graphdb_ns:node_table(Home), + RelTab = graphdb_ns:rel_table(Home), + Nrefs = mnesia:all_keys(NodesTab), + lists:foreach(fun(N) -> rebuild_one(NodesTab, RelTab, N) end, Nrefs), ok end, case graphdb_mgr:transaction(Txn) of @@ -591,6 +724,9 @@ handle_call({get_node, Nref}, _From, State0) -> {reply, Err, State0} end; +handle_call({get_node, Project, Nref}, _From, State) -> + {reply, do_get_node(Project, Nref), State}; + handle_call({get_relationships, Nref, Direction}, _From, State) -> {reply, do_get_relationships(Nref, Direction), State}; @@ -627,6 +763,13 @@ handle_call({unretire_node, Nref}, _From, State0) -> {Reply, State} = set_retired(Nref, false, State0), {reply, Reply, State}; +handle_call({retire_node, Project, Nref}, _From, State0) -> + {Reply, State} = set_retired(Project, Nref, true, State0), + {reply, Reply, State}; +handle_call({unretire_node, Project, Nref}, _From, State0) -> + {Reply, State} = set_retired(Project, Nref, false, State0), + {reply, Reply, State}; + handle_call({delete_node, Nref}, _From, State) -> case check_category_guard(Nref) of {error, _} = Err -> @@ -638,6 +781,14 @@ handle_call({delete_node, Nref}, _From, State) -> {reply, {error, not_implemented}, State} end; +handle_call({delete_node, Project, Nref}, _From, State) -> + case check_category_guard(Project, Nref) of + {error, _} = Err -> + {reply, Err, State}; + ok -> + {reply, {error, not_implemented}, State} + end; + handle_call({update_node_avps, Nref, AVPs}, _From, State) -> case check_category_guard(Nref) of {error, _} = Err -> @@ -647,6 +798,15 @@ handle_call({update_node_avps, Nref, AVPs}, _From, State) -> {reply, Reply, State1} end; +handle_call({update_node_avps, Project, Nref, AVPs}, _From, State) -> + case check_category_guard(Project, Nref) of + {error, _} = Err -> + {reply, Err, State}; + ok -> + {Reply, State1} = do_update_node_avps(Project, Nref, AVPs, State), + {reply, Reply, State1} + end; + handle_call(Request, From, State) -> ?UEM(handle_call, {Request, From, State}), {noreply, State}. @@ -671,6 +831,23 @@ code_change(_OldVsn, State, _Extra) -> %% Internal Functions %%============================================================================= +%%----------------------------------------------------------------------------- +%% with_project(Project, Fun) -> term() +%% +%% Gate a project operation on a valid Project handle. A missing or +%% malformed handle short-circuits with {error, invalid_project} -- BEFORE +%% any gen_server:call, so a bad handle never reaches (and never crashes) +%% the graphdb_mgr singleton via graphdb_ns:node_table/1's bare 2-clause +%% match. A valid handle runs Fun(Project). Mirrors +%% graphdb_instance:with_project/2 (same contract, same name, private to +%% each module). +%%----------------------------------------------------------------------------- +with_project(Project, Fun) when is_function(Fun, 1) -> + case graphdb_project:require_project(Project) of + ok -> Fun(Project); + {error, _} = Err -> Err + end. + %%----------------------------------------------------------------------------- %% validate_direction(Direction) -> ok | {error, {invalid_direction, term()}} %% @@ -694,6 +871,16 @@ do_get_node(Nref) -> end. +%%----------------------------------------------------------------------------- +%% do_get_node(Home, Nref) -> {ok, #node{}} | {error, not_found} +%%----------------------------------------------------------------------------- +do_get_node(Home, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of + [Node] -> {ok, Node}; + [] -> {error, not_found} + end. + + %%----------------------------------------------------------------------------- %% do_get_relationships(Nref, Direction) -> %% {ok, [#relationship{}]} | {error, term()} @@ -739,6 +926,20 @@ check_category_guard(Nref) -> end. +%%----------------------------------------------------------------------------- +%% check_category_guard(Home, Nref) -> ok | {error, ...} +%%----------------------------------------------------------------------------- +check_category_guard(Home, Nref) -> + case do_get_node(Home, Nref) of + {ok, #node{kind = category}} -> + {error, category_nodes_are_immutable}; + {ok, _} -> + ok; + {error, _} = Err -> + Err + end. + + %%----------------------------------------------------------------------------- %% set_retired(Nref, Bool, State) -> {ok | {error, Reason}, State'} %% @@ -752,7 +953,20 @@ set_retired(Nref, _Bool, State) when Nref < ?NREF_START -> set_retired(Nref, Bool, State0) -> {RetAttr, State} = ensure_retired_nref(State0), Reply = case graphdb_mgr:transaction( - fun() -> set_retired_(Nref, Bool, RetAttr) end) of + fun() -> set_retired_(environment, Nref, Bool, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. + +%%----------------------------------------------------------------------------- +%% set_retired(Project, Nref, Bool, State) -> {ok | {error, Reason}, State'} +%% No permanent-tier guard for a project (see moduledoc above retire_node/2). +%%----------------------------------------------------------------------------- +set_retired(Project, Nref, Bool, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> set_retired_(Project, Nref, Bool, RetAttr) end) of {ok, ok} -> ok; {error, _}=E -> E end, @@ -772,18 +986,19 @@ ensure_retired_nref(#state{retired_nref = RetAttr} = State) -> {RetAttr, State}. %%----------------------------------------------------------------------------- -%% set_retired_(Nref, Bool, RetAttr) -> ok +%% set_retired_(Home, Nref, Bool, RetAttr) -> ok %% Tier-1 primitive. Must run inside an active mnesia transaction. Reads the %% node under a write lock, rewrites its AVP list so the `retired` marker %% reflects Bool, writes it back. Aborts with not_found if absent. %%----------------------------------------------------------------------------- -set_retired_(Nref, Bool, RetAttr) -> - case mnesia:read(nodes, Nref, write) of +set_retired_(Home, Nref, Bool, RetAttr) -> + NodesTab = graphdb_ns:node_table(Home), + case mnesia:read(NodesTab, Nref, write) of [] -> mnesia:abort(not_found); [Node] -> AVPs0 = Node#node.attribute_value_pairs, AVPs1 = set_marker(AVPs0, RetAttr, Bool), - mnesia:write(nodes, + mnesia:write(NodesTab, Node#node{attribute_value_pairs = AVPs1}, write) end. @@ -802,14 +1017,27 @@ do_update_node_avps(Nref, _AVPs, State) when Nref < ?NREF_START -> do_update_node_avps(Nref, AVPs, State0) -> {RetAttr, State} = ensure_retired_nref(State0), Reply = case graphdb_mgr:transaction( - fun() -> update_node_avps_in_txn(Nref, AVPs, RetAttr) end) of + fun() -> update_node_avps_in_txn(environment, Nref, AVPs, RetAttr) end) of {ok, ok} -> ok; {error, _}=E -> E end, {Reply, State}. %%----------------------------------------------------------------------------- -%% update_node_avps_in_txn(Nref, AVPs, RetAttr) -> ok +%% do_update_node_avps(Project, Nref, AVPs, State) -> {ok | {error, Reason}, State'} +%% No permanent-tier guard for a project. +%%----------------------------------------------------------------------------- +do_update_node_avps(Project, Nref, AVPs, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> update_node_avps_in_txn(Project, Nref, AVPs, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. + +%%----------------------------------------------------------------------------- +%% update_node_avps_in_txn(Home, Nref, AVPs, RetAttr) -> ok %% Tier-1 primitive. Must run inside an active mnesia transaction. Reads the %% node under a write lock; aborts not_found if absent. Aborts use_retire_api %% if any update targets the seeded `retired` attribute. Aborts @@ -817,8 +1045,9 @@ do_update_node_avps(Nref, AVPs, State0) -> %% Applies the merge and writes the node back. RetAttr is resolved by the %% caller OUTSIDE the transaction (load-bearing: no gen_server call in-txn). %%----------------------------------------------------------------------------- -update_node_avps_in_txn(Nref, AVPs, RetAttr) -> - case mnesia:read(nodes, Nref, write) of +update_node_avps_in_txn(Home, Nref, AVPs, RetAttr) -> + NodesTab = graphdb_ns:node_table(Home), + case mnesia:read(NodesTab, Nref, write) of [] -> mnesia:abort(not_found); [Node] -> @@ -826,7 +1055,7 @@ update_node_avps_in_txn(Nref, AVPs, RetAttr) -> ok = guard_instance_only(Node#node.attribute_value_pairs, AVPs), ok = guard_attribute_existence(AVPs), New = apply_avp_updates(Node#node.attribute_value_pairs, AVPs), - mnesia:write(nodes, Node#node{attribute_value_pairs = New}, write) + mnesia:write(NodesTab, Node#node{attribute_value_pairs = New}, write) end. %% Abort if any update (upsert or delete) targets the seeded `retired` attr. @@ -993,15 +1222,15 @@ is_avp_for(_, _) -> false. -define(PARENT_ARCS, [?ARC_CAT_PARENT, ?ARC_ATTR_PARENT, ?ARC_CLS_PARENT, ?ARC_INST_PARENT]). %%----------------------------------------------------------------------------- -%% expected_parents(Nref) -> [integer()] +%% expected_parents(RelTab, Nref) -> [integer()] %% -%% Reads outgoing arcs from Nref of kind composition or taxonomy whose -%% characterization is one of the parent-arc labels, and returns the -%% corresponding target nrefs (the node's parent set). Must run inside -%% an active mnesia transaction. +%% Reads outgoing arcs from Nref (in RelTab) of kind composition or +%% taxonomy whose characterization is one of the parent-arc labels, and +%% returns the corresponding target nrefs (the node's parent set). Must +%% run inside an active mnesia transaction. %%----------------------------------------------------------------------------- -expected_parents(Nref) -> - Arcs = mnesia:index_read(relationships, Nref, +expected_parents(RelTab, Nref) -> + Arcs = mnesia:index_read(RelTab, Nref, #relationship.source_nref), [A#relationship.target_nref || A <- Arcs, (A#relationship.kind =:= composition orelse @@ -1010,15 +1239,15 @@ expected_parents(Nref) -> %%----------------------------------------------------------------------------- -%% expected_classes(Nref) -> [integer()] +%% expected_classes(RelTab, Nref) -> [integer()] %% -%% Reads outgoing instantiation arcs from Nref (char=29) and returns -%% the corresponding target class nrefs. Non-instance nodes have no -%% instantiation arcs, so the returned list is naturally empty. Must +%% Reads outgoing instantiation arcs from Nref (in RelTab, char=29) and +%% returns the corresponding target class nrefs. Non-instance nodes have +%% no instantiation arcs, so the returned list is naturally empty. Must %% run inside an active mnesia transaction. %%----------------------------------------------------------------------------- -expected_classes(Nref) -> - Arcs = mnesia:index_read(relationships, Nref, +expected_classes(RelTab, Nref) -> + Arcs = mnesia:index_read(RelTab, Nref, #relationship.source_nref), [A#relationship.target_nref || A <- Arcs, A#relationship.kind =:= instantiation, @@ -1026,16 +1255,16 @@ expected_classes(Nref) -> %%----------------------------------------------------------------------------- -%% verify_one(Nref) -> [{Nref, Field, Expected, Actual}] +%% verify_one(NodesTab, RelTab, Nref) -> [{Nref, Field, Expected, Actual}] %% -%% Compares one node's cache fields against its arcs. Returns a list -%% of zero, one, or two mismatch tuples. Must run inside an active -%% mnesia transaction. -%%----------------------------------------------------------------------------- -verify_one(Nref) -> - [Node] = mnesia:read(nodes, Nref), - Parents = lists:sort(expected_parents(Nref)), - Classes = lists:sort(expected_classes(Nref)), +%% Compares one node's (in NodesTab) cache fields against its arcs (in +%% RelTab). Returns a list of zero, one, or two mismatch tuples. Must +%% run inside an active mnesia transaction. +%%----------------------------------------------------------------------------- +verify_one(NodesTab, RelTab, Nref) -> + [Node] = mnesia:read(NodesTab, Nref), + Parents = lists:sort(expected_parents(RelTab, Nref)), + Classes = lists:sort(expected_classes(RelTab, Nref)), Cached_P = lists:sort(Node#node.parents), Cached_C = lists:sort(Node#node.classes), P = case Cached_P =:= Parents of @@ -1050,17 +1279,17 @@ verify_one(Nref) -> %%----------------------------------------------------------------------------- -%% rebuild_one(Nref) -> ok +%% rebuild_one(NodesTab, RelTab, Nref) -> ok %% -%% Rewrites one node's cache fields from its arcs. Must run inside an -%% active mnesia transaction. +%% Rewrites one node's (in NodesTab) cache fields from its arcs (in +%% RelTab). Must run inside an active mnesia transaction. %%----------------------------------------------------------------------------- -rebuild_one(Nref) -> - [Node] = mnesia:read(nodes, Nref), - Parents = expected_parents(Nref), - Classes = expected_classes(Nref), +rebuild_one(NodesTab, RelTab, Nref) -> + [Node] = mnesia:read(NodesTab, Nref), + Parents = expected_parents(RelTab, Nref), + Classes = expected_classes(RelTab, Nref), Updated = Node#node{parents = Parents, classes = Classes}, - ok = mnesia:write(nodes, Updated, write). + ok = mnesia:write(NodesTab, Updated, write). %%----------------------------------------------------------------------------- diff --git a/apps/graphdb/src/graphdb_ns.erl b/apps/graphdb/src/graphdb_ns.erl index 44ead83..b2a135d 100644 --- a/apps/graphdb/src/graphdb_ns.erl +++ b/apps/graphdb/src/graphdb_ns.erl @@ -7,18 +7,23 @@ %% Created: 2026-06-29 %% Description: Pure namespace resolution module. Encodes which %% database namespace each kind of nref reference belongs -%% to. No dependencies on other modules; fixed lookup table +%% to, and resolves a Home into its physical table atoms. +%% No dependencies on other modules; fixed lookup table %% based on the project-environment separation model. %%--------------------------------------------------------------------- %% Revision History %%--------------------------------------------------------------------- %% Rev PA1 Date: 2026-06-29 Author: David W. Thomas %% Initial implementation. +%% Rev PA2 Date: 2026-08-05 Author: David W. Thomas +%% SP2: home-relative routing. namespace_of/1 and target_namespace/1 +%% replaced by /2 forms taking a Home (environment | project handle). +%% node_table/1 and rel_table/1 added. %%--------------------------------------------------------------------- -module(graphdb_ns). --export([namespace_of/1, target_namespace/1]). +-export([namespace_of/2, target_namespace/2, node_table/1, rel_table/1]). %%--------------------------------------------------------------------- %% NYI / UEM Macros @@ -34,27 +39,47 @@ end)). %%--------------------------------------------------------------------- -%% namespace_of(Role) -> environment | project | home +%% namespace_of(Home, Role) -> environment | Home %% -%% Encodes docs/designs/project-env-reference-namespace-model-design.md §3. -%% `home` = same store as the containing record (node's own DB / row's home). +%% Encodes docs/designs/project-env-reference-namespace-model-design.md §3 +%% (amended 2026-08-02 for home-relative routing). `Home` is the store the +%% containing record was read from: `environment` or a `graphdb_project` +%% handle. target_nref and source_nref are NOT roles here — they need the +%% arc label's target_kind too, so they route through target_namespace/2 +%% directly at the call site (see that design's §6 code block). %%--------------------------------------------------------------------- -namespace_of(characterization) -> environment; -namespace_of(reciprocal) -> environment; -namespace_of(avp_attribute) -> environment; -namespace_of(node_classes) -> environment; -namespace_of(taxonomy_parent) -> environment; -namespace_of(compositional_parent) -> project; -namespace_of(node_nref) -> home; -namespace_of(source_nref) -> home. +namespace_of(_Home, characterization) -> environment; +namespace_of(_Home, reciprocal) -> environment; +namespace_of(_Home, avp_attribute) -> environment; +namespace_of(_Home, node_classes) -> environment; +namespace_of(_Home, taxonomy_parent) -> environment; +namespace_of(Home, compositional_parent) -> Home; +namespace_of(Home, node_nref) -> Home. %%--------------------------------------------------------------------- -%% target_namespace(TargetKind) -> environment | project +%% target_namespace(Home, TargetKind) -> environment | Home %% -%% The single routed field (relationship.target_nref): project iff instance. +%% The routed-field resolver: category/attribute/class targets are always +%% environment; an instance target is home-relative (Home itself — whatever +%% that Home is, environment or a specific project). %%--------------------------------------------------------------------- -target_namespace(instance) -> project; -target_namespace(category) -> environment; -target_namespace(attribute) -> environment; -target_namespace(class) -> environment. +target_namespace(_Home, category) -> environment; +target_namespace(_Home, attribute) -> environment; +target_namespace(_Home, class) -> environment; +target_namespace(Home, instance) -> Home. + + +%%--------------------------------------------------------------------- +%% node_table(Home) -> atom() +%% rel_table(Home) -> atom() +%% +%% Resolves a Home into its physical Mnesia table atoms. `environment` +%% resolves to the literal shared tables; a project handle (as returned by +%% graphdb_project:open/1) carries its own table atoms directly. +%%--------------------------------------------------------------------- +node_table(environment) -> nodes; +node_table(#{nodes := T}) -> T. + +rel_table(environment) -> relationships; +rel_table(#{rels := T}) -> T. diff --git a/apps/graphdb/src/graphdb_project.erl b/apps/graphdb/src/graphdb_project.erl index fa78116..7d90ef6 100644 --- a/apps/graphdb/src/graphdb_project.erl +++ b/apps/graphdb/src/graphdb_project.erl @@ -78,8 +78,8 @@ end)). %%--------------------------------------------------------------------- %% Exports %%--------------------------------------------------------------------- --export([register_project/1, is_project/1, open_session/1, session_project/1, - require_session/1, +-export([register_project/1, is_project/1, open/1, + require_project/1, next_nref/1, next_rel_id_pair/1, add_relationship/5, add_relationship/6, add_relationship/7, add_class_membership/3, remove_relationship/4, remove_relationship/5, @@ -94,16 +94,85 @@ end)). %%--------------------------------------------------------------------- %% register_project(Name) -> {ok, ProjectNref} | {error, term()} %% -%% Creates a kind=instance node in the environment under the Projects -%% category (nref 5) via a pair of category composition arcs, then -%% returns the new node's nref. +%% Creates the project's anchor node (a kind=instance node in the +%% environment under the Projects category, nref 5) then its three +%% physical tables (nodes_, relationships_, counters_). %% -%% The nref and rel-id pair are allocated OUTSIDE the transaction fun: -%% calling gen_servers (graphdb_nref, rel_id_server) inside a Mnesia -%% activity is a latent deadlock — load-bearing invariant in this +%% mnesia:create_table/2 is a schema operation and cannot run inside a +%% transaction, so table creation happens AFTER the anchor write, not +%% atomically with it. Both steps are create-if-absent: create_anchor/1 +%% looks up an existing project anchor by Name first and reuses it if +%% found; ensure_tables/1 treats already_exists as success. A retried +%% register_project/1 call for the same Name therefore converges on the +%% same anchor and the same tables, rather than allocating a duplicate +%% anchor each time (this was NOT true before -- see TASKS.md). +%% +%% The anchor's nref and rel-id pair are allocated OUTSIDE the transaction +%% fun: calling gen_servers (graphdb_nref, rel_id_server) inside a Mnesia +%% activity is a latent deadlock -- load-bearing invariant in this %% codebase. %%--------------------------------------------------------------------- register_project(Name) when is_list(Name) -> + case create_anchor(Name) of + {ok, Nref} -> + try + ok = ensure_tables(Nref), + {ok, Nref} + catch + throw:{error, _} = Err -> Err + end; + {error, _} = Err -> + Err + end. + +%%--------------------------------------------------------------------- +%% create_anchor(Name) -> {ok, Nref} | {error, term()} +%% +%% Create-if-absent: looks up an existing project anchor by Name under +%% Projects (nref 5) first; if found, reuses its nref without allocating +%% a new one or writing anything. Otherwise allocates a fresh nref and +%% writes a new anchor node + composition arc pair. The by-name lookup +%% and the write are two separate transactions (nref/rel-id allocation +%% must stay outside any transaction fun), so this narrows but does not +%% eliminate the race between two concurrent first-time registrations of +%% the same Name -- unchanged from the existing rel_id_server/graphdb_nref +%% discipline used elsewhere (e.g. graphdb_attr:ensure_seed/2). +%%--------------------------------------------------------------------- +create_anchor(Name) -> + case find_anchor_by_name(Name) of + {ok, Nref} -> {ok, Nref}; + not_found -> create_new_anchor(Name); + {error, _} = Err -> Err + end. + +find_anchor_by_name(Name) -> + F = fun() -> + Arcs = mnesia:index_read(relationships, ?NREF_PROJECTS, + #relationship.source_nref), + ChildNrefs = [A#relationship.target_nref || A <- Arcs, + A#relationship.kind =:= composition, + A#relationship.characterization =:= ?ARC_CAT_CHILD], + Children = lists:flatmap(fun(N) -> mnesia:read(nodes, N) end, + ChildNrefs), + case lists:search(fun(N) -> anchor_has_name(N, Name) end, Children) of + {value, #node{nref = Nref}} -> {ok, Nref}; + false -> not_found + end + end, + case graphdb_mgr:transaction(F) of + {ok, Result} -> Result; + {error, Reason} -> {error, Reason} + end. + +anchor_has_name(#node{kind = instance, attribute_value_pairs = AVPs}, Name) -> + lists:any(fun + (#{attribute := ?NAME_ATTR_INSTANCE, value := V}) -> V =:= Name; + (_) -> false + end, AVPs); +anchor_has_name(_, _) -> + false. + +create_new_anchor(Name) -> Nref = graphdb_nref:get_next(), {Id1, Id2} = rel_id_server:get_id_pair(), NameAVP = #{attribute => ?NAME_ATTR_INSTANCE, value => Name}, @@ -129,6 +198,46 @@ register_project(Name) when is_list(Name) -> graphdb_mgr:transaction(Fun). +%%--------------------------------------------------------------------- +%% ensure_tables(Anchor) -> ok (throws {error, {create_table_failed, ...}}) +%% +%% Creates the project's three physical tables if absent. Mirrors +%% graphdb_bootstrap:create_tables/0's shape (disc_copies, record_info- +%% derived attributes, source_nref/target_nref index on relationships). +%% The counters table has no fixed record shape -- it is looked up by a +%% bare {Key, Value} tuple via mnesia:dirty_update_counter/3. +%%--------------------------------------------------------------------- +ensure_tables(Anchor) -> + NodeList = [node()], + ok = ensure_table(nodes_table(Anchor), [ + {record_name, node}, + {attributes, record_info(fields, node)}, + {disc_copies, NodeList} + ]), + ok = ensure_table(rels_table(Anchor), [ + {record_name, relationship}, + {attributes, record_info(fields, relationship)}, + {disc_copies, NodeList}, + {index, [source_nref, target_nref]} + ]), + ok = ensure_table(counters_table(Anchor), [ + {disc_copies, NodeList} + ]), + ok. + +ensure_table(Name, Opts) -> + case mnesia:create_table(Name, Opts) of + {atomic, ok} -> ok; + {aborted, {already_exists, Name}} -> ok; + {aborted, Reason} -> + throw({error, {create_table_failed, Name, Reason}}) + end. + +nodes_table(Anchor) -> list_to_atom("nodes_" ++ integer_to_list(Anchor)). +rels_table(Anchor) -> list_to_atom("relationships_" ++ integer_to_list(Anchor)). +counters_table(Anchor) -> list_to_atom("counters_" ++ integer_to_list(Anchor)). + + %%--------------------------------------------------------------------- %% is_project(Nref) -> boolean() %% @@ -143,86 +252,116 @@ is_project(Nref) -> %%--------------------------------------------------------------------- -%% open_session(ProjectNref) -> {ok, Session} | {error, not_a_project} +%% open(ProjectNref) -> +%% {ok, Project} | {error, not_a_project} | {error, no_store} %% -%% Opens a session on a registered project. Returns an opaque Session map -%% if the nref is a registered project, otherwise {error, not_a_project}. -%% Session is #{kind => project_session, project => Nref}. +%% Resolves a registered project's nref into its physical store handle. +%% {error, no_store} covers an anchor that predates SP2 (registered, but +%% without tables); SP4's migration resolves that state -- open/1 reports +%% it rather than silently creating an empty store. %%--------------------------------------------------------------------- -open_session(ProjectNref) -> +open(ProjectNref) -> case is_project(ProjectNref) of - true -> {ok, #{kind => project_session, project => ProjectNref}}; - false -> {error, not_a_project} + false -> + {error, not_a_project}; + true -> + case tables_exist(ProjectNref) of + true -> + {ok, #{anchor => ProjectNref, + nodes => nodes_table(ProjectNref), + rels => rels_table(ProjectNref), + counters => counters_table(ProjectNref)}}; + false -> + {error, no_store} + end end. +tables_exist(Anchor) -> + Tables = mnesia:system_info(tables), + lists:all(fun(T) -> lists:member(T, Tables) end, + [nodes_table(Anchor), rels_table(Anchor), counters_table(Anchor)]). + %%--------------------------------------------------------------------- -%% session_project(Session) -> ProjectNref +%% require_project(Project) -> ok | {error, invalid_project} %% -%% Extracts the project nref from an opaque session map. +%% Gate for project-scoped operations: a well-formed Project handle +%% passes; any other term is rejected. Pure (no store access) -- the +%% handle was already validated against the registry by open/1. %%--------------------------------------------------------------------- -session_project(#{kind := project_session, project := Nref}) -> Nref. +require_project(#{anchor := _, nodes := _, rels := _, counters := _}) -> ok; +require_project(_) -> + {error, invalid_project}. %%--------------------------------------------------------------------- -%% require_session(Session) -> ok | {error, invalid_session} +%% next_nref(Project) -> pos_integer() +%% next_rel_id_pair(Project) -> {pos_integer(), pos_integer()} %% -%% Gate for project-scoped operations: a well-formed project session -%% passes; any other term is rejected. Pure (no store access) — the -%% session was already validated against the registry by open_session/1. +%% Project-local allocators. mnesia:dirty_update_counter/3 on a key that +%% has never been written creates it with the increment as its value, so +%% the first call to either yields the low end of the unbounded monotonic +%% space -- no seeding needed at register_project/1 time. Dirty ops do not +%% participate in a surrounding transaction; callers must invoke these +%% OUTSIDE any transaction fun, same discipline as graphdb_nref:get_next/0 +%% and rel_id_server:get_id_pair/0 for the environment. %%--------------------------------------------------------------------- -require_session(#{kind := project_session, project := _}) -> ok; -require_session(_) -> {error, invalid_session}. +next_nref(#{counters := Counters}) -> + mnesia:dirty_update_counter(Counters, nref, 1). + +next_rel_id_pair(#{counters := Counters}) -> + Id2 = mnesia:dirty_update_counter(Counters, rel_id, 2), + {Id2 - 1, Id2}. %%===================================================================== %% Canonical project-scoped relationship API (SP1 §8 relocation). %% %% These are the project side of the environment/project split: they -%% take a project Session as the first argument and delegate to the -%% graphdb_instance implementations. The session is validated inside -%% graphdb_instance via require_session/1. +%% take a Project handle as the first argument and delegate to the +%% graphdb_instance implementations. The handle is validated inside +%% graphdb_instance via require_project/1. %%===================================================================== -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref) -> - graphdb_instance:add_relationship(Session, SourceNref, CharNref, +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + graphdb_instance:add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref). -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref) -> - graphdb_instance:add_relationship(Session, SourceNref, CharNref, + graphdb_instance:add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref). -add_relationship(Session, SourceNref, CharNref, TargetNref, ReciprocalNref, +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec) -> - graphdb_instance:add_relationship(Session, SourceNref, CharNref, + graphdb_instance:add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec). -add_class_membership(Session, InstanceNref, ClassNref) -> - graphdb_instance:add_class_membership(Session, InstanceNref, ClassNref). +add_class_membership(Project, InstanceNref, ClassNref) -> + graphdb_instance:add_class_membership(Project, InstanceNref, ClassNref). -remove_relationship(Session, SourceNref, CharNref, TargetNref) -> - graphdb_instance:remove_relationship(Session, SourceNref, CharNref, +remove_relationship(Project, SourceNref, CharNref, TargetNref) -> + graphdb_instance:remove_relationship(Project, SourceNref, CharNref, TargetNref). -remove_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref) -> - graphdb_instance:remove_relationship(Session, SourceNref, CharNref, +remove_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref) -> + graphdb_instance:remove_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref). -update_relationship(Session, SourceNref, CharNref, TargetNref, Updates) -> - graphdb_instance:update_relationship(Session, SourceNref, CharNref, +update_relationship(Project, SourceNref, CharNref, TargetNref, Updates) -> + graphdb_instance:update_relationship(Project, SourceNref, CharNref, TargetNref, Updates). -update_relationship(Session, SourceNref, CharNref, TargetNref, TemplateNref, +update_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref, Updates) -> - graphdb_instance:update_relationship(Session, SourceNref, CharNref, + graphdb_instance:update_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref, Updates). -update_relationship_both(Session, SourceNref, CharNref, TargetNref, Pair) -> - graphdb_instance:update_relationship_both(Session, SourceNref, CharNref, +update_relationship_both(Project, SourceNref, CharNref, TargetNref, Pair) -> + graphdb_instance:update_relationship_both(Project, SourceNref, CharNref, TargetNref, Pair). -update_relationship_both(Session, SourceNref, CharNref, TargetNref, TemplateNref, +update_relationship_both(Project, SourceNref, CharNref, TargetNref, TemplateNref, Pair) -> - graphdb_instance:update_relationship_both(Session, SourceNref, CharNref, + graphdb_instance:update_relationship_both(Project, SourceNref, CharNref, TargetNref, TemplateNref, Pair). diff --git a/apps/graphdb/src/graphdb_query.erl b/apps/graphdb/src/graphdb_query.erl index 552c068..2d48e1d 100644 --- a/apps/graphdb/src/graphdb_query.erl +++ b/apps/graphdb/src/graphdb_query.erl @@ -101,6 +101,7 @@ -export([ parse_query/1, new_session/0, + new_session/1, refresh/1, execute_query/1, execute_query/2, @@ -136,6 +137,21 @@ new_session() -> #{snapshot_at => os:timestamp(), cache => #{}}. +%% new_session(Project) -> Session +%% +%% Same as new_session/0 but binds the session to a Project, so bare-nref +%% reads (session_read_node/2, session_read_arcs/4) resolve Home per nref +%% via resolve_home/2 instead of assuming the environment table. The +%% Project value is stored as-is, unvalidated -- new_session/1 is a plain +%% data constructor, not an entry point that touches Mnesia or the +%% singleton, so there is nothing unsafe about accepting garbage here. +%% Validation happens at the two entry points that actually dispatch a +%% session against the worker: execute_query/2 and resume/2, below. +new_session(Project) -> + #{snapshot_at => os:timestamp(), + cache => #{}, + project => Project}. + refresh(Session) when is_map(Session) -> Session#{snapshot_at := os:timestamp(), cache := #{}}. @@ -143,11 +159,46 @@ refresh(Session) when is_map(Session) -> execute_query(Query) -> gen_server:call(?MODULE, {execute_query_1, Query}). +%% execute_query(Query, Session) -> {ok, _, _} | {partial, _, _, _} | {error, _} +%% +%% Gated on the caller side by validate_session_home/1 (SP2 review wave B +%% Fix 2) BEFORE the gen_server:call, mirroring graphdb_instance:with_home/2 +%% / with_project/2's established pattern: a Session built via +%% new_session/1 carries its `project` field completely unvalidated (see +%% that function's header), and dispatch/2 runs SYNCHRONOUSLY inside this +%% gen_server's own handle_call -- so a malformed Project field would reach +%% resolve_home/2's mnesia:dirty_read(graphdb_ns:node_table(Project), _) +%% and trip graphdb_ns:node_table/1's bare two-clause match FROM INSIDE the +%% singleton's own process, killing it. Rejecting it here returns a clean +%% {error, invalid_project} instead. execute_query(Query, Session) when is_map(Session) -> - gen_server:call(?MODULE, {execute_query_2, Query, Session}). + case validate_session_home(Session) of + ok -> gen_server:call(?MODULE, {execute_query_2, Query, Session}); + {error, _} = Err -> Err + end. +%% resume(Cont, Session) -> {ok, _, _} | {partial, _, _, _} | {error, _} +%% +%% Same gate as execute_query/2 above -- resume/2 also dispatches Session +%% straight into a handle_call that resolves bare nrefs via resolve_home/2. resume(Cont, Session) when is_map(Session) -> - gen_server:call(?MODULE, {resume, Cont, Session}). + case validate_session_home(Session) of + ok -> gen_server:call(?MODULE, {resume, Cont, Session}); + {error, _} = Err -> Err + end. + +%% validate_session_home(Session) -> ok | {error, invalid_project} +%% +%% A session with no `project` key (built via new_session/0) is always +%% environment-bound -- ok. A session built via new_session/1 must carry +%% either the atom `environment` or a well-formed Project handle; +%% graphdb_project:require_project/1 is reused for the well-formedness +%% check (same contract as graphdb_instance:with_home/2's write-side twin). +validate_session_home(Session) -> + case maps:get(project, Session, environment) of + environment -> ok; + Project -> graphdb_project:require_project(Project) + end. %% find_path/3 — public convenience matching the query task spec API. find_path(From, To, MaxDepth) -> @@ -239,10 +290,29 @@ dispatch(#q_instances_of{class = C, recursive = Recursive}, Session) -> true -> [C | all_subclasses(C)]; false -> [C] end, + %% Instances only ever live in projects: the class->instance + %% membership row (characterization = ?ARC_CLASS_TO_INST) is written + %% into the project's own relationship table + %% (graphdb_instance:instance_records/5), never into the + %% environment's, regardless of what resolve_home/2 would pick for + %% the bare class nref (a class node never exists in a project's + %% node table, so resolve_home/2 always answers `environment` for + %% it -- the wrong table for this particular arc shape). When the + %% session has a Project bound, route every class in Classes + %% through that project explicitly instead of resolve_home/2. With + %% no Project bound, preserve prior behaviour exactly (environment + %% read, which legitimately yields [] since no environment-resident + %% class has project-resident instances). + ProjectHome = maps:get(project, Session, undefined), {Instances, Session1} = lists:foldl( fun(Cl, {Acc, S}) -> - {Arcs, S1} = session_read_arcs(S, Cl, outgoing, - [instantiation]), + {Arcs, S1} = if + ProjectHome =:= undefined -> + session_read_arcs(S, Cl, outgoing, [instantiation]); + true -> + session_read_arcs_home(S, ProjectHome, Cl, outgoing, + [instantiation]) + end, Members = [A#relationship.target_nref || A <- Arcs, A#relationship.characterization =:= ?ARC_CLASS_TO_INST], {Members ++ Acc, S1} @@ -261,21 +331,70 @@ dispatch(#q_find_path{from = From, to = To, max_depth = D, dispatch(_Query, Session) -> {{error, not_implemented}, Session}. +%%--------------------------------------------------------------------- +%% resolve_home(Session, Nref) -> environment | Project +%% +%% Determines which store an Nref belongs to when no relationship +%% context is available. Every session_read_node/session_read_arcs call +%% in this module is a bare-nref, no-characterization-context read +%% (#q_get_node{}, #q_describe{}, #q_find_path{}'s endpoints, and every +%% arc-discovered nref during BFS/#q_instances_of{} traversal) -- the +%% query language's records carry no target_kind/characterization +%% alongside the nref, unlike graphdb_instance's connection-arc +%% primitives, so this can't reuse graphdb_ns:target_namespace/2 +%% directly (it needs a TargetKind this module never has in hand). +%% +%% Resolution: try the session's bound Project first (if any); a +%% genuine ambiguity (the key exists in BOTH tables) is logged, and the +%% project's copy wins on the theory that a session opened against a +%% project is evidence of caller intent. This is deliberately +%% intent-following, not exhaustive-and-arbitrary: it is the one place +%% in SP2 where "try both, pick a winner" was chosen over the +%% home-relative determinism used everywhere else, because the query +%% language's entry points give no characterization context to +%% determine Home outright. +%%--------------------------------------------------------------------- +resolve_home(#{project := environment}, _Nref) -> + %% new_session/1 accepts the atom `environment` as a legitimate Home + %% (validate_session_home/1 blesses it). Match it ahead of the project + %% clause: without this, `environment` falls into the clause below and + %% maps:get(anchor, environment) raises badmap INSIDE the singleton. + environment; +resolve_home(#{project := Project}, Nref) when Project =/= undefined -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [_] -> + case mnesia:dirty_read(nodes, Nref) of + [_] -> + logger:warning( + "graphdb_query: nref ~p exists in both project ~p " + "and the environment -- resolving to the project", + [Nref, maps:get(anchor, Project)]); + [] -> + ok + end, + Project; + [] -> + environment + end; +resolve_home(_Session, _Nref) -> + environment. + %%--------------------------------------------------------------------- %% session_read_node(Session, Nref) -> {Node | not_found, Session1} %% -%% Read-through cache: a hit returns immediately; a miss reads Mnesia -%% and (if the node exists) populates the cache before returning. Misses -%% that hit Mnesia and find nothing are NOT cached — caching a negative -%% result would require threading the session on error replies, which -%% the current /2 API does not do. +%% Read-through cache: a hit returns immediately; a miss resolves Home +%% via resolve_home/2, reads Mnesia, and (if the node exists) populates +%% the cache before returning. Misses that hit Mnesia and find nothing +%% are NOT cached — caching a negative result would require threading +%% the session on error replies, which the current /2 API does not do. %% %% Cache key shape: {node, Nref}. %%--------------------------------------------------------------------- session_read_node(#{cache := Cache} = Session, Nref) -> case maps:get({node, Nref}, Cache, miss) of miss -> - case mnesia:dirty_read(nodes, Nref) of + Home = resolve_home(Session, Nref), + case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of [Node] -> Cache1 = Cache#{{node, Nref} => Node}, {Node, Session#{cache := Cache1}}; @@ -298,23 +417,50 @@ session_read_arcs(#{cache := Cache} = Session, Nref, Dir, Kinds) -> Key = {arcs, Nref, Dir, Kinds}, case maps:get(Key, Cache, miss) of miss -> - Arcs = read_arcs(Nref, Dir, Kinds), + Home = resolve_home(Session, Nref), + Arcs = read_arcs(Home, Nref, Dir, Kinds), Cache1 = Cache#{Key => Arcs}, {Arcs, Session#{cache := Cache1}}; Cached -> {Cached, Session} end. -read_arcs(Nref, outgoing, Kinds) -> - Raw = mnesia:dirty_index_read(relationships, Nref, +%%--------------------------------------------------------------------- +%% session_read_arcs_home(Session, Home, Nref, Direction, KindFilter) +%% -> {[#relationship{}], Session1} +%% +%% Same read-through cache as session_read_arcs/4, but Home is supplied +%% by the caller instead of being resolved via resolve_home/2 -- for +%% arc shapes (e.g. the project-side class->instance membership arc +%% read by #q_instances_of{}) where resolve_home/2's bare-nref +%% resolution would pick the wrong table. Cache key is {arcs, Home, +%% Nref, Direction, KindFilter}: a 5-tuple, deliberately a different +%% shape from session_read_arcs/4's 4-tuple {arcs, Nref, Direction, +%% KindFilter} key, so the two read paths can never collide in the +%% shared per-session cache map even when called for the same Nref -- +%% no entry written by one path is ever a valid key for the other. +%%--------------------------------------------------------------------- +session_read_arcs_home(#{cache := Cache} = Session, Home, Nref, Dir, Kinds) -> + Key = {arcs, Home, Nref, Dir, Kinds}, + case maps:get(Key, Cache, miss) of + miss -> + Arcs = read_arcs(Home, Nref, Dir, Kinds), + Cache1 = Cache#{Key => Arcs}, + {Arcs, Session#{cache := Cache1}}; + Cached -> + {Cached, Session} + end. + +read_arcs(Home, Nref, outgoing, Kinds) -> + Raw = mnesia:dirty_index_read(graphdb_ns:rel_table(Home), Nref, #relationship.source_nref), filter_kinds(Raw, Kinds); -read_arcs(Nref, incoming, Kinds) -> - Raw = mnesia:dirty_index_read(relationships, Nref, +read_arcs(Home, Nref, incoming, Kinds) -> + Raw = mnesia:dirty_index_read(graphdb_ns:rel_table(Home), Nref, #relationship.target_nref), filter_kinds(Raw, Kinds); -read_arcs(Nref, both, Kinds) -> - read_arcs(Nref, outgoing, Kinds) ++ read_arcs(Nref, incoming, Kinds). +read_arcs(Home, Nref, both, Kinds) -> + read_arcs(Home, Nref, outgoing, Kinds) ++ read_arcs(Home, Nref, incoming, Kinds). filter_kinds(Arcs, all) -> Arcs; filter_kinds(Arcs, Kinds) when is_list(Kinds) -> @@ -367,8 +513,11 @@ describe_attribute(#node{nref = N, parents = Parents, Children = [A#relationship.target_nref || A <- ChildArcs, A#relationship.characterization =:= ?ARC_ATTR_CHILD], AttrType = avp_value_of(AVPs, attribute_type_marker(Session1)), - {Labels, Session2} = resolve_labels([N, Parent | Children], LangSpec, - Session1), + %% N, Parent, and every taxonomy child are all attribute nrefs -- attribute + %% nodes are known-environment by construction (see resolve_labels_env/3), + %% so this bypasses resolve_home/2's ambiguous-nref guess entirely. + {Labels, Session2} = resolve_labels_env([N, Parent | Children], LangSpec, + Session1), Result = #{nref => N, kind => attribute, attribute_type => AttrType, @@ -400,7 +549,10 @@ describe_class(#node{nref = N, parents = Parents, QCAttrs = [A || {A, _Value} <- QCs], AllNrefs = lists:usort([N] ++ Superclasses ++ Ancestors ++ Subclasses ++ QCAttrs), - {Labels, Session1} = resolve_labels(AllNrefs, LangSpec, Session), + %% N, every superclass/ancestor/subclass, and every QC attribute are all + %% class or attribute nrefs -- known-environment by construction (see + %% resolve_labels_env/3), so this bypasses resolve_home/2 entirely. + {Labels, Session1} = resolve_labels_env(AllNrefs, LangSpec, Session), Result = #{nref => N, kind => class, superclasses => Superclasses, @@ -419,6 +571,12 @@ describe_class(#node{nref = N, parents = Parents, %% via 4-priority inheritance (Task 0's resolve_value/2 returns %% {ok, Value, Source}), and BOTH outgoing and incoming connection %% arcs (per-direction characterization and AVPs differ). +%% +%% Home is resolved once, via resolve_home/2 against the described +%% instance's own nref N, and threaded into the two Project-taking +%% graphdb_instance calls (compositional_ancestors/2, resolve_value/3) +%% below -- both operate on N's own compositional/attribute space, so +%% they share N's Home. %%--------------------------------------------------------------------- describe_instance(#node{nref = N, parents = Parents, classes = Classes, attribute_value_pairs = AVPs} = Node, LangSpec, @@ -427,7 +585,8 @@ describe_instance(#node{nref = N, parents = Parents, classes = Classes, [P | _] -> P; [] -> undefined end, - {ok, CompAncestorNodes} = graphdb_instance:compositional_ancestors(N), + Home = resolve_home(Session, N), + {ok, CompAncestorNodes} = graphdb_instance:compositional_ancestors(Home, N), CompAncestors = [Nd#node.nref || Nd <- CompAncestorNodes], %% class_ancestors is the transitive closure of "is-a" from the %% instance's classes, INCLUDING the direct classes themselves so @@ -438,7 +597,7 @@ describe_instance(#node{nref = N, parents = Parents, classes = Classes, {ok, AncNodes} = graphdb_class:ancestors(C), [Nd#node.nref || Nd <- AncNodes] end, Classes)), - Resolved = resolved_attributes(Node), + Resolved = resolved_attributes(Node, Home), {OutArcs, Session1} = session_read_arcs(Session, N, outgoing, [connection]), {InArcs, Session2} = session_read_arcs(Session1, N, incoming, @@ -451,15 +610,36 @@ describe_instance(#node{nref = N, parents = Parents, classes = Classes, source => A#relationship.source_nref, template => template_avp(A#relationship.avps)} || A <- InArcs], - AllNrefs = lists:usort( - [N] ++ Classes ++ ClassAncestors + %% Split into known-environment nrefs (classes/class-ancestors/arc-label + %% characterizations -- always environment by field-role, per + %% graphdb_ns:namespace_of/2) and genuinely ambiguous ones (N itself, + %% the compositional parent/ancestors, and connection target/source -- + %% all instance-space nrefs that may collide in key with an environment + %% nref, so they still need resolve_home/2's per-session guess). Routing + %% the first group directly to the environment table, instead of through + %% resolve_home/2, is the SP2 review wave B Fix 1: previously ALL of + %% AllNrefs went through resolve_home/2, so a project-bound session with + %% enough instances could silently misresolve a low environment nref + %% (e.g. an arc-label characterization) into the project's own table, + %% get back the wrong kind, and drop its label with no error. + EnvNrefs = lists:usort( + Classes ++ ClassAncestors + ++ [maps:get(characterization, M) || M <- Outgoing] + ++ [maps:get(characterization, M) || M <- Incoming]), + AmbiguousNrefs = lists:usort( + [N] ++ case CompositionalParent of undefined -> []; X -> [X] end ++ CompAncestors - ++ [maps:get(characterization, M) || M <- Outgoing] - ++ [maps:get(target, M) || M <- Outgoing] - ++ [maps:get(characterization, M) || M <- Incoming] - ++ [maps:get(source, M) || M <- Incoming]), - {Labels, Session3} = resolve_labels(AllNrefs, LangSpec, Session2), + ++ [maps:get(target, M) || M <- Outgoing] + ++ [maps:get(source, M) || M <- Incoming]), + {EnvLabels, Session3} = resolve_labels_env(EnvNrefs, LangSpec, + Session2), + {AmbiguousLabels, Session4} = resolve_labels(AmbiguousNrefs, LangSpec, + Session3), + %% EnvLabels second: on key overlap (reachable when a connection + %% target is also one of the instance's classes) the known-environment + %% resolution is authoritative over resolve_home/2's guess. + Labels = maps:merge(AmbiguousLabels, EnvLabels), Result = #{nref => N, kind => instance, classes => Classes, @@ -471,23 +651,25 @@ describe_instance(#node{nref = N, parents = Parents, classes = Classes, incoming_connections => Incoming, avps => AVPs, labels => Labels}, - {{ok, Result}, Session3}. + {{ok, Result}, Session4}. %%--------------------------------------------------------------------- -%% resolved_attributes(Node) -> #{AttrNref => #{value, source}} +%% resolved_attributes(Node, Home) -> #{AttrNref => #{value, source}} %% %% Walks every class's full QC list and resolves each via -%% graphdb_instance:resolve_value/2, which returns -%% {ok, Value, Source} (Task 0). +%% graphdb_instance:resolve_value/3, which returns +%% {ok, Value, Source} (Task 0). Home is the instance's own resolved +%% store (environment | Project), as determined by the caller via +%% resolve_home/2. %%--------------------------------------------------------------------- -resolved_attributes(#node{nref = N, classes = Classes}) -> +resolved_attributes(#node{nref = N, classes = Classes}, Home) -> QCAttrs = lists:usort(lists:flatmap( fun(C) -> {ok, QCs} = graphdb_class:inherited_qcs(C), [A || {A, _Value} <- QCs] end, Classes)), lists:foldl(fun(Q, Acc) -> - case graphdb_instance:resolve_value(N, Q) of + case graphdb_instance:resolve_value(Home, N, Q) of {ok, Value, Source} -> Acc#{Q => #{value => Value, source => Source}}; not_found -> Acc @@ -536,18 +718,56 @@ avp_value_of(AVPs, AttrNref) -> %%--------------------------------------------------------------------- %% resolve_labels(Nrefs, LangSpec, Session) -> {LabelMap, Session1} +%% resolve_labels_env(Nrefs, LangSpec, Session) -> {LabelMap, Session1} %% %% Resolves a label for every nref via graphdb_language. For %% LangSpec = default, uses base-language English. For %% {language, LangNref}, looks up the registered chain. Nrefs that %% resolve to no label are simply omitted from the map. +%% +%% Two entry points, both thin wrappers around the shared /4 worker below, +%% differing only in how each nref's kind is looked up for NAME_ATTR_* +%% selection (SP2 review wave B Fix 1): +%% +%% resolve_labels/3 -- for nrefs that are genuinely ambiguous bare +%% nrefs with no context to disambiguate them +%% (e.g. describe_instance's own nref, its +%% compositional parent/ancestors, and connection +%% target/source nrefs -- all instance-space, +%% may legitimately live in a bound Project, and +%% may collide in key with an environment nref). +%% Kind detection routes through resolve_home/2, +%% same as before. +%% +%% resolve_labels_env/3 -- for nrefs that are known-environment BY +%% CONSTRUCTION: arc-label characterizations, +%% class nrefs, and attribute nrefs always +%% resolve to the environment (see +%% graphdb_ns:namespace_of/2's characterization/ +%% node_classes/taxonomy_parent clauses -- classes +%% and attributes are never Project-resident). +%% Kind detection reads the environment `nodes` +%% table directly, with NO resolve_home/2 guess +%% at all -- so a project-bound session can never +%% misresolve one of these into the project's own +%% table and silently drop its label. Before +%% this fix, describe_attribute/describe_class +%% (100% known-environment nrefs) and half of +%% describe_instance's AllNrefs went through +%% resolve_home/2 unnecessarily/incorrectly. %%--------------------------------------------------------------------- resolve_labels(Nrefs, LangSpec, Session) -> + resolve_labels_4(Nrefs, LangSpec, Session, ambiguous). + +resolve_labels_env(Nrefs, LangSpec, Session) -> + resolve_labels_4(Nrefs, LangSpec, Session, environment). + +resolve_labels_4(Nrefs, LangSpec, Session, HomeMode) -> Chain = label_chain(LangSpec), Map = lists:foldl(fun (undefined, Acc) -> Acc; (N, Acc) when is_integer(N) -> - case resolve_one_label(N, Chain) of + case resolve_one_label(Session, N, Chain, HomeMode) of undefined -> Acc; Label -> Acc#{N => Label} end @@ -559,7 +779,10 @@ label_chain({language, LangNref}) -> case lookup_chain_for_nref(LangNref) of [] -> [en]; L -> L - end. + end; +%% Catch-all: a #q_describe{} left with `labels` unset (or carrying any +%% unrecognised spec) must not function_clause inside the singleton. +label_chain(_Other) -> [en]. lookup_chain_for_nref(LangNref) -> %% Translates a language Nref to a code, then asks @@ -569,30 +792,43 @@ lookup_chain_for_nref(LangNref) -> _ = LangNref, [en]. -resolve_one_label(Nref, Chain) -> - NameAttr = name_attr_for_node(Nref), +resolve_one_label(Session, Nref, Chain, HomeMode) -> + NameAttr = name_attr_for_node(Session, Nref, HomeMode), + %% The language overlay itself is environment-bound (labels are + %% registered against the shared language layer, not per-project) -- + %% only the kind-detection read above is Home-routed. case graphdb_language:resolve_label(Nref, NameAttr, Chain, environment) of {ok, Label} -> Label; not_found -> undefined end. %%--------------------------------------------------------------------- -%% name_attr_for_node(Nref) -> integer() +%% name_attr_for_node(Session, Nref, HomeMode) -> integer() %% %% Returns the appropriate NAME_ATTR_* for the node based on its kind. -%% Reads through dirty_read for kind detection. The catch-all -%% returns NAME_ATTR_CATEGORY as a safe default — templates and other -%% unknown kinds will simply fail to resolve, which the caller handles -%% by omitting them from the label map. -%%--------------------------------------------------------------------- -name_attr_for_node(Nref) -> - case mnesia:dirty_read(nodes, Nref) of - [#node{kind = category}] -> ?NAME_ATTR_CATEGORY; - [#node{kind = attribute}] -> ?NAME_ATTR_ATTRIBUTE; - [#node{kind = class}] -> ?NAME_ATTR_CLASS; - [#node{kind = instance}] -> ?NAME_ATTR_INSTANCE; - _ -> ?NAME_ATTR_CATEGORY - end. +%% +%% HomeMode = environment -- Nref is known-environment by construction +%% (see resolve_labels_env/3's header); reads the environment +%% `nodes` table directly, no resolve_home/2 guess. +%% HomeMode = ambiguous -- Nref is a genuinely ambiguous bare nref (may +%% be a project instance colliding in key with an environment +%% nref); resolves Home via resolve_home/2 first, as before. +%% +%% The catch-all returns NAME_ATTR_CATEGORY as a safe default — templates +%% and other unknown kinds will simply fail to resolve, which the caller +%% handles by omitting them from the label map. +%%--------------------------------------------------------------------- +name_attr_for_node(_Session, Nref, environment) -> + name_attr_of_kind(mnesia:dirty_read(nodes, Nref)); +name_attr_for_node(Session, Nref, ambiguous) -> + Home = resolve_home(Session, Nref), + name_attr_of_kind(mnesia:dirty_read(graphdb_ns:node_table(Home), Nref)). + +name_attr_of_kind([#node{kind = category}]) -> ?NAME_ATTR_CATEGORY; +name_attr_of_kind([#node{kind = attribute}]) -> ?NAME_ATTR_ATTRIBUTE; +name_attr_of_kind([#node{kind = class}]) -> ?NAME_ATTR_CLASS; +name_attr_of_kind([#node{kind = instance}]) -> ?NAME_ATTR_INSTANCE; +name_attr_of_kind(_) -> ?NAME_ATTR_CATEGORY. %%--------------------------------------------------------------------- %% all_subclasses(ClassNref) -> [integer()] @@ -674,7 +910,7 @@ expand_arcs(To, From, PathHere, To -> {Acc, V, {found, NewPath}, S}; _ -> - case maps:is_key(T, V) orelse is_scaffold_node(T) of + case maps:is_key(T, V) orelse is_scaffold_node(S, T) of true -> expand_arcs(To, From, PathHere, Rest, V, Acc, Found, S); @@ -687,7 +923,7 @@ expand_arcs(To, From, PathHere, end. %%--------------------------------------------------------------------- -%% is_scaffold_node(Nref) -> boolean() +%% is_scaffold_node(Session, Nref) -> boolean() %% %% Category nodes are structural scaffold (nrefs 1-5) -- never %% traversed by graph queries. Matches the semantics already encoded @@ -695,9 +931,18 @@ expand_arcs(To, From, PathHere, %% taxonomy walk. Without this filter, two classes sharing only %% NREF_CLASSES as a parent would be considered taxonomically %% connected, which contradicts both the design and existing helpers. -%%--------------------------------------------------------------------- -is_scaffold_node(Nref) -> - case mnesia:dirty_read(nodes, Nref) of +%% +%% Home-routed via resolve_home/2: a project instance can legitimately +%% collide in key with an environment scaffold nref (1-5). Without this, +%% BFS would silently skip a real project-instance hop as if it were +%% scaffold, producing a wrong path result with no error. When the +%% collision resolves to the project, the project's node is read (never +%% kind=category there), so is_scaffold_node correctly returns false and +%% the real instance hop is traversed. +%%--------------------------------------------------------------------- +is_scaffold_node(Session, Nref) -> + Home = resolve_home(Session, Nref), + case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of [#node{kind = category}] -> true; _ -> false end. diff --git a/apps/graphdb/src/graphdb_rules.erl b/apps/graphdb/src/graphdb_rules.erl index 9e9f343..e1a3566 100644 --- a/apps/graphdb/src/graphdb_rules.erl +++ b/apps/graphdb/src/graphdb_rules.erl @@ -173,7 +173,7 @@ seeded_nrefs() -> %% optional template_nref) lives on the node; rule deployment (Template, %% mode, multiplicity) lives on the applies_to connection arc from the %% owning (parent) class to the rule instance. Scope environment writes to -%% the shared ontology; {project, _} is not yet supported. +%% the shared ontology; {project, Project} is not yet supported. %%----------------------------------------------------------------------------- create_composition_rule(Scope, Name, ParentClass, ChildClass, Mode, Mult) -> create_composition_rule(Scope, Name, ParentClass, ChildClass, Mode, Mult, @@ -203,7 +203,7 @@ create_composition_rule(Scope, Name, ParentClass, ChildClass, Mode, Mult, %% node; rule deployment (Template, mode, multiplicity) lives on the applies_to %% connection arc from the owning (source) class to the rule instance. Recip is %% the reverse arc label: the arc as seen from the target back. Scope -%% environment writes to the shared ontology; {project, _} is not supported. +%% environment writes to the shared ontology; {project, Project} is not supported. %%----------------------------------------------------------------------------- create_connection_rule(Scope, Name, SourceClass, Char, Recip, TargetClass, Mode, Mult) -> @@ -221,7 +221,7 @@ create_connection_rule(Scope, Name, SourceClass, Char, Recip, TargetClass, %% %% Returns the full rule instance node iff RuleNref names a kind=instance %% node whose class membership includes CompositionRule or ConnectionRule. -%% Scope environment reads the shared ontology; {project, _} -> not_found. +%% Scope environment reads the shared ontology; {project, Project} -> not_found. %%----------------------------------------------------------------------------- get_rule(Scope, RuleNref) -> gen_server:call(?MODULE, {get_rule, Scope, RuleNref}). @@ -230,7 +230,7 @@ get_rule(Scope, RuleNref) -> %% rules_for_class(Scope, ClassNref) -> {ok, [#node{}]} %% %% All rules (both kinds) attached to ClassNref -- i.e. the targets of the -%% applies_to connection arcs out of ClassNref. {project, _} -> {ok, []}. +%% applies_to connection arcs out of ClassNref. {project, Project} -> {ok, []}. %% DIRECT attachments only: rules attached to ClassNref's taxonomy %% ancestors are NOT included. Ancestor-walking (effective_rules_for_class) %% is a later-phase addition. @@ -243,7 +243,7 @@ rules_for_class(Scope, ClassNref) -> %% connection_rules_for_class(Scope, ClassNref) -> {ok, [#node{}]} %% %% Attached rules of ClassNref filtered to the CompositionRule (resp. -%% ConnectionRule) meta-class. {project, _} -> {ok, []}. +%% ConnectionRule) meta-class. {project, Project} -> {ok, []}. %%----------------------------------------------------------------------------- composition_rules_for_class(Scope, ClassNref) -> gen_server:call(?MODULE, {rules_for_class_kind, Scope, ClassNref, @@ -263,7 +263,7 @@ connection_rules_for_class(Scope, ClassNref) -> %% first), each rule paired with that attachment's deployment map %% (#{mode, multiplicity, template}). Both rule kinds are returned; callers %% filter inline. Levels contributing no rules are omitted. -%% {project, _} -> {ok, []}. +%% {project, Project} -> {ok, []}. %% %% Does NOT resolve override/shadow/conflict -- every level's rules are %% present. Resolution is the firing engine's job. @@ -284,7 +284,7 @@ effective_rules_for_class(Scope, ClassNref) -> %% connection-firing engine consumes this during create_instance. Additive -- a %% rule reached from two ancestors appears twice; horizontal precedence is %% applied at firing time by the conflict resolver, not here. -%% {project, _} -> {ok, []}. +%% {project, Project} -> {ok, []}. %%----------------------------------------------------------------------------- effective_connection_rules(Scope, ClassNref) -> gen_server:call(?MODULE, {effective_connection_rules, Scope, ClassNref}). @@ -293,7 +293,7 @@ effective_connection_rules(Scope, ClassNref) -> %% list_rules(Scope) -> {ok, [#node{}]} %% %% Every rule instance in the ontology: the instances of both meta-classes. -%% {project, _} -> {ok, []}. +%% {project, Project} -> {ok, []}. %%----------------------------------------------------------------------------- list_rules(Scope) -> gen_server:call(?MODULE, {list_rules, Scope}). @@ -320,7 +320,7 @@ list_rules(Scope) -> %% {class_not_instantiable, ChildClassNref} -- %% a mandatory rule's child_class is abstract %% -%% Scope {project, _} returns a leaf plan immediately (no rule lookup). +%% Scope {project, Project} returns a leaf plan immediately (no rule lookup). %%----------------------------------------------------------------------------- plan_composition_firing(Scope, ClassNref) -> gen_server:call(?MODULE, {plan_composition_firing, Scope, ClassNref}). diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index c4c362f..0444483 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -113,6 +113,16 @@ get_instance_returns_node/1, get_instance_not_found/1, get_instance_rejects_non_instance/1, + %% SP2 review wave B Fix 2 -- read-path handle gating + get_instance_rejects_bad_session/1, + children_rejects_bad_session/1, + compositional_ancestors_rejects_bad_session/1, + class_of_rejects_bad_session/1, + class_memberships_rejects_bad_session/1, + resolve_value_rejects_bad_session/1, + compositional_ancestors_accepts_environment_home/1, + resolve_value_accepts_environment_home/1, + bad_session_does_not_crash_worker/1, %% Hierarchy children_returns_instance_children/1, children_empty_for_leaf/1, @@ -278,7 +288,16 @@ groups() -> {lookups, [], [ get_instance_returns_node, get_instance_not_found, - get_instance_rejects_non_instance + get_instance_rejects_non_instance, + get_instance_rejects_bad_session, + children_rejects_bad_session, + compositional_ancestors_rejects_bad_session, + class_of_rejects_bad_session, + class_memberships_rejects_bad_session, + resolve_value_rejects_bad_session, + compositional_ancestors_accepts_environment_home, + resolve_value_accepts_environment_home, + bad_session_does_not_crash_worker ]}, {hierarchy, [], [ children_returns_instance_children, @@ -424,11 +443,13 @@ init_per_testcase(TC, Config) -> %% Mirror production graphdb:start/2: flip to runtime tier after all workers %% have seeded so that user-level create_* calls allocate runtime nrefs. maybe_set_runtime_phase(TC), - %% SP1: pre-warm the project session (register a project + open it) as part - %% of setup, so its arc rows are already in the baseline before any test body - %% captures a relationships/nodes before/after delta. Runs in the test-case - %% process, so sess()'s process-dict memo is visible to the body. - _ = sess(), + %% SP2: pre-warm the project (register + open it) and its shared + %% compositional root as part of setup, so their table-creation/seed + %% writes are already in the baseline before any test body captures a + %% project-table before/after delta. Runs in the test-case process, so + %% proj()'s and root()'s process-dict memos are visible to the body. + _ = proj(), + _ = root(), setup_firing_fixtures(TC, Config1). %% Test cases that call graphdb_mgr:retire_node/1 require runtime nrefs. @@ -504,6 +525,7 @@ setup_isolated_env(Config) -> %%----------------------------------------------------------------------------- end_per_testcase(TC, Config) -> verify_cache_invariant(TC), + verify_project_cache_invariant(TC), catch gen_server:stop(graphdb_rules), catch gen_server:stop(graphdb_instance), catch gen_server:stop(graphdb_class), @@ -544,32 +566,59 @@ verify_cache_invariant(TC) -> _ -> ok end. +%% Project-scoped twin of verify_cache_invariant/1 (SP2). proj() memoises +%% the Project handle in the process dictionary on first use per testcase; +%% only testcases that actually touched a project (i.e. called proj()) +%% have anything to check. A failed verify is a fatal CT failure, same as +%% the environment check above. +verify_project_cache_invariant(TC) -> + case get(sp2_project) of + undefined -> + ok; + Project -> + case mnesia:system_info(is_running) of + yes -> + case graphdb_mgr:verify_caches(Project) of + ok -> ok; + {error, Mismatches} -> + ct:pal("Project cache invariant failed in ~p:~n~p", + [TC, Mismatches]), + ct:fail({project_cache_invariant_failed, TC, + Mismatches}) + end; + _ -> ok + end + end. + %%============================================================================= %% Creation Tests %%============================================================================= %%----------------------------------------------------------------------------- -%% Create an instance with a class and parent. Uses the Projects -%% category (nref 5) as the compositional parent anchor. +%% Create an instance with a class and parent. Uses root() (a throwaway +%% compositional-root instance seeded directly into Project's own table -- +%% project nrefs start at 1, so this is no longer the environment's +%% Projects category) as the compositional parent anchor. %%----------------------------------------------------------------------------- create_instance_basic(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Vehicle", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Car1", ClassNref, 5), - {ok, Node} = graphdb_instance:get_instance(InstNref), + Root = root(), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "Car1", ClassNref, Root), + {ok, Node} = graphdb_instance:get_instance(proj(), InstNref), ?assertEqual(instance, Node#node.kind), - ?assertEqual([5], Node#node.parents), + ?assertEqual([Root], Node#node.parents), ?assertEqual([#{attribute => ?NAME_ATTR_INSTANCE, value => "Car1"}], Node#node.attribute_value_pairs). %%----------------------------------------------------------------------------- -%% SP1: create_instance is a project op — a non-session term is rejected -%% before any store access (2-tuple {error, invalid_session}, consistent with +%% SP2: create_instance is a project op — a non-project term is rejected +%% before any store access (2-tuple {error, invalid_project}, consistent with %% the pre-PLAN validation error shape). %%----------------------------------------------------------------------------- create_instance_rejects_bad_session(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Vehicle", 3), - ?assertEqual({error, invalid_session}, + ?assertEqual({error, invalid_project}, graphdb_instance:create_instance(not_a_session, "Car1", ClassNref, 5)). %%----------------------------------------------------------------------------- @@ -578,14 +627,14 @@ create_instance_rejects_bad_session(_Config) -> create_instance_rejects_bad_class(_Config) -> %% Nref 6 (Names) is an attribute node ?assertMatch({error, {not_a_class, attribute}}, - graphdb_instance:create_instance(sess(), "Bad", 6, 5)). + graphdb_instance:create_instance(proj(), "Bad", 6, root())). %%----------------------------------------------------------------------------- %% Reject creation with a non-existent class. %%----------------------------------------------------------------------------- create_instance_rejects_missing_class(_Config) -> ?assertEqual({error, class_not_found}, - graphdb_instance:create_instance(sess(), "Bad", 99999, 5)). + graphdb_instance:create_instance(proj(), "Bad", 99999, root())). %%----------------------------------------------------------------------------- %% Reject creation with a non-existent parent. @@ -593,18 +642,23 @@ create_instance_rejects_missing_class(_Config) -> create_instance_rejects_missing_parent(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), ?assertEqual({error, parent_not_found}, - graphdb_instance:create_instance(sess(), "Bad", ClassNref, 99999)). + graphdb_instance:create_instance(proj(), "Bad", ClassNref, 99999)). %%----------------------------------------------------------------------------- %% Creating an instance must write membership arcs (char=29/30). %%----------------------------------------------------------------------------- create_instance_writes_membership_arcs(_Config) -> + Project = proj(), + RelsTab = graphdb_ns:rel_table(Project), {ok, ClassNref} = graphdb_class:create_class("Animal", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Dog1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(Project, "Dog1", ClassNref, root()), - %% Instance -> Class (char=29, reciprocal=30) + %% Instance -> Class (char=29, reciprocal=30). Both membership-arc + %% rows land in the PROJECT's own relationships table, even the + %% Class -> Instance direction whose source_nref is an environment + %% class nref (see instance_records/5's routing comment). {atomic, InstOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, InstNref, + mnesia:index_read(RelsTab, InstNref, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> @@ -615,7 +669,7 @@ create_instance_writes_membership_arcs(_Config) -> %% Class -> Instance (char=30, reciprocal=29) {atomic, ClassOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, ClassNref, + mnesia:index_read(RelsTab, ClassNref, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> @@ -628,12 +682,15 @@ create_instance_writes_membership_arcs(_Config) -> %% Creating an instance must write compositional arcs (char=28/27). %%----------------------------------------------------------------------------- create_instance_writes_compositional_arcs(_Config) -> + Project = proj(), + RelsTab = graphdb_ns:rel_table(Project), {ok, ClassNref} = graphdb_class:create_class("Part", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Bolt1", ClassNref, 5), + Root = root(), + {ok, InstNref, _} = graphdb_instance:create_instance(Project, "Bolt1", ClassNref, Root), - %% Parent (5) -> Child (InstNref) with char=28 + %% Parent (Root) -> Child (InstNref) with char=28 {atomic, ParentOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, 5, #relationship.source_nref) + mnesia:index_read(RelsTab, Root, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> R#relationship.target_nref =:= InstNref andalso @@ -641,13 +698,13 @@ create_instance_writes_compositional_arcs(_Config) -> R#relationship.reciprocal =:= ?ARC_INST_PARENT end, ParentOut)), - %% Child (InstNref) -> Parent (5) with char=27 + %% Child (InstNref) -> Parent (Root) with char=27 {atomic, ChildOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, InstNref, + mnesia:index_read(RelsTab, InstNref, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> - R#relationship.target_nref =:= 5 andalso + R#relationship.target_nref =:= Root andalso R#relationship.characterization =:= ?ARC_INST_PARENT andalso R#relationship.reciprocal =:= ?ARC_INST_CHILD end, ChildOut)). @@ -661,10 +718,17 @@ create_instance_refused_for_abstract_class(_Config) -> {ok, #{instantiable := Inst}} = graphdb_attr:seeded_nrefs(), {ok, ClassNref} = graphdb_class:create_class("Meta", 3, [#{attribute => Inst, value => false}]), - Before = mnesia:table_info(nodes, size), + %% proj() is pre-warmed in init_per_testcase, so its table-creation + %% write is already outside this measurement window. Measure the + %% PROJECT's own node table -- a rejected create_instance never + %% touches the environment's nodes table regardless of outcome, so + %% asserting against it would trivially pass without exercising + %% anything. + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), ?assertEqual({error, {class_not_instantiable, ClassNref}}, - graphdb_instance:create_instance(sess(), "Nope", ClassNref, 5)), - ?assertEqual(Before, mnesia:table_info(nodes, size)). + graphdb_instance:create_instance(proj(), "Nope", ClassNref, root())), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)). %%----------------------------------------------------------------------------- %% Ordinary classes still instantiate normally. @@ -672,7 +736,7 @@ create_instance_refused_for_abstract_class(_Config) -> create_instance_allowed_for_unmarked_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Plain", 3), ?assertMatch({ok, _, _}, - graphdb_instance:create_instance(sess(), "Inst1", ClassNref, 5)). + graphdb_instance:create_instance(proj(), "Inst1", ClassNref, root())). %%----------------------------------------------------------------------------- %% create_instance rejects a retired class node. @@ -681,17 +745,21 @@ create_instance_refuses_retired_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("RetClass", 3), ok = graphdb_mgr:retire_node(ClassNref), ?assertEqual({error, {class_retired, ClassNref}}, - graphdb_instance:create_instance(sess(), "i", ClassNref, 3)). + graphdb_instance:create_instance(proj(), "i", ClassNref, root())). %%----------------------------------------------------------------------------- %% create_instance rejects a retired compositional parent. %%----------------------------------------------------------------------------- create_instance_refuses_retired_parent(_Config) -> {ok, ClassNref} = graphdb_class:create_class("PClass", 3), - {ok, Parent, _} = graphdb_instance:create_instance(sess(), "p", ClassNref, 3), - ok = graphdb_mgr:retire_node(Parent), + {ok, Parent, _} = graphdb_instance:create_instance(proj(), "p", ClassNref, root()), + %% Parent is a project-space nref (small integer, allocated by the + %% project's own counter) -- retire_node/1 checks the environment's + %% permanent-tier boundary and would misclassify a small project nref + %% as an immutable permanent-tier node. Use the project-taking twin. + ok = graphdb_mgr:retire_node(proj(), Parent), ?assertEqual({error, {parent_retired, Parent}}, - graphdb_instance:create_instance(sess(), "child", ClassNref, Parent)). + graphdb_instance:create_instance(proj(), "child", ClassNref, Parent)). %%============================================================================= @@ -703,14 +771,17 @@ create_instance_refuses_retired_parent(_Config) -> %%----------------------------------------------------------------------------- add_relationship_basic(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), %% Create a relationship attribute pair for testing {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - RelsBefore = mnesia:table_info(relationships, size), - ok = graphdb_instance:add_relationship(sess(), A, MakesNref, B, MadeByNref), - RelsAfter = mnesia:table_info(relationships, size), + %% Connection rows land in the PROJECT's own relationships table, not + %% the environment's -- measure the right one. + RelsTab = graphdb_ns:rel_table(proj()), + RelsBefore = mnesia:table_info(RelsTab, size), + ok = graphdb_instance:add_relationship(proj(), A, MakesNref, B, MadeByNref), + RelsAfter = mnesia:table_info(RelsTab, size), ?assertEqual(RelsBefore + 2, RelsAfter). %%----------------------------------------------------------------------------- @@ -718,15 +789,15 @@ add_relationship_basic(_Config) -> %%----------------------------------------------------------------------------- add_relationship_both_directions(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), - {ok, Ford, _} = graphdb_instance:create_instance(sess(), "Ford", ClassNref, 5), - {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", ClassNref, 5), + {ok, Ford, _} = graphdb_instance:create_instance(proj(), "Ford", ClassNref, root()), + {ok, Taurus, _} = graphdb_instance:create_instance(proj(), "Taurus", ClassNref, root()), {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - ok = graphdb_instance:add_relationship(sess(), Ford, MakesNref, Taurus, MadeByNref), + ok = graphdb_instance:add_relationship(proj(), Ford, MakesNref, Taurus, MadeByNref), %% Ford -> Taurus (char=Makes, reciprocal=MadeBy) {atomic, FordOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, Ford, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), Ford, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> R#relationship.target_nref =:= Taurus andalso @@ -736,7 +807,7 @@ add_relationship_both_directions(_Config) -> %% Taurus -> Ford (char=MadeBy, reciprocal=Makes) {atomic, TaurusOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, Taurus, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), Taurus, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> R#relationship.target_nref =:= Ford andalso @@ -751,14 +822,14 @@ add_relationship_both_directions(_Config) -> add_relationship_stamps_template_avp(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, @@ -773,14 +844,14 @@ add_relationship_stamps_template_avp(_Config) -> add_relationship_explicit_template(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Person", 3), {ok, AltTmpl} = graphdb_class:add_template(ClassNref, "social"), - {ok, A, _} = graphdb_instance:create_instance(sess(), "Alice", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "Bob", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "Alice", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "Bob", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, AltTmpl), {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, @@ -793,13 +864,13 @@ add_relationship_explicit_template(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_non_template_nref(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Animal", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), %% ClassNref is a class, not a template ?assertMatch({error, {invalid_template, _, not_a_template}}, - graphdb_instance:add_relationship(sess(), A, Char, B, Recip, ClassNref)). + graphdb_instance:add_relationship(proj(), A, Char, B, Recip, ClassNref)). %%----------------------------------------------------------------------------- %% add_relationship/5 rejects a template whose parent class is unrelated @@ -809,12 +880,12 @@ add_relationship_rejects_template_out_of_ancestry(_Config) -> {ok, AnimalCls} = graphdb_class:create_class("Animal", 3), {ok, VehicleCls} = graphdb_class:create_class("Vehicle", 3), {ok, VehTmpl} = graphdb_class:default_template(VehicleCls), - {ok, A, _} = graphdb_instance:create_instance(sess(), "Cat", AnimalCls, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "Dog", AnimalCls, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "Cat", AnimalCls, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "Dog", AnimalCls, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertMatch({error, {template_class_not_in_ancestry, _, _, _, _}}, - graphdb_instance:add_relationship(sess(), A, Char, B, Recip, VehTmpl)). + graphdb_instance:add_relationship(proj(), A, Char, B, Recip, VehTmpl)). %%----------------------------------------------------------------------------- %% After deleting the default template, /4 returns no_default_template; @@ -823,61 +894,61 @@ add_relationship_rejects_template_out_of_ancestry(_Config) -> add_relationship_no_default_after_delete(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Animal", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), {atomic, ok} = mnesia:transaction(fun() -> mnesia:delete({nodes, DefaultTmpl}) end), ?assertEqual({error, no_default_template}, - graphdb_instance:add_relationship(sess(), A, Char, B, Recip)). + graphdb_instance:add_relationship(proj(), A, Char, B, Recip)). %%----------------------------------------------------------------------------- %% missing source nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_source(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {source_not_found, 99999}}, - graphdb_instance:add_relationship(sess(), 99999, Char, B, Recip)). + graphdb_instance:add_relationship(proj(), 99999, Char, B, Recip)). %%----------------------------------------------------------------------------- %% missing target nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_target(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {target_not_found, 99999}}, - graphdb_instance:add_relationship(sess(), A, Char, 99999, Recip)). + graphdb_instance:add_relationship(proj(), A, Char, 99999, Recip)). %%----------------------------------------------------------------------------- %% missing characterization nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_characterization(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {_Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {characterization_not_found, 99999}}, - graphdb_instance:add_relationship(sess(), A, 99999, B, Recip)). + graphdb_instance:add_relationship(proj(), A, 99999, B, Recip)). %%----------------------------------------------------------------------------- %% missing reciprocal nref is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_missing_reciprocal(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, _Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertEqual({error, {reciprocal_not_found, 99999}}, - graphdb_instance:add_relationship(sess(), A, Char, B, 99999)). + graphdb_instance:add_relationship(proj(), A, Char, B, 99999)). %%----------------------------------------------------------------------------- %% characterization that is not kind=attribute is rejected. Uses @@ -885,24 +956,24 @@ add_relationship_rejects_missing_reciprocal(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_non_attribute_char(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {_Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertMatch({error, {characterization_not_an_attribute, 5, category}}, - graphdb_instance:add_relationship(sess(), A, 5, B, Recip)). + graphdb_instance:add_relationship(proj(), A, 5, B, Recip)). %%----------------------------------------------------------------------------- %% reciprocal that is not kind=attribute is rejected. %%----------------------------------------------------------------------------- add_relationship_rejects_non_attribute_reciprocal(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, _Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), ?assertMatch({error, {reciprocal_not_an_attribute, 5, category}}, - graphdb_instance:add_relationship(sess(), A, Char, B, 5)). + graphdb_instance:add_relationship(proj(), A, Char, B, 5)). %%----------------------------------------------------------------------------- %% target whose kind disagrees with the characterization's @@ -911,39 +982,53 @@ add_relationship_rejects_non_attribute_reciprocal(_Config) -> %%----------------------------------------------------------------------------- add_relationship_rejects_target_kind_mismatch(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), %% target_kind=class, but B is an instance {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Has", "HeldBy", class), ?assertEqual({error, {target_kind_mismatch, class, instance}}, - graphdb_instance:add_relationship(sess(), A, Char, B, Recip)). + graphdb_instance:add_relationship(proj(), A, Char, B, Recip)). %%----------------------------------------------------------------------------- %% source that exists and passes endpoint validation but has no instance->class -%% membership arc is rejected. A class node is such a node: validate_arc_endpoints -%% does not constrain the source's kind, and a class has no ?ARC_INST_TO_CLASS arc. +%% membership arc is rejected. SourceNref/TargetNref endpoint lookups are +%% Project-scoped (SP2), so an environment class nref would simply be +%% source_not_found -- root() (a genuine project instance seeded directly, +%% bypassing create_instance's membership write) is such a node instead: +%% validate_arc_endpoints does not constrain the source's kind, and root() +%% has no ?ARC_INST_TO_CLASS arc. %%----------------------------------------------------------------------------- add_relationship_rejects_source_has_no_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ?assertEqual({error, {source_has_no_class, ClassNref}}, - graphdb_instance:add_relationship(sess(), ClassNref, Char, B, Recip)). + Root = root(), + ?assertEqual({error, {source_has_no_class, Root}}, + graphdb_instance:add_relationship(proj(), Root, Char, B, Recip)). %%----------------------------------------------------------------------------- %% target that exists and passes endpoint validation but has no instance->class -%% membership arc is rejected. Char's target_kind=class lets a class node pass -%% endpoint validation as the target; the class has no ?ARC_INST_TO_CLASS arc. +%% membership arc is rejected. Char's target_kind=class requires the target's +%% kind field to be class; TargetNref lookups are Project-scoped (SP2), so an +%% environment class nref would simply be target_not_found -- seed a raw +%% kind=class node directly into Project's own table instead: it satisfies +%% check_target_kind's target_kind=class requirement while still lacking an +%% ?ARC_INST_TO_CLASS arc. %%----------------------------------------------------------------------------- add_relationship_rejects_target_has_no_class(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(Project, "A", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Has", "HeldBy", class), - ?assertEqual({error, {target_has_no_class, ClassNref}}, - graphdb_instance:add_relationship(sess(), A, Char, ClassNref, Recip)). + ClassLikeNref = graphdb_project:next_nref(Project), + ClassLikeNode = #node{nref = ClassLikeNref, kind = class, + attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), ClassLikeNode), + ?assertEqual({error, {target_has_no_class, ClassLikeNref}}, + graphdb_instance:add_relationship(Project, A, Char, ClassLikeNref, Recip)). %%----------------------------------------------------------------------------- @@ -954,18 +1039,18 @@ add_relationship_rejects_target_has_no_class(_Config) -> add_relationship_stamps_user_avps(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), {ok, Confidence} = graphdb_attr:create_literal_attribute("confidence", float), UserAVP = #{attribute => Confidence, value => 0.95}, - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl, + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, DefaultTmpl, {[UserAVP], [UserAVP]}), %% Both directions should carry Template AVP and the user AVP. {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, @@ -981,19 +1066,19 @@ add_relationship_stamps_user_avps(_Config) -> add_relationship_avps_are_per_direction(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), {ok, Source} = graphdb_attr:create_literal_attribute("source", string), {ok, Confidence} = graphdb_attr:create_literal_attribute("conf", float), FwdOnly = #{attribute => Source, value => "research-paper"}, RevOnly = #{attribute => Confidence, value => 0.42}, - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl, + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, DefaultTmpl, {[FwdOnly], [RevOnly]}), {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, @@ -1002,7 +1087,7 @@ add_relationship_avps_are_per_direction(_Config) -> ?assertNot(lists:member(RevOnly, Fwd#relationship.avps)), {atomic, BRels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, B, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), B, #relationship.source_nref) end), [Rev] = [R || R <- BRels, R#relationship.characterization =:= Recip, @@ -1017,14 +1102,14 @@ add_relationship_avps_are_per_direction(_Config) -> add_relationship_default_avps_empty(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, @@ -1037,16 +1122,21 @@ add_relationship_default_avps_empty(_Config) -> %%----------------------------------------------------------------------------- add_relationship_refuses_retired_endpoint(_Config) -> {ok, ClassNref} = graphdb_class:create_class("ArcClass", 3), - {ok, Src, _} = graphdb_instance:create_instance(sess(), "s", ClassNref, 3), - {ok, Tgt, _} = graphdb_instance:create_instance(sess(), "t", ClassNref, 3), + {ok, Src, _} = graphdb_instance:create_instance(proj(), "s", ClassNref, root()), + {ok, Tgt, _} = graphdb_instance:create_instance(proj(), "t", ClassNref, root()), {ok, {Fwd, Rec}} = graphdb_attr:create_relationship_attribute_pair("Likes", "LikedBy", instance), - ok = graphdb_instance:add_relationship(sess(), Src, Fwd, Tgt, Rec), - ok = graphdb_mgr:retire_node(Tgt), - {ok, Tgt2, _} = graphdb_instance:create_instance(sess(), "t2", ClassNref, 3), - ok = graphdb_mgr:retire_node(Tgt2), + ok = graphdb_instance:add_relationship(proj(), Src, Fwd, Tgt, Rec), + %% Tgt/Tgt2 are project-space nrefs -- retire_node/1 checks the + %% environment's permanent-tier boundary and would misclassify a small + %% project nref as an immutable permanent-tier node. Use the + %% project-taking twin (no permanent-tier guard: every project nref + %% is mutable). + ok = graphdb_mgr:retire_node(proj(), Tgt), + {ok, Tgt2, _} = graphdb_instance:create_instance(proj(), "t2", ClassNref, root()), + ok = graphdb_mgr:retire_node(proj(), Tgt2), ?assertEqual({error, {endpoint_retired, Tgt2}}, - graphdb_instance:add_relationship(sess(), Src, Fwd, Tgt2, Rec)). + graphdb_instance:add_relationship(proj(), Src, Fwd, Tgt2, Rec)). %%----------------------------------------------------------------------------- @@ -1054,8 +1144,8 @@ add_relationship_refuses_retired_endpoint(_Config) -> %%----------------------------------------------------------------------------- class_of_returns_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Color", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "Red", ClassNref, 5), - ?assertEqual({ok, ClassNref}, graphdb_instance:class_of(InstNref)). + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "Red", ClassNref, root()), + ?assertEqual({ok, ClassNref}, graphdb_instance:class_of(proj(), InstNref)). %%============================================================================= @@ -1067,8 +1157,8 @@ class_of_returns_class(_Config) -> %%----------------------------------------------------------------------------- get_instance_returns_node(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Widget", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "W1", ClassNref, 5), - {ok, Node} = graphdb_instance:get_instance(InstNref), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "W1", ClassNref, root()), + {ok, Node} = graphdb_instance:get_instance(proj(), InstNref), ?assertEqual(InstNref, Node#node.nref), ?assertEqual(instance, Node#node.kind). @@ -1076,14 +1166,106 @@ get_instance_returns_node(_Config) -> %% get_instance returns {error, not_found} for unknown nref. %%----------------------------------------------------------------------------- get_instance_not_found(_Config) -> - ?assertEqual({error, not_found}, graphdb_instance:get_instance(99999)). + ?assertEqual({error, not_found}, graphdb_instance:get_instance(proj(), 99999)). %%----------------------------------------------------------------------------- %% get_instance rejects non-instance nodes. %%----------------------------------------------------------------------------- get_instance_rejects_non_instance(_Config) -> - %% Nref 1 (Root) is a category - ?assertEqual({error, not_an_instance}, graphdb_instance:get_instance(1)). + %% get_instance/2 reads exclusively from Project's own table -- a + %% bootstrap category node (e.g. the environment's Root, nref 1) never + %% lives there. Seed a raw non-instance (kind=class) node directly + %% into Project's table to exercise the not_an_instance path. + Project = proj(), + Nref = graphdb_project:next_nref(Project), + Node = #node{nref = Nref, kind = class, attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), Node), + ?assertEqual({error, not_an_instance}, graphdb_instance:get_instance(Project, Nref)). + + +%%----------------------------------------------------------------------------- +%% SP2 review wave B Fix 2 -- read-path handle gating. +%% +%% Before this fix, get_instance/2, children/2, compositional_ancestors/2, +%% class_of/2, class_memberships/2, and resolve_value/3 all called +%% gen_server:call directly with an unvalidated Project/Home argument. A +%% malformed handle (anything that is neither `environment` nor a +%% well-formed Project map) reached graphdb_ns:node_table/1's bare +%% two-clause match INSIDE the graphdb_instance worker process, which +%% raised function_clause and crashed the singleton shared by every +%% project. with_home/2 now gates all six on the caller side, before the +%% gen_server:call, exactly like the write path's with_project/2. +%%----------------------------------------------------------------------------- +get_instance_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_project}, + graphdb_instance:get_instance(not_a_session, 1)). + +children_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_project}, + graphdb_instance:children(not_a_session, 1)). + +compositional_ancestors_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_project}, + graphdb_instance:compositional_ancestors(not_a_session, 1)). + +class_of_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_project}, + graphdb_instance:class_of(not_a_session, 1)). + +class_memberships_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_project}, + graphdb_instance:class_memberships(not_a_session, 1)). + +resolve_value_rejects_bad_session(_Config) -> + ?assertEqual({error, invalid_project}, + graphdb_instance:resolve_value(not_a_session, 1, 2)). + +%%----------------------------------------------------------------------------- +%% compositional_ancestors/2 and resolve_value/3 are the two reads +%% graphdb_query threads resolve_home/2's result into directly, and that +%% result can be the atom `environment` (an ordinary environment-resident +%% nref) -- plain with_project/2 (which REJECTS `environment`) would have +%% broken every such call. with_home/2 must accept it: prove it does, by +%% pointing both functions at the environment's own bootstrap Root node +%% (nref 1, kind=category) and confirming the call reaches the environment +%% table (a real not_an_instance / not_found reply) instead of +%% short-circuiting with {error, invalid_project}. +%%----------------------------------------------------------------------------- +compositional_ancestors_accepts_environment_home(_Config) -> + ?assertEqual({error, not_an_instance}, + graphdb_instance:compositional_ancestors(environment, ?NREF_ROOT)). + +resolve_value_accepts_environment_home(_Config) -> + ?assertEqual({error, not_an_instance}, + graphdb_instance:resolve_value(environment, ?NREF_ROOT, ?NREF_ROOT)). + +%%----------------------------------------------------------------------------- +%% The empirical proof the review demanded: a malformed handle returns a +%% clean error AND the graphdb_instance worker's pid is unchanged +%% before/after -- i.e. it never crashed and was never restarted by its +%% supervisor. (This suite starts graphdb_instance directly via +%% start_link/0, not under a supervisor, so a crash would leave the +%% registered name unregistered rather than restarted -- whereis/1 would +%% return `undefined` afterwards instead of a different pid. Either +%% divergence from the pre-call pid proves the crash.) +%%----------------------------------------------------------------------------- +bad_session_does_not_crash_worker(_Config) -> + PidBefore = whereis(graphdb_instance), + ?assert(is_pid(PidBefore)), + ?assertEqual({error, invalid_project}, + graphdb_instance:get_instance(not_a_session, 1)), + ?assertEqual({error, invalid_project}, + graphdb_instance:children(not_a_session, 1)), + ?assertEqual({error, invalid_project}, + graphdb_instance:compositional_ancestors(not_a_session, 1)), + ?assertEqual({error, invalid_project}, + graphdb_instance:class_of(not_a_session, 1)), + ?assertEqual({error, invalid_project}, + graphdb_instance:class_memberships(not_a_session, 1)), + ?assertEqual({error, invalid_project}, + graphdb_instance:resolve_value(not_a_session, 1, 2)), + PidAfter = whereis(graphdb_instance), + ?assertEqual(PidBefore, PidAfter). %%============================================================================= @@ -1095,10 +1277,10 @@ get_instance_rejects_non_instance(_Config) -> %%----------------------------------------------------------------------------- children_returns_instance_children(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Car", 3), - {ok, Car, _} = graphdb_instance:create_instance(sess(), "MyCar", ClassNref, 5), - {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine1", ClassNref, Car), - {ok, Wheel, _} = graphdb_instance:create_instance(sess(), "Wheel1", ClassNref, Car), - {ok, Kids} = graphdb_instance:children(Car), + {ok, Car, _} = graphdb_instance:create_instance(proj(), "MyCar", ClassNref, root()), + {ok, Engine, _} = graphdb_instance:create_instance(proj(), "Engine1", ClassNref, Car), + {ok, Wheel, _} = graphdb_instance:create_instance(proj(), "Wheel1", ClassNref, Car), + {ok, Kids} = graphdb_instance:children(proj(), Car), KidNrefs = lists:sort([N#node.nref || N <- Kids]), ?assertEqual(lists:sort([Engine, Wheel]), KidNrefs). @@ -1107,30 +1289,41 @@ children_returns_instance_children(_Config) -> %%----------------------------------------------------------------------------- children_empty_for_leaf(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Leaf", 3), - {ok, Leaf, _} = graphdb_instance:create_instance(sess(), "Leaf1", ClassNref, 5), - ?assertEqual({ok, []}, graphdb_instance:children(Leaf)). + {ok, Leaf, _} = graphdb_instance:create_instance(proj(), "Leaf1", ClassNref, root()), + ?assertEqual({ok, []}, graphdb_instance:children(proj(), Leaf)). %%----------------------------------------------------------------------------- %% compositional_ancestors returns the chain in nearest-first order. %%----------------------------------------------------------------------------- ancestors_returns_chain(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), - {ok, Car, _} = graphdb_instance:create_instance(sess(), "Car", ClassNref, 5), - {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine", ClassNref, Car), - {ok, Block, _} = graphdb_instance:create_instance(sess(), "Block", ClassNref, Engine), - {ok, Ancestors} = graphdb_instance:compositional_ancestors(Block), + Root = root(), + {ok, Car, _} = graphdb_instance:create_instance(proj(), "Car", ClassNref, Root), + {ok, Engine, _} = graphdb_instance:create_instance(proj(), "Engine", ClassNref, Car), + {ok, Block, _} = graphdb_instance:create_instance(proj(), "Block", ClassNref, Engine), + {ok, Ancestors} = graphdb_instance:compositional_ancestors(proj(), Block), AncNrefs = [N#node.nref || N <- Ancestors], - %% Nearest-first: Engine, then Car - ?assertEqual([Engine, Car], AncNrefs). + %% Nearest-first: Engine, then Car, then Root itself -- root() is a + %% genuine kind=instance node (unlike the old single-store stand-in, + %% the environment's non-instance Projects category), so the walk + %% does not stop before including it. + ?assertEqual([Engine, Car, Root], AncNrefs). %%----------------------------------------------------------------------------- %% compositional_ancestors returns empty for top-level instance (parent %% is a non-instance node like a category). %%----------------------------------------------------------------------------- ancestors_empty_for_top_level(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("Top", 3), - {ok, Top, _} = graphdb_instance:create_instance(sess(), "Top1", ClassNref, 5), - ?assertEqual({ok, []}, graphdb_instance:compositional_ancestors(Top)). + %% Seed a non-instance (kind=category) node directly into Project's own + %% table -- root() is a kind=instance node and would itself show up as + %% an ancestor, defeating the point of this test. + CatNref = graphdb_project:next_nref(Project), + CatNode = #node{nref = CatNref, kind = category, attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), CatNode), + {ok, Top, _} = graphdb_instance:create_instance(Project, "Top1", ClassNref, CatNref), + ?assertEqual({ok, []}, graphdb_instance:compositional_ancestors(Project, Top)). %%============================================================================= @@ -1142,10 +1335,10 @@ ancestors_empty_for_top_level(_Config) -> %%----------------------------------------------------------------------------- resolve_value_local(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Thing", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "T1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "T1", ClassNref, root()), %% The name attribute (20) was set by create_instance ?assertMatch({ok, "T1", _}, - graphdb_instance:resolve_value(InstNref, ?NAME_ATTR_INSTANCE)). + graphdb_instance:resolve_value(proj(), InstNref, ?NAME_ATTR_INSTANCE)). %%----------------------------------------------------------------------------- %% resolve_value finds a value from the class node's AVPs. @@ -1155,10 +1348,10 @@ resolve_value_from_class(_Config) -> %% Add a custom AVP directly to the class node {ok, TestAttr} = graphdb_attr:create_literal_attribute("shade", string), set_avp(ClassNref, TestAttr, "blue"), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "C1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "C1", ClassNref, root()), %% Instance doesn't have shade — resolved from class ?assertMatch({ok, "blue", _}, - graphdb_instance:resolve_value(InstNref, TestAttr)). + graphdb_instance:resolve_value(proj(), InstNref, TestAttr)). %%----------------------------------------------------------------------------- %% resolve_value finds a value from a compositional ancestor. @@ -1166,13 +1359,13 @@ resolve_value_from_class(_Config) -> resolve_value_from_ancestor(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("location", string), - {ok, Car, _} = graphdb_instance:create_instance(sess(), "Car", ClassNref, 5), - set_avp(Car, TestAttr, "garage"), - {ok, Engine, _} = graphdb_instance:create_instance(sess(), "Engine", ClassNref, Car), - {ok, Block, _} = graphdb_instance:create_instance(sess(), "Block", ClassNref, Engine), + {ok, Car, _} = graphdb_instance:create_instance(proj(), "Car", ClassNref, root()), + set_avp(proj(), Car, TestAttr, "garage"), + {ok, Engine, _} = graphdb_instance:create_instance(proj(), "Engine", ClassNref, Car), + {ok, Block, _} = graphdb_instance:create_instance(proj(), "Block", ClassNref, Engine), %% Block doesn't have location, Engine doesn't — resolved from Car ?assertMatch({ok, "garage", _}, - graphdb_instance:resolve_value(Block, TestAttr)). + graphdb_instance:resolve_value(proj(), Block, TestAttr)). %%----------------------------------------------------------------------------- %% resolve_value finds a value from a directly connected node. @@ -1180,25 +1373,25 @@ resolve_value_from_ancestor(_Config) -> resolve_value_from_connected(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("country", string), - {ok, Ford, _} = graphdb_instance:create_instance(sess(), "Ford", ClassNref, 5), - set_avp(Ford, TestAttr, "USA"), - {ok, Taurus, _} = graphdb_instance:create_instance(sess(), "Taurus", ClassNref, 5), + {ok, Ford, _} = graphdb_instance:create_instance(proj(), "Ford", ClassNref, root()), + set_avp(proj(), Ford, TestAttr, "USA"), + {ok, Taurus, _} = graphdb_instance:create_instance(proj(), "Taurus", ClassNref, root()), {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - ok = graphdb_instance:add_relationship(sess(), Taurus, MadeByNref, Ford, MakesNref), + ok = graphdb_instance:add_relationship(proj(), Taurus, MadeByNref, Ford, MakesNref), %% Taurus doesn't have country, its class doesn't, no ancestors have it %% — resolved from connected Ford ?assertMatch({ok, "USA", _}, - graphdb_instance:resolve_value(Taurus, TestAttr)). + graphdb_instance:resolve_value(proj(), Taurus, TestAttr)). %%----------------------------------------------------------------------------- %% resolve_value returns not_found when attribute is nowhere. %%----------------------------------------------------------------------------- resolve_value_not_found(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Empty", 3), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "E1", ClassNref, 5), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "E1", ClassNref, root()), ?assertEqual(not_found, - graphdb_instance:resolve_value(InstNref, 99999)). + graphdb_instance:resolve_value(proj(), InstNref, 99999)). %%----------------------------------------------------------------------------- %% Priority: local value overrides class-level value. @@ -1207,10 +1400,10 @@ resolve_value_priority_local_over_class(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Color", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("hue", string), set_avp(ClassNref, TestAttr, "class_hue"), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "C1", ClassNref, 5), - set_avp(InstNref, TestAttr, "local_hue"), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), "C1", ClassNref, root()), + set_avp(proj(), InstNref, TestAttr, "local_hue"), ?assertMatch({ok, "local_hue", _}, - graphdb_instance:resolve_value(InstNref, TestAttr)). + graphdb_instance:resolve_value(proj(), InstNref, TestAttr)). %%----------------------------------------------------------------------------- %% Priority: class-level value overrides compositional ancestor value. @@ -1219,13 +1412,13 @@ resolve_value_priority_class_over_ancestor(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("weight", string), set_avp(ClassNref, TestAttr, "class_weight"), - {ok, Parent, _} = graphdb_instance:create_instance(sess(), "P1", ClassNref, 5), - set_avp(Parent, TestAttr, "parent_weight"), - {ok, Child, _} = graphdb_instance:create_instance(sess(), "C1", ClassNref, Parent), + {ok, Parent, _} = graphdb_instance:create_instance(proj(), "P1", ClassNref, root()), + set_avp(proj(), Parent, TestAttr, "parent_weight"), + {ok, Child, _} = graphdb_instance:create_instance(proj(), "C1", ClassNref, Parent), %% Child has no local value; class has weight; parent has weight %% Class (priority 2) should win over parent (priority 3) ?assertMatch({ok, "class_weight", _}, - graphdb_instance:resolve_value(Child, TestAttr)). + graphdb_instance:resolve_value(proj(), Child, TestAttr)). %%----------------------------------------------------------------------------- %% Priority: ancestor value overrides directly-connected-node value. @@ -1233,18 +1426,18 @@ resolve_value_priority_class_over_ancestor(_Config) -> resolve_value_priority_ancestor_over_connected(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("region", string), - {ok, Parent, _} = graphdb_instance:create_instance(sess(), "Parent", ClassNref, 5), - set_avp(Parent, TestAttr, "ancestor_region"), - {ok, Child, _} = graphdb_instance:create_instance(sess(), "Child", ClassNref, Parent), - {ok, Peer, _} = graphdb_instance:create_instance(sess(), "Peer", ClassNref, 5), - set_avp(Peer, TestAttr, "peer_region"), + {ok, Parent, _} = graphdb_instance:create_instance(proj(), "Parent", ClassNref, root()), + set_avp(proj(), Parent, TestAttr, "ancestor_region"), + {ok, Child, _} = graphdb_instance:create_instance(proj(), "Child", ClassNref, Parent), + {ok, Peer, _} = graphdb_instance:create_instance(proj(), "Peer", ClassNref, root()), + set_avp(proj(), Peer, TestAttr, "peer_region"), {ok, {LinksNref, LinkedByNref}} = graphdb_attr:create_relationship_attribute_pair("Links", "LinkedBy", instance), - ok = graphdb_instance:add_relationship(sess(), Child, LinksNref, Peer, LinkedByNref), + ok = graphdb_instance:add_relationship(proj(), Child, LinksNref, Peer, LinkedByNref), %% Child has no local value, class has no value %% Ancestor Parent (priority 3) should win over connected Peer (priority 4) ?assertMatch({ok, "ancestor_region", _}, - graphdb_instance:resolve_value(Child, TestAttr)). + graphdb_instance:resolve_value(proj(), Child, TestAttr)). %%----------------------------------------------------------------------------- %% resolve_from_class must walk the class taxonomy. Animal IS-A @@ -1258,9 +1451,9 @@ resolve_value_walks_class_taxonomy(_Config) -> {ok, TestAttr} = graphdb_attr:create_literal_attribute("kingdom", string), %% Bind kingdom only on the topmost class set_avp(AnimalNref, TestAttr, "Animalia"), - {ok, Rex, _} = graphdb_instance:create_instance(sess(), "Rex", DogNref, 5), + {ok, Rex, _} = graphdb_instance:create_instance(proj(), "Rex", DogNref, root()), ?assertMatch({ok, "Animalia", _}, - graphdb_instance:resolve_value(Rex, TestAttr)). + graphdb_instance:resolve_value(proj(), Rex, TestAttr)). %%----------------------------------------------------------------------------- %% when both the local class and a taxonomy ancestor bind the same @@ -1272,9 +1465,9 @@ resolve_value_local_class_overrides_taxonomy_ancestor(_Config) -> {ok, TestAttr} = graphdb_attr:create_literal_attribute("class_color", string), set_avp(AnimalNref, TestAttr, "from_animal"), set_avp(DogNref, TestAttr, "from_dog"), - {ok, Rex, _} = graphdb_instance:create_instance(sess(), "Rex", DogNref, 5), + {ok, Rex, _} = graphdb_instance:create_instance(proj(), "Rex", DogNref, root()), ?assertMatch({ok, "from_dog", _}, - graphdb_instance:resolve_value(Rex, TestAttr)). + graphdb_instance:resolve_value(proj(), Rex, TestAttr)). %%----------------------------------------------------------------------------- %% Priority 4 ("directly connected nodes") must consider only @@ -1282,16 +1475,24 @@ resolve_value_local_class_overrides_taxonomy_ancestor(_Config) -> %% category (reached only via the parent_arc) must not surface via P4. %%----------------------------------------------------------------------------- resolve_value_p4_ignores_compositional_arc(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("Widget", 3), {ok, TestAttr} = graphdb_attr:create_literal_attribute("color", string), - %% Bind color directly on the Projects category (nref 5) - set_avp(5, TestAttr, "category_color"), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), "W1", ClassNref, 5), - %% Local: no. Class: no. Ancestors: P3 stops at category 5 - %% (non-instance). P4 must not pick up category 5's AVP via the + %% Seed a non-instance (kind=category) node directly into Project's own + %% table to stand in for a compositional parent that is NOT an instance + %% (do_validate_parent/3 only requires the parent to exist and not be + %% retired -- it does not require kind=instance). Bind color directly + %% on it. + CatNref = graphdb_project:next_nref(Project), + CatNode = #node{nref = CatNref, kind = category, attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), CatNode), + set_avp(Project, CatNref, TestAttr, "category_color"), + {ok, InstNref, _} = graphdb_instance:create_instance(Project, "W1", ClassNref, CatNref), + %% Local: no. Class: no. Ancestors: P3 stops at the category node + %% (non-instance). P4 must not pick up its AVP via the %% parent_arc — only true connection arcs count. ?assertEqual(not_found, - graphdb_instance:resolve_value(InstNref, TestAttr)). + graphdb_instance:resolve_value(Project, InstNref, TestAttr)). %%----------------------------------------------------------------------------- @@ -1301,11 +1502,11 @@ resolve_value_source_local(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), {ok, AttrNref} = graphdb_attr:create_literal_attribute("weight", number), ok = graphdb_class:add_qualifying_characteristic(ClassNref, AttrNref), - {ok, InstNref, _} = graphdb_instance:create_instance(sess(), - "Taurus", ClassNref, ?NREF_PROJECTS), - set_avp(InstNref, AttrNref, 3500), + {ok, InstNref, _} = graphdb_instance:create_instance(proj(), + "Taurus", ClassNref, root()), + set_avp(proj(), InstNref, AttrNref, 3500), ?assertEqual({ok, 3500, local}, - graphdb_instance:resolve_value(InstNref, AttrNref)). + graphdb_instance:resolve_value(proj(), InstNref, AttrNref)). %%----------------------------------------------------------------------------- %% Task 0: Source tagging — Priority 2 hit returns `{class, ClassNref}`. @@ -1316,10 +1517,10 @@ resolve_value_source_class(_Config) -> {ok, AttrN} = graphdb_attr:create_literal_attribute("weight", number), ok = graphdb_class:add_qualifying_characteristic(Veh, AttrN), ok = graphdb_class:bind_qc_value(Veh, AttrN, 3500), - {ok, InstN, _} = graphdb_instance:create_instance(sess(), - "Taurus", Veh, ?NREF_PROJECTS), + {ok, InstN, _} = graphdb_instance:create_instance(proj(), + "Taurus", Veh, root()), ?assertEqual({ok, 3500, {class, Veh}}, - graphdb_instance:resolve_value(InstN, AttrN)). + graphdb_instance:resolve_value(proj(), InstN, AttrN)). %%----------------------------------------------------------------------------- %% Task 0: Source tagging — Priority 3 hit returns `{compositional, AncNref}` @@ -1328,15 +1529,15 @@ resolve_value_source_class(_Config) -> resolve_value_source_ancestor(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Part", ?NREF_CLASSES), {ok, TestAttr} = graphdb_attr:create_literal_attribute("location", string), - {ok, Car, _} = graphdb_instance:create_instance(sess(), - "Car", ClassNref, ?NREF_PROJECTS), - set_avp(Car, TestAttr, "garage"), - {ok, Engine, _} = graphdb_instance:create_instance(sess(), + {ok, Car, _} = graphdb_instance:create_instance(proj(), + "Car", ClassNref, root()), + set_avp(proj(), Car, TestAttr, "garage"), + {ok, Engine, _} = graphdb_instance:create_instance(proj(), "Engine", ClassNref, Car), - {ok, Block, _} = graphdb_instance:create_instance(sess(), + {ok, Block, _} = graphdb_instance:create_instance(proj(), "Block", ClassNref, Engine), ?assertEqual({ok, "garage", {compositional, Car}}, - graphdb_instance:resolve_value(Block, TestAttr)). + graphdb_instance:resolve_value(proj(), Block, TestAttr)). %%----------------------------------------------------------------------------- %% Task 0: Source tagging — Priority 4 hit returns `{connected, NodeNref}` @@ -1345,16 +1546,16 @@ resolve_value_source_ancestor(_Config) -> resolve_value_source_connected(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Org", ?NREF_CLASSES), {ok, TestAttr} = graphdb_attr:create_literal_attribute("country", string), - {ok, Ford, _} = graphdb_instance:create_instance(sess(), - "Ford", ClassNref, ?NREF_PROJECTS), - set_avp(Ford, TestAttr, "USA"), - {ok, Taurus, _} = graphdb_instance:create_instance(sess(), - "Taurus", ClassNref, ?NREF_PROJECTS), + {ok, Ford, _} = graphdb_instance:create_instance(proj(), + "Ford", ClassNref, root()), + set_avp(proj(), Ford, TestAttr, "USA"), + {ok, Taurus, _} = graphdb_instance:create_instance(proj(), + "Taurus", ClassNref, root()), {ok, {MakesNref, MadeByNref}} = graphdb_attr:create_relationship_attribute_pair("Makes", "MadeBy", instance), - ok = graphdb_instance:add_relationship(sess(), Taurus, MadeByNref, Ford, MakesNref), + ok = graphdb_instance:add_relationship(proj(), Taurus, MadeByNref, Ford, MakesNref), ?assertEqual({ok, "USA", {connected, Ford}}, - graphdb_instance:resolve_value(Taurus, TestAttr)). + graphdb_instance:resolve_value(proj(), Taurus, TestAttr)). %%============================================================================= @@ -1368,11 +1569,11 @@ resolve_value_source_connected(_Config) -> add_class_membership_basic(_Config) -> {ok, ClassA} = graphdb_class:create_class("Vehicle", 3), {ok, ClassB} = graphdb_class:create_class("Toy", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "ToyCar", ClassA, 5), - ?assertEqual({ok, [ClassA]}, graphdb_instance:class_memberships(Inst)), - ?assertEqual(ok, graphdb_instance:add_class_membership(sess(), Inst, ClassB)), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "ToyCar", ClassA, root()), + ?assertEqual({ok, [ClassA]}, graphdb_instance:class_memberships(proj(), Inst)), + ?assertEqual(ok, graphdb_instance:add_class_membership(proj(), Inst, ClassB)), ?assertEqual({ok, [ClassA, ClassB]}, - graphdb_instance:class_memberships(Inst)). + graphdb_instance:class_memberships(proj(), Inst)). %%----------------------------------------------------------------------------- %% add_class_membership writes a 29/30 arc pair. @@ -1380,12 +1581,12 @@ add_class_membership_basic(_Config) -> add_class_membership_writes_arcs(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), {ok, ClassB} = graphdb_class:create_class("B", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), %% Instance -> ClassB (char=29, reciprocal=30) {atomic, InstOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, Inst, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), Inst, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> R#relationship.target_nref =:= ClassB andalso @@ -1395,7 +1596,7 @@ add_class_membership_writes_arcs(_Config) -> %% ClassB -> Instance (char=30, reciprocal=29) {atomic, ClassOut} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, ClassB, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), ClassB, #relationship.source_nref) end), ?assert(lists:any(fun(R) -> R#relationship.target_nref =:= Inst andalso @@ -1410,14 +1611,15 @@ add_class_membership_writes_arcs(_Config) -> add_class_membership_idempotent(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), {ok, ClassB} = graphdb_class:create_class("B", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), - RelsBefore = mnesia:table_info(relationships, size), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), - RelsAfter = mnesia:table_info(relationships, size), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), + RelsTab = graphdb_ns:rel_table(proj()), + RelsBefore = mnesia:table_info(RelsTab, size), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), + RelsAfter = mnesia:table_info(RelsTab, size), ?assertEqual(RelsBefore, RelsAfter), ?assertEqual({ok, [ClassA, ClassB]}, - graphdb_instance:class_memberships(Inst)). + graphdb_instance:class_memberships(proj(), Inst)). %%----------------------------------------------------------------------------- %% Missing instance subject is rejected. @@ -1425,34 +1627,42 @@ add_class_membership_idempotent(_Config) -> add_class_membership_rejects_missing_instance(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), ?assertEqual({error, not_found}, - graphdb_instance:add_class_membership(sess(), 99999, ClassA)). + graphdb_instance:add_class_membership(proj(), 99999, ClassA)). %%----------------------------------------------------------------------------- %% Non-instance subject (e.g., a class node) is rejected. %%----------------------------------------------------------------------------- add_class_membership_rejects_non_instance(_Config) -> + %% The subject lookup is Project-scoped (SP2), so an environment class + %% nref would simply be not_found -- seed a raw non-instance (kind=class) + %% node directly into Project's own table instead. + Project = proj(), {ok, ClassA} = graphdb_class:create_class("A", 3), + NonInstNref = graphdb_project:next_nref(Project), + NonInstNode = #node{nref = NonInstNref, kind = class, + attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), NonInstNode), ?assertEqual({error, not_an_instance}, - graphdb_instance:add_class_membership(sess(), ClassA, ClassA)). + graphdb_instance:add_class_membership(Project, NonInstNref, ClassA)). %%----------------------------------------------------------------------------- %% Missing class target is rejected. %%----------------------------------------------------------------------------- add_class_membership_rejects_missing_class(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), ?assertEqual({error, class_not_found}, - graphdb_instance:add_class_membership(sess(), Inst, 99999)). + graphdb_instance:add_class_membership(proj(), Inst, 99999)). %%----------------------------------------------------------------------------- %% Non-class target (e.g., an attribute node) is rejected. %%----------------------------------------------------------------------------- add_class_membership_rejects_non_class_target(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), %% Nref 6 (Names) is an attribute node ?assertMatch({error, {not_a_class, attribute}}, - graphdb_instance:add_class_membership(sess(), Inst, 6)). + graphdb_instance:add_class_membership(proj(), Inst, 6)). %%----------------------------------------------------------------------------- %% A non-instantiable (abstract) class target is rejected — an instance @@ -1462,13 +1672,14 @@ add_class_membership_rejects_non_class_target(_Config) -> add_class_membership_refuses_abstract_class(_Config) -> {ok, #{instantiable := Inst}} = graphdb_attr:seeded_nrefs(), {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Instance, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), + {ok, Instance, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), {ok, Abstract} = graphdb_class:create_class("Meta", 3, [#{attribute => Inst, value => false}]), - RelsBefore = mnesia:table_info(relationships, size), + RelsTab = graphdb_ns:rel_table(proj()), + RelsBefore = mnesia:table_info(RelsTab, size), ?assertEqual({error, {class_not_instantiable, Abstract}}, - graphdb_instance:add_class_membership(sess(), Instance, Abstract)), - ?assertEqual(RelsBefore, mnesia:table_info(relationships, size)). + graphdb_instance:add_class_membership(proj(), Instance, Abstract)), + ?assertEqual(RelsBefore, mnesia:table_info(RelsTab, size)). %%----------------------------------------------------------------------------- %% add_class_membership rejects a retired class node. @@ -1476,18 +1687,18 @@ add_class_membership_refuses_abstract_class(_Config) -> add_class_membership_refuses_retired_class(_Config) -> {ok, ClassA} = graphdb_class:create_class("MemA", 3), {ok, ClassB} = graphdb_class:create_class("MemB", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "m", ClassA, 3), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "m", ClassA, root()), ok = graphdb_mgr:retire_node(ClassB), ?assertEqual({error, {class_retired, ClassB}}, - graphdb_instance:add_class_membership(sess(), Inst, ClassB)). + graphdb_instance:add_class_membership(proj(), Inst, ClassB)). %%----------------------------------------------------------------------------- %% After create_instance/3, class_memberships/1 returns the single class. %%----------------------------------------------------------------------------- class_memberships_initial(_Config) -> {ok, ClassA} = graphdb_class:create_class("A", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ?assertEqual({ok, [ClassA]}, graphdb_instance:class_memberships(Inst)). + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ?assertEqual({ok, [ClassA]}, graphdb_instance:class_memberships(proj(), Inst)). %%============================================================================= @@ -1503,10 +1714,10 @@ resolve_value_unique_across_two_classes(_Config) -> {ok, ClassB} = graphdb_class:create_class("Tag", 3), {ok, Attr} = graphdb_attr:create_literal_attribute("badge", string), set_avp(ClassA, Attr, "blue_badge"), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), ?assertMatch({ok, "blue_badge", _}, - graphdb_instance:resolve_value(Inst, Attr)). + graphdb_instance:resolve_value(proj(), Inst, Attr)). %%----------------------------------------------------------------------------- %% Two classes both bind the attribute to the SAME value. Not @@ -1518,10 +1729,10 @@ resolve_value_same_value_two_classes(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("colour", string), set_avp(ClassA, Attr, "red"), set_avp(ClassB, Attr, "red"), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), ?assertMatch({ok, "red", _}, - graphdb_instance:resolve_value(Inst, Attr)). + graphdb_instance:resolve_value(proj(), Inst, Attr)). %%----------------------------------------------------------------------------- %% Two classes bind the attribute to DIFFERENT values. Resolver returns @@ -1533,9 +1744,9 @@ resolve_value_ambiguous_two_classes(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("flavour", string), set_avp(ClassA, Attr, "sweet"), set_avp(ClassB, Attr, "salty"), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), - Result = graphdb_instance:resolve_value(Inst, Attr), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), + Result = graphdb_instance:resolve_value(proj(), Inst, Attr), ?assertMatch({error, {ambiguous_class_value, Attr, _}}, Result), {error, {ambiguous_class_value, _, Hits}} = Result, ?assertEqual(lists:sort([{ClassA, "sweet"}, {ClassB, "salty"}]), @@ -1552,11 +1763,11 @@ resolve_value_local_overrides_ambiguity(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("flavour", string), set_avp(ClassA, Attr, "sweet"), set_avp(ClassB, Attr, "salty"), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "X", ClassA, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ClassB), - set_avp(Inst, Attr, "umami"), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "X", ClassA, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ClassB), + set_avp(proj(), Inst, Attr, "umami"), ?assertMatch({ok, "umami", _}, - graphdb_instance:resolve_value(Inst, Attr)). + graphdb_instance:resolve_value(proj(), Inst, Attr)). %%----------------------------------------------------------------------------- %% Per-membership taxonomy walk: ClassA's ancestor binds X, ClassB binds @@ -1571,9 +1782,9 @@ resolve_value_ambiguity_via_taxonomy(_Config) -> {ok, Attr} = graphdb_attr:create_literal_attribute("origin", string), set_avp(AnimalCls, Attr, "biological"), set_avp(ToyCls, Attr, "manufactured"), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "Plushie", MammalCls, 5), - ok = graphdb_instance:add_class_membership(sess(), Inst, ToyCls), - Result = graphdb_instance:resolve_value(Inst, Attr), + {ok, Inst, _} = graphdb_instance:create_instance(proj(), "Plushie", MammalCls, root()), + ok = graphdb_instance:add_class_membership(proj(), Inst, ToyCls), + Result = graphdb_instance:resolve_value(proj(), Inst, Attr), ?assertMatch({error, {ambiguous_class_value, Attr, _}}, Result), {error, {ambiguous_class_value, _, Hits}} = Result, ?assertEqual( @@ -1590,7 +1801,7 @@ resolve_value_ambiguity_via_taxonomy(_Config) -> %%----------------------------------------------------------------------------- firing_no_rules_baseline(_Config) -> {ok, ClassNref} = graphdb_class:create_class("Plain", 3), - {ok, Nref, Report} = graphdb_instance:create_instance(sess(), "p1", ClassNref, 5), + {ok, Nref, Report} = graphdb_instance:create_instance(proj(), "p1", ClassNref, root()), ?assert(is_integer(Nref)), ?assertEqual([], Report). @@ -1601,9 +1812,9 @@ firing_single_mandatory(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OB", Owner, Bolt, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), %% one Bolt child created, reported fired under the rule - {ok, Kids} = graphdb_instance:children(Root), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(1, length(Kids)), [#{rule := _, outcomes := [#{owner := Root, status := fired, child := ChildNref}]}] = Report, @@ -1619,7 +1830,7 @@ firing_mandatory_mult(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB", Owner, Bolt, mandatory, {3, 3}), {ok, _Root, [#{deployment := Dep, outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(3, length(Outs)), ?assertEqual([1, 2, 3], [maps:get(index, O) || O <- Outs]), %% report carries the rule's real deployment map @@ -1635,10 +1846,10 @@ firing_mandatory_cascade_atomic(Config) -> environment, "OB", Owner, Bolt, mandatory, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "BW", Bolt, Widget, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), - {ok, [BoltInst]} = graphdb_instance:children(Root), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), + {ok, [BoltInst]} = graphdb_instance:children(proj(), Root), BoltNref = element(2, BoltInst), - {ok, [_Widget]} = graphdb_instance:children(BoltNref), + {ok, [_Widget]} = graphdb_instance:children(proj(), BoltNref), %% both rules report a fired outcome ?assertEqual(2, length(Report)). @@ -1650,10 +1861,11 @@ firing_mandatory_failure_rolls_back(Config) -> {Owner, Abstract} = ?config(oa, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OA", Owner, Abstract, mandatory, {1, 1}), - Before = mnesia:table_info(nodes, size), + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), {error, {class_not_instantiable, Abstract}, Report} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), - ?assertEqual(Before, mnesia:table_info(nodes, size)), %% nothing written + graphdb_instance:create_instance(proj(), "car", Owner, root()), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)), %% nothing written %% culprit rule has a failed outcome in the report ?assert(lists:any( fun(#{outcomes := Os}) -> @@ -1669,8 +1881,8 @@ firing_auto_best_effort(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBauto", Owner, Bolt, auto, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), - {ok, [_]} = graphdb_instance:children(Root), %% auto child created + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), + {ok, [_]} = graphdb_instance:children(proj(), Root), %% auto child created ?assertEqual(#{fired => 1, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, graphdb_instance:summarize(Report)). @@ -1683,7 +1895,7 @@ firing_auto_failure_survives(Config) -> {Owner, Abstract} = ?config(oa, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OAauto", Owner, Abstract, auto, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assert(is_integer(Root)), %% root survived ?assertEqual(#{fired => 0, failed => 1, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, @@ -1699,7 +1911,7 @@ firing_auto_cascade_merges(Config) -> environment, "OBauto", Owner, Bolt, auto, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "BW", Bolt, Widget, mandatory, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), %% the auto Bolt and its mandatory Widget both fired ?assertEqual(#{fired => 2, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, @@ -1713,9 +1925,9 @@ firing_propose_outcome_in_report(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), %% no child materialised - ?assertEqual({ok, []}, graphdb_instance:children(Root)), + ?assertEqual({ok, []}, graphdb_instance:children(proj(), Root)), %% exactly one proposed outcome, owner=Root, proposed_class=Bolt, no child key [#{outcomes := [Outcome]}] = Report, ?assertEqual(proposed, maps:get(status, Outcome)), @@ -1732,9 +1944,10 @@ firing_propose_not_materialised(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {3, 3}), - Before = mnesia:table_info(nodes, size), - {ok, _Root, _Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), - After = mnesia:table_info(nodes, size), + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), + {ok, _Root, _Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), + After = mnesia:table_info(NodesTab, size), ?assertEqual(Before + 1, After). %% only the root, no proposed children %%----------------------------------------------------------------------------- @@ -1747,7 +1960,7 @@ firing_propose_multiplicity_bounded(Config) -> environment, "OBpropose", Owner, Bolt, propose, {3, 3}, undefined, #{name_pattern => "Spare {i}"}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(3, length(Outs)), ?assertEqual([1, 2, 3], [maps:get(index, O) || O <- Outs]), ?assertEqual(["Spare 1", "Spare 2", "Spare 3"], @@ -1763,7 +1976,7 @@ firing_propose_multiplicity_unbounded(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {1, unbounded}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(1, length(Outs)), [#{index := Idx, status := proposed, max := Max}] = Outs, ?assertEqual(1, Idx), @@ -1778,7 +1991,7 @@ firing_propose_on_path_cut(Config) -> {Owner, _Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "selfpropose", Owner, Owner, propose, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual([], Report). %%----------------------------------------------------------------------------- @@ -1788,7 +2001,7 @@ firing_propose_summarize(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBpropose", Owner, Bolt, propose, {2, 2}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(#{fired => 0, failed => 0, not_attempted => 0, proposed => 2, connected => 0, required => 0, not_connected => 0}, graphdb_instance:summarize(Report)). @@ -1808,9 +2021,9 @@ firing_propose_with_mandatory_and_auto(Config) -> environment, "aut", Owner, Widget, auto, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "pro", Owner, Gizmo, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), %% two children materialised (mandatory Bolt + auto Widget), Gizmo is not - {ok, Kids} = graphdb_instance:children(Root), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(2, length(Kids)), ?assertEqual(#{fired => 2, failed => 0, not_attempted => 0, proposed => 1, connected => 0, required => 0, not_connected => 0}, @@ -1827,9 +2040,9 @@ firing_propose_owner_is_materialised_child(Config) -> environment, "OB", Owner, Bolt, mandatory, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "BWpropose", Bolt, Widget, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), %% the materialised mandatory child - {ok, [BoltInst]} = graphdb_instance:children(Root), + {ok, [BoltInst]} = graphdb_instance:children(proj(), Root), BoltNref = element(2, BoltInst), %% find the proposed outcome across all rule reports Proposed = [O || #{outcomes := Os} <- Report, O <- Os, @@ -1850,7 +2063,7 @@ firing_propose_carries_max(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBp3-5", Owner, Bolt, propose, {3, 5}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(3, length(Outs)), ?assertEqual([1, 2, 3], [maps:get(index, O) || O <- Outs]), ?assert(lists:all(fun(O) -> maps:get(max, O) =:= 5 end, Outs)), @@ -1866,7 +2079,7 @@ firing_propose_min_zero_surfaces_none(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBp0-3", Owner, Bolt, propose, {0, 3}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(0, maps:get(proposed, graphdb_instance:summarize(Report))). %%----------------------------------------------------------------------------- @@ -1877,7 +2090,7 @@ firing_mandatory_mints_min(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB2-5", Owner, Bolt, mandatory, {2, 5}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), Fired = [O || O <- Outs, maps:get(status, O) =:= fired], ?assertEqual(2, length(Fired)), ?assertEqual([1, 2], [maps:get(index, O) || O <- Fired]). @@ -1889,7 +2102,7 @@ firing_mandatory_min_zero_mints_none(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OB0-3", Owner, Bolt, mandatory, {0, 3}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), ?assertEqual(#{fired => 0, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, graphdb_instance:summarize(Report)). @@ -1903,7 +2116,7 @@ firing_mandatory_min_unbounded_mints_min(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB1-U", Owner, Bolt, mandatory, {1, unbounded}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), Fired = [O || O <- Outs, maps:get(status, O) =:= fired], ?assertEqual(1, length(Fired)), ?assert(lists:all(fun(O) -> @@ -1918,7 +2131,7 @@ firing_auto_mints_min(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OBauto2-5", Owner, Bolt, auto, {2, 5}), {ok, _Root, [#{outcomes := Outs}]} = - graphdb_instance:create_instance(sess(), "car", Owner, 5), + graphdb_instance:create_instance(proj(), "car", Owner, root()), Fired = [O || O <- Outs, maps:get(status, O) =:= fired], ?assertEqual(2, length(Fired)). @@ -1929,7 +2142,7 @@ firing_auto_min_zero_unbounded(Config) -> {Owner, Bolt} = ?config(ob, Config), {ok, _} = graphdb_rules:create_composition_rule( environment, "OBauto0-U", Owner, Bolt, auto, {0, unbounded}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car", Owner, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car", Owner, root()), Outs = lists:append([maps:get(outcomes, RR) || RR <- Report]), ?assertEqual([], [O || O <- Outs, maps:get(reason, O, none) =:= unbounded_multiplicity_not_fireable]), @@ -1953,7 +2166,8 @@ ensure_loaded(App) -> %%----------------------------------------------------------------------------- %% set_avp(Nref, AttrNref, Value) -> ok %% -%% Appends an AVP to the node's existing attribute_value_pairs. +%% Appends an AVP to an ENVIRONMENT node's existing attribute_value_pairs +%% (class/category nrefs -- classes always live in the environment). %% Used by tests to inject values for inheritance testing. %%----------------------------------------------------------------------------- set_avp(Nref, AttrNref, Value) -> @@ -1966,6 +2180,23 @@ set_avp(Nref, AttrNref, Value) -> end), ok. +%%----------------------------------------------------------------------------- +%% set_avp(Project, Nref, AttrNref, Value) -> ok +%% +%% Project-scoped twin of set_avp/3: appends an AVP to a PROJECT node's +%% (instance nrefs) existing attribute_value_pairs. +%%----------------------------------------------------------------------------- +set_avp(Project, Nref, AttrNref, Value) -> + NodesTab = graphdb_ns:node_table(Project), + {atomic, ok} = mnesia:transaction(fun() -> + [Node] = mnesia:read(NodesTab, Nref), + AVPs = Node#node.attribute_value_pairs, + NewAVP = #{attribute => AttrNref, value => Value}, + Updated = Node#node{attribute_value_pairs = AVPs ++ [NewAVP]}, + ok = mnesia:write(NodesTab, Updated, write) + end), + ok. + -define(SCRATCH_SENTINEL, "_build/test/ct_scratch/"). -define(DIR_PREFIX, "instance_"). @@ -2019,7 +2250,7 @@ firing_conn_report_only_mandatory(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root()), ?assert(is_integer(Root)), %% create succeeded ?assertEqual([], b4_conn_targets(Root, Char)), %% nothing connected O = b4_single_outcome(Report), @@ -2033,14 +2264,14 @@ firing_conn_report_only_auto(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, auto, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root()), ?assertEqual(not_connected, maps:get(status, b4_single_outcome(Report))). firing_conn_report_only_propose(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, propose, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root()), ?assertEqual([], b4_conn_targets(Root, Char)), ?assertEqual(proposed, maps:get(status, b4_single_outcome(Report))). @@ -2050,7 +2281,7 @@ firing_conn_explicit_defer(_Config) -> {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), R = fun(_Ctx) -> defer end, - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), ?assertEqual([], b4_conn_targets(Root, Char)), ?assertEqual(required, maps:get(status, b4_single_outcome(Report))). @@ -2059,7 +2290,7 @@ firing_conn_summarize(_Config) -> {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), - {ok, _Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5), + {ok, _Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root()), S = graphdb_instance:summarize(Report), ?assertEqual(1, maps:get(required, S)), ?assertEqual(0, maps:get(connected, S)), @@ -2078,7 +2309,7 @@ firing_conn_mandatory_connected(_Config) -> environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), Target = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Target]} end, - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), ?assertEqual([Target], b4_conn_targets(Root, Char)), %% forward arc ?assertEqual([Root], b4_conn_targets(Target, Recip)), %% reverse arc O = b4_single_outcome(Report), @@ -2093,10 +2324,11 @@ firing_conn_mandatory_shortfall_fails(_Config) -> {ok, RuleNref} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), R = fun(_Ctx) -> {connect, []} end, - Before = mnesia:table_info(nodes, size), + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), {error, {mandatory_connection_unsatisfied, RuleNref}, Report} = - graphdb_instance:create_instance(sess(), "car1", Src, 5, R), - ?assertEqual(Before, mnesia:table_info(nodes, size)), %% nothing written + graphdb_instance:create_instance(proj(), "car1", Src, root(), R), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)), %% nothing written ?assert(lists:any( fun(#{outcomes := Os}) -> lists:any(fun(#{status := S}) -> S =:= failed end, Os) @@ -2110,10 +2342,11 @@ firing_conn_mandatory_invalid_target_fails(_Config) -> {ok, Other} = graphdb_class:create_class("Other", 3), Wrong = b4_target_instance("wrong", Other), %% not a Mfr R = fun(_Ctx) -> {connect, [Wrong]} end, - Before = mnesia:table_info(nodes, size), + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), {error, {invalid_connection_target, _}, _Report} = - graphdb_instance:create_instance(sess(), "car1", Src, 5, R), - ?assertEqual(Before, mnesia:table_info(nodes, size)). + graphdb_instance:create_instance(proj(), "car1", Src, root(), R), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)). %% multiplicity {1,2}: resolver returns 3 valid -> exactly 2 written (cap=Max). firing_conn_mandatory_caps_at_max(_Config) -> @@ -2124,7 +2357,7 @@ firing_conn_mandatory_caps_at_max(_Config) -> T2 = b4_target_instance("m2", Tgt), T3 = b4_target_instance("m3", Tgt), R = fun(_Ctx) -> {connect, [T1, T2, T3]} end, - {ok, Root, _Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, _Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), ?assertEqual(2, length(b4_conn_targets(Root, Char))). %% rollback cause is discriminable: a class carrying BOTH a mandatory composition @@ -2147,7 +2380,7 @@ firing_conn_rollback_discriminable_composition(_Config) -> Mfr = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Mfr]} end, {error, {class_not_instantiable, Abstract}, Report} = - graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + graphdb_instance:create_instance(proj(), "car1", Src, root(), R), %% the lone failed outcome is a COMPOSITION culprit: carries no connection keys Failed = [O || #{outcomes := Os} <- Report, #{status := failed} = O <- Os], ?assertEqual(1, length(Failed)), @@ -2170,10 +2403,11 @@ firing_conn_rollback_discriminable_connection(_Config) -> {ok, _} = graphdb_rules:create_connection_rule( environment, "CM", Src, Char, Recip, Tgt, mandatory, {1, 1}), R = fun(_Ctx) -> {connect, []} end, %% shortfall - Before = mnesia:table_info(nodes, size), + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), {error, {mandatory_connection_unsatisfied, _}, Report} = - graphdb_instance:create_instance(sess(), "car1", Src, 5, R), - ?assertEqual(Before, mnesia:table_info(nodes, size)), + graphdb_instance:create_instance(proj(), "car1", Src, root(), R), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)), %% lone failed outcome is a CONNECTION culprit (has characterization); %% the composition Bolt rule is not_attempted. Failed = [O || #{outcomes := Os} <- Report, #{status := failed} = O <- Os], @@ -2199,8 +2433,8 @@ firing_conn_descendant_in_root_txn(_Config) -> environment, "BM", Bolt, Char, Recip, Tgt, mandatory, {1, 1}), Mfr = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Mfr]} end, - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Owner, 5, R), - {ok, [BoltInst]} = graphdb_instance:children(Root), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Owner, root(), R), + {ok, [BoltInst]} = graphdb_instance:children(proj(), Root), BoltNref = element(2, BoltInst), ?assertEqual([Mfr], b4_conn_targets(BoltNref, Char)), %% the connected outcome's source is the Bolt descendant, not the root @@ -2221,7 +2455,7 @@ firing_conn_auto_connected(_Config) -> environment, "car-made-by", Src, Char, Recip, Tgt, auto, {1, 1}), Target = b4_target_instance("acme", Tgt), R = fun(_Ctx) -> {connect, [Target]} end, - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), ?assertEqual([Target], b4_conn_targets(Root, Char)), ?assertEqual(connected, maps:get(status, b4_single_outcome(Report))). @@ -2233,7 +2467,7 @@ firing_conn_auto_invalid_survives(_Config) -> {ok, Other} = graphdb_class:create_class("Other", 3), Wrong = b4_target_instance("wrong", Other), R = fun(_Ctx) -> {connect, [Wrong]} end, - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), ?assert(is_integer(Root)), ?assertEqual([], b4_conn_targets(Root, Char)), ?assertEqual(failed, maps:get(status, b4_single_outcome(Report))). @@ -2251,7 +2485,7 @@ firing_conn_subclass_target_accepted(_Config) -> environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), Target = b4_target_instance("acme", SubMfr), R = fun(_Ctx) -> {connect, [Target]} end, - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), ?assertEqual([Target], b4_conn_targets(Root, Char)), ?assertEqual(connected, maps:get(status, b4_single_outcome(Report))). @@ -2261,21 +2495,32 @@ firing_conn_missing_target_fails(_Config) -> {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), R = fun(_Ctx) -> {connect, [999999]} end, - Before = mnesia:table_info(nodes, size), + NodesTab = graphdb_ns:node_table(proj()), + Before = mnesia:table_info(NodesTab, size), {error, {invalid_connection_target, {target_not_found, 999999}}, _Report} = - graphdb_instance:create_instance(sess(), "car1", Src, 5, R), - ?assertEqual(Before, mnesia:table_info(nodes, size)). + graphdb_instance:create_instance(proj(), "car1", Src, root(), R), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)). %% a non-instance target (a class nref) on a mandatory rule fails the create. firing_conn_non_instance_target_fails(_Config) -> + Project = proj(), {Src, Tgt, Char, Recip} = b4_conn_classes("Car", "Mfr", "made_by", "makes"), {ok, _} = graphdb_rules:create_connection_rule( environment, "car-made-by", Src, Char, Recip, Tgt, mandatory, {1, 1}), - R = fun(_Ctx) -> {connect, [Tgt]} end, %% Tgt is a class, not an instance - Before = mnesia:table_info(nodes, size), - {error, {invalid_connection_target, {target_not_an_instance, Tgt}}, _R} = - graphdb_instance:create_instance(sess(), "car1", Src, 5, R), - ?assertEqual(Before, mnesia:table_info(nodes, size)). + %% validate_target/4 reads the Project's own node table (SP2) -- an + %% environment class nref like Tgt would simply be target_not_found. + %% Seed a raw non-instance (kind=class) node directly into Project's + %% own table instead, to exercise target_not_an_instance. + NonInstNref = graphdb_project:next_nref(Project), + NonInstNode = #node{nref = NonInstNref, kind = class, + attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), NonInstNode), + R = fun(_Ctx) -> {connect, [NonInstNref]} end, + NodesTab = graphdb_ns:node_table(Project), + Before = mnesia:table_info(NodesTab, size), + {error, {invalid_connection_target, {target_not_an_instance, NonInstNref}}, _R} = + graphdb_instance:create_instance(Project, "car1", Src, root(), R), + ?assertEqual(Before, mnesia:table_info(NodesTab, size)). %% resolver-supplied per-direction AVPs are stamped on the written arc. firing_conn_resolver_avps_stamped(_Config) -> @@ -2286,7 +2531,7 @@ firing_conn_resolver_avps_stamped(_Config) -> FwdAVP = #{attribute => Char, value => "fwd-meta"}, RevAVP = #{attribute => Recip, value => "rev-meta"}, R = fun(_Ctx) -> {connect, [{Target, {[FwdAVP], [RevAVP]}}]} end, - {ok, Root, _Report} = graphdb_instance:create_instance(sess(), "car1", Src, 5, R), + {ok, Root, _Report} = graphdb_instance:create_instance(proj(), "car1", Src, root(), R), Fwd = b4_conn_arc(Root, Char), Rev = b4_conn_arc(Target, Recip), ?assert(lists:member(FwdAVP, Fwd#relationship.avps)), @@ -2299,7 +2544,7 @@ firing_conn_resolver_avps_stamped(_Config) -> %% the single outgoing connection arc (#relationship{}) from Source with char. b4_conn_arc(Source, Char) -> - Arcs = mnesia:dirty_index_read(relationships, Source, + Arcs = mnesia:dirty_index_read(graphdb_ns:rel_table(proj()), Source, #relationship.source_nref), [Arc] = [A || A <- Arcs, A#relationship.kind =:= connection, @@ -2311,9 +2556,9 @@ b4_inst_attr() -> {ok, #{instantiable := InstAttr}} = graphdb_attr:seeded_nrefs(), {ok, InstAttr}. -%% make a pre-existing target instance of class Tgt, parented at Projects (5). +%% make a pre-existing target instance of class Tgt, parented at root(). b4_target_instance(Name, Tgt) -> - {ok, Nref, _} = graphdb_instance:create_instance(sess(), Name, Tgt, 5), + {ok, Nref, _} = graphdb_instance:create_instance(proj(), Name, Tgt, root()), Nref. %% make a (Source, Target, Char, Recip) connection fixture; returns nrefs. @@ -2331,7 +2576,7 @@ b4_single_outcome(Report) -> %% outgoing connection arc targets from Source with characterization Char. b4_conn_targets(Source, Char) -> - Arcs = mnesia:dirty_index_read(relationships, Source, + Arcs = mnesia:dirty_index_read(graphdb_ns:rel_table(proj()), Source, #relationship.source_nref), [A#relationship.target_nref || A <- Arcs, A#relationship.kind =:= connection, @@ -2350,8 +2595,8 @@ b5_create_instance_5_accepts_resolvers(_Config) -> Conn = fun(_Ctx) -> defer end, Conflict = graphdb_rules:default_conflict_resolver(), {ok, Root, Report} = - graphdb_instance:create_instance(sess(), "car", Vehicle, 5, Conn, Conflict), - {ok, Kids} = graphdb_instance:children(Root), + graphdb_instance:create_instance(proj(), "car", Vehicle, root(), Conn, Conflict), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(1, length(Kids)), ?assertEqual(#{fired => 1, failed => 0, not_attempted => 0, proposed => 0, connected => 0, required => 0, not_connected => 0}, @@ -2366,8 +2611,8 @@ b5_default_resolver_single_rule_unchanged(_Config) -> {ok, Engine} = graphdb_class:create_class("Engine", 3), {ok, _} = graphdb_rules:create_composition_rule( environment, "VE", Vehicle, Engine, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "car", Vehicle, 5), - {ok, Kids} = graphdb_instance:children(Root), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "car", Vehicle, root()), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(1, length(Kids)), ?assertEqual(1, length(Report)). @@ -2382,8 +2627,8 @@ b5_firing_same_level_mode_priority(_Config) -> environment, "CN-prop", Cell, Nucleus, propose, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "CN-mand", Cell, Nucleus, mandatory, {1, 1}), - {ok, Root, Report} = graphdb_instance:create_instance(sess(), "c1", Cell, 5), - {ok, Kids} = graphdb_instance:children(Root), + {ok, Root, Report} = graphdb_instance:create_instance(proj(), "c1", Cell, root()), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(1, length(Kids)), %% exactly one Nucleus minted #{fired := 1, proposed := 0} = maps:with([fired, proposed], graphdb_instance:summarize(Report)). @@ -2400,8 +2645,8 @@ b5_firing_cross_level_shadow(_Config) -> environment, "CE", Car, Engine, mandatory, {1, 1}), {ok, _} = graphdb_rules:create_composition_rule( environment, "VE", Vehicle, Engine, mandatory, {1, 1}), - {ok, Root, _Report} = graphdb_instance:create_instance(sess(), "car", Car, 5), - {ok, Kids} = graphdb_instance:children(Root), + {ok, Root, _Report} = graphdb_instance:create_instance(proj(), "car", Car, root()), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(1, length(Kids)). %%----------------------------------------------------------------------------- @@ -2419,8 +2664,8 @@ b5_custom_resolver_pure_additive(_Config) -> Additive = fun(#{rules := R}) -> R end, Conn = fun(_Ctx) -> defer end, {ok, Root, _Report} = - graphdb_instance:create_instance(sess(), "car", Car, 5, Conn, Additive), - {ok, Kids} = graphdb_instance:children(Root), + graphdb_instance:create_instance(proj(), "car", Car, root(), Conn, Additive), + {ok, Kids} = graphdb_instance:children(proj(), Root), ?assertEqual(2, length(Kids)). %% additive: both fire @@ -2433,8 +2678,8 @@ b5_custom_resolver_pure_additive(_Config) -> re_setup() -> {ok, ClassNref} = graphdb_class:create_class("Org", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), #{class => ClassNref, tmpl => DefaultTmpl, a => A, b => B, @@ -2443,7 +2688,7 @@ re_setup() -> %% count forward connection rows A--Char-->B re_count(A, Char, B) -> {atomic, Rows} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), length([R || R <- Rows, R#relationship.kind =:= connection, @@ -2452,75 +2697,76 @@ re_count(A, Char, B) -> remove_relationship_basic(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), ?assertEqual(1, re_count(A, Char, B)), ?assertEqual(1, re_count(B, Recip, A)), - ok = graphdb_instance:remove_relationship(sess(), A, Char, B), + ok = graphdb_instance:remove_relationship(proj(), A, Char, B), ?assertEqual(0, re_count(A, Char, B)), ?assertEqual(0, re_count(B, Recip, A)). remove_relationship_not_found(_Config) -> #{a := A, b := B, char := Char} = re_setup(), ?assertEqual({error, relationship_not_found}, - graphdb_instance:remove_relationship(sess(), A, Char, B)). + graphdb_instance:remove_relationship(proj(), A, Char, B)). -%% SP1: a project op requires a valid session. A non-session term is -%% rejected before any store access -- covers the tier-2 (plain-function) -%% session gate. +%% SP2: a project op requires a valid Project handle. A non-project term +%% is rejected before any store access -- covers the plain-function +%% with_project/2 gate (remove_relationship). remove_relationship_rejects_bad_session(_Config) -> - ?assertEqual({error, invalid_session}, + ?assertEqual({error, invalid_project}, graphdb_instance:remove_relationship(not_a_session, 1, 2, 3)). -%% SP1: covers the gen_server-wrapper session gate (a distinct code path -%% from the tier-2 gate above): with_session/2 short-circuits before the -%% gen_server:call. +%% SP2: covers with_project/2 wrapping a gen_server:call (add_relationship); +%% same gate as remove_relationship above -- with_project/2 short-circuits +%% before the gen_server:call. add_relationship_rejects_bad_session(_Config) -> - ?assertEqual({error, invalid_session}, + ?assertEqual({error, invalid_project}, graphdb_instance:add_relationship(not_a_session, 1, 2, 3, 4)). -%% SP1: the update-* family (tier-2) also rejects a bad session. +%% SP2: the update-* family also rejects a bad Project handle. update_relationship_rejects_bad_session(_Config) -> - ?assertEqual({error, invalid_session}, + ?assertEqual({error, invalid_project}, graphdb_instance:update_relationship(not_a_session, 1, 2, 3, [])). -%% SP1: add_class_membership (gen_server-wrapper) also rejects a bad session. +%% SP2: add_class_membership (gen_server-wrapper) also rejects a bad +%% Project handle. add_class_membership_rejects_bad_session(_Config) -> - ?assertEqual({error, invalid_session}, + ?assertEqual({error, invalid_project}, graphdb_instance:add_class_membership(not_a_session, 1, 2)). remove_relationship_ambiguous(_Config) -> #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), {ok, DefaultTmpl} = graphdb_class:default_template(Class), {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, AltTmpl), ?assertMatch({error, {ambiguous_relationship, [_, _]}}, - graphdb_instance:remove_relationship(sess(), A, Char, B)). + graphdb_instance:remove_relationship(proj(), A, Char, B)). remove_relationship_disambiguate_by_template(_Config) -> #{a := A, b := B, char := Char, recip := Recip, class := Class} = re_setup(), {ok, DefaultTmpl} = graphdb_class:default_template(Class), {ok, AltTmpl} = graphdb_class:add_template(Class, "social"), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, DefaultTmpl), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip, AltTmpl), - ok = graphdb_instance:remove_relationship(sess(), A, Char, B, DefaultTmpl), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, DefaultTmpl), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip, AltTmpl), + ok = graphdb_instance:remove_relationship(proj(), A, Char, B, DefaultTmpl), %% one edge (the AltTmpl one) remains in each direction ?assertEqual(1, re_count(A, Char, B)), ?assertEqual(1, re_count(B, Recip, A)). remove_relationship_dangling_half_edge(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), %% manually delete the reverse row, leaving a half-edge {atomic, ok} = mnesia:transaction(fun() -> - Rows = mnesia:index_read(relationships, B, #relationship.source_nref), + Rows = mnesia:index_read(graphdb_ns:rel_table(proj()), B, #relationship.source_nref), [Rev] = [R || R <- Rows, R#relationship.characterization =:= Recip, R#relationship.target_nref =:= A], - mnesia:delete_object(relationships, Rev, write) + mnesia:delete_object(graphdb_ns:rel_table(proj()), Rev, write) end), ?assertMatch({error, {dangling_half_edge, _}}, - graphdb_instance:remove_relationship(sess(), A, Char, B)), + graphdb_instance:remove_relationship(proj(), A, Char, B)), %% the forward row is NOT deleted -- rollback left it intact ?assertEqual(1, re_count(A, Char, B)). @@ -2532,7 +2778,7 @@ remove_relationship_dangling_half_edge(_Config) -> %% fetch the single forward row's avps re_avps(A, Char, B) -> {atomic, Rows} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) + mnesia:index_read(graphdb_ns:rel_table(proj()), A, #relationship.source_nref) end), [R] = [X || X <- Rows, X#relationship.kind =:= connection, @@ -2543,8 +2789,8 @@ re_avps(A, Char, B) -> update_relationship_single_direction(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), {ok, Note} = graphdb_attr:create_literal_attribute("note", string), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), - ok = graphdb_instance:update_relationship(sess(), A, Char, B, + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), + ok = graphdb_instance:update_relationship(proj(), A, Char, B, [#{attribute => Note, value => "fwd"}]), ?assert(lists:member(#{attribute => Note, value => "fwd"}, re_avps(A, Char, B))), @@ -2558,9 +2804,9 @@ update_relationship_single_direction(_Config) -> update_relationship_reverse_direction(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), {ok, Note} = graphdb_attr:create_literal_attribute("note", string), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), %% name the reverse direction from the other endpoint: (T, R, S) - ok = graphdb_instance:update_relationship(sess(), B, Recip, A, + ok = graphdb_instance:update_relationship(proj(), B, Recip, A, [#{attribute => Note, value => "rev"}]), ?assert(lists:member(#{attribute => Note, value => "rev"}, re_avps(B, Recip, A))), @@ -2569,24 +2815,24 @@ update_relationship_reverse_direction(_Config) -> update_relationship_protects_template(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), ?assertEqual({error, {protected_relationship_avp, ?ARC_TEMPLATE}}, - graphdb_instance:update_relationship(sess(), A, Char, B, + graphdb_instance:update_relationship(proj(), A, Char, B, [#{attribute => ?ARC_TEMPLATE, value => 7}])). update_relationship_not_found(_Config) -> #{a := A, b := B, char := Char} = re_setup(), {ok, Note} = graphdb_attr:create_literal_attribute("note", string), ?assertEqual({error, relationship_not_found}, - graphdb_instance:update_relationship(sess(), A, Char, B, + graphdb_instance:update_relationship(proj(), A, Char, B, [#{attribute => Note, value => "x"}])). update_relationship_both_directions(_Config) -> #{a := A, b := B, char := Char, recip := Recip} = re_setup(), {ok, FAttr} = graphdb_attr:create_literal_attribute("fwd_meta", string), {ok, RAttr} = graphdb_attr:create_literal_attribute("rev_meta", string), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), - ok = graphdb_instance:update_relationship_both(sess(), A, Char, B, + ok = graphdb_instance:add_relationship(proj(), A, Char, B, Recip), + ok = graphdb_instance:update_relationship_both(proj(), A, Char, B, {[#{attribute => FAttr, value => "F"}], [#{attribute => RAttr, value => "R"}]}), FwdAVPs = re_avps(A, Char, B), @@ -2626,19 +2872,56 @@ proxy_recognizer_rejects_plain_instance(_Config) -> ?assertEqual(not_a_proxy, graphdb_instance:proxy_coordinates(Node)). %%--------------------------------------------------------------------- -%% sess() -> Session +%% proj() -> Project %% -%% SP1 test helper: returns a project session, memoised per test-case -%% process. Registers a project under Projects (nref 5) on first use and -%% opens a session against it; subsequent calls in the same process reuse it. +%% SP2 test helper: returns a project handle, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens it; subsequent calls in the same process reuse it. %%--------------------------------------------------------------------- -sess() -> - case get(sp1_session) of +proj() -> + case get(sp2_project) of undefined -> - {ok, P} = graphdb_project:register_project("SP1 test session"), - {ok, S} = graphdb_project:open_session(P), - put(sp1_session, S), - S; - S -> - S + {ok, P} = graphdb_project:register_project("SP2 test project"), + {ok, Project} = graphdb_project:open(P), + put(sp2_project, Project), + Project; + Project -> + Project end. + +%%--------------------------------------------------------------------- +%% root() -> Nref +%% +%% SP2 test helper: returns a shared compositional-root instance nref for +%% proj(), memoised per test-case process (mirrors proj()'s own memo +%% pattern) so every create_instance/4,5,6 call in one test case that +%% used to pass the old single-store stand-in parent (bare 5/3 or +%% ?NREF_PROJECTS -- an environment category nref that happened to +%% always exist in the pre-SP2 shared table) shares the SAME project- +%% local parent. Seeds via root_instance/1 on first use. +%%--------------------------------------------------------------------- +root() -> + case get(sp2_root) of + undefined -> + Nref = root_instance(proj()), + put(sp2_root, Nref), + Nref; + Nref -> + Nref + end. + +%%--------------------------------------------------------------------- +%% root_instance(Project) -> Nref +%% +%% Seeds a throwaway compositional-root instance directly into Project's +%% own (initially empty) nodes table, bypassing create_instance's parent +%% validation (do_validate_parent/3 requires the parent to already exist, +%% and a fresh project store has nothing yet to point at). For a freshly +%% registered project, next_nref/1's first call returns 1 -- callers must +%% never assume this and should bind/assert on the returned Nref. +%%--------------------------------------------------------------------- +root_instance(Project) -> + Nref = graphdb_project:next_nref(Project), + Node = #node{nref = Nref, kind = instance, attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), Node), + Nref. diff --git a/apps/graphdb/test/graphdb_mgr_SUITE.erl b/apps/graphdb/test/graphdb_mgr_SUITE.erl index f933d50..3afd5ba 100644 --- a/apps/graphdb/test/graphdb_mgr_SUITE.erl +++ b/apps/graphdb/test/graphdb_mgr_SUITE.erl @@ -135,7 +135,16 @@ mutate_rejects_instance_only/1, mutate_remove_relationship/1, mutate_update_relationship/1, - mutate_mixed_rollback/1 + mutate_mixed_rollback/1, + %% Project-taking twins (SP2 T10) + get_node_2_reads_project_instance/1, + get_node_2_does_not_leak_into_environment_table/1, + retire_node_2_retires_a_project_instance/1, + update_node_avps_3_edits_a_project_instance/1, + delete_node_2_reports_not_implemented/1, + %% mutate/2 (SP2 T11) + mutate_2_batches_within_one_project/1, + mutate_1_still_rejects_permanent_tier/1 ]). @@ -150,7 +159,8 @@ all() -> [{group, init_tests}, {group, read_ops}, {group, category_guard}, {group, write_delegation}, {group, cache_audit}, {group, transaction_seam}, - {group, soft_retire}, {group, mutate}, {group, update_avps}]. + {group, soft_retire}, {group, mutate}, {group, update_avps}, + {group, project_twins}]. groups() -> [ @@ -239,6 +249,15 @@ groups() -> update_node_avps_atomic_rollback, update_node_avps_rejects_instance_only, update_node_avps_delete_instance_only_ok + ]}, + {project_twins, [], [ + get_node_2_reads_project_instance, + get_node_2_does_not_leak_into_environment_table, + retire_node_2_retires_a_project_instance, + update_node_avps_3_edits_a_project_instance, + delete_node_2_reports_not_implemented, + mutate_2_batches_within_one_project, + mutate_1_still_rejects_permanent_tier ]} ]. @@ -328,7 +347,14 @@ init_per_testcase(TC, Config) when TC =:= mutate_rejects_instance_only; TC =:= mutate_remove_relationship; TC =:= mutate_update_relationship; - TC =:= mutate_mixed_rollback -> + TC =:= mutate_mixed_rollback; + TC =:= get_node_2_reads_project_instance; + TC =:= get_node_2_does_not_leak_into_environment_table; + TC =:= retire_node_2_retires_a_project_instance; + TC =:= update_node_avps_3_edits_a_project_instance; + TC =:= delete_node_2_reports_not_implemented; + TC =:= mutate_2_batches_within_one_project; + TC =:= mutate_1_still_rejects_permanent_tier -> Config1 = setup_isolated_env(Config), BootstrapFile = proplists:get_value(bootstrap_file, Config), application:set_env(seerstone_graph_db, bootstrap_file, BootstrapFile), @@ -694,9 +720,12 @@ create_class_delegates(_Config) -> %%----------------------------------------------------------------------------- create_instance_delegates(_Config) -> {ok, ClassNref} = graphdb_class:create_class("TestClass2", 3), - {ok, Nref, _} = graphdb_mgr:create_instance(sess(), "TestInst", ClassNref, 5), + {ok, Nref, _} = graphdb_mgr:create_instance(proj(), "TestInst", ClassNref, root()), ?assert(is_integer(Nref)), - {ok, Node} = graphdb_mgr:get_node(Nref), + %% Nref is a project-space nref -- it can numerically collide with an + %% environment node (e.g. a category), so get_node/1 (environment-only) + %% is the wrong read here. Use the project-taking twin. + {ok, Node} = graphdb_mgr:get_node(proj(), Nref), ?assertEqual(instance, Node#node.kind). %%----------------------------------------------------------------------------- @@ -706,16 +735,23 @@ create_instance_delegates(_Config) -> add_relationship_delegates(_Config) -> %% Create a class and two instances {ok, ClassNref} = graphdb_class:create_class("RelClass", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "A", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "B", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(proj(), "A", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(proj(), "B", ClassNref, root()), %% Create a reciprocal relationship attribute pair (char/reciprocal nrefs) {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("Knows", "KnownBy", instance), %% Delegate through mgr ?assertEqual(ok, - graphdb_mgr:add_relationship(sess(), InstA, CharNref, InstB, RecipNref)), - %% Verify the arc is readable - {ok, Rels} = graphdb_mgr:get_relationships(InstA), + graphdb_mgr:add_relationship(proj(), InstA, CharNref, InstB, RecipNref)), + %% Verify the arc is readable. graphdb_mgr:get_relationships/1 is + %% environment-only and has no project-taking twin (SP2 T10 only added + %% one for get_node/retire_node/unretire_node/update_node_avps/ + %% delete_node) -- connection rows for project instances live in the + %% project's own relationships table, so read it directly. + {atomic, Rels} = mnesia:transaction(fun() -> + mnesia:index_read(graphdb_ns:rel_table(proj()), InstA, + #relationship.source_nref) + end), Targets = [R#relationship.target_nref || R <- Rels, R#relationship.characterization =:= CharNref], ?assertEqual([InstB], Targets). @@ -1029,18 +1065,19 @@ mutate_empty_batch(_Config) -> %% A single add_relationship returns {ok, [ok]} and writes the arc. %%----------------------------------------------------------------------------- mutate_single_add_relationship(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MClassAR", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MB", ClassNref, root()), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MKnows", "MKnownBy", instance), + %% Project instances -- mutate/1 resolves Home = environment (design); + %% use the Project-aware mutate/2 twin (SP2 T11). ?assertEqual({ok, [ok]}, - graphdb_mgr:mutate( + graphdb_mgr:mutate(Project, [{add_relationship, InstA, CharNref, InstB, RecipNref}])), - {ok, Rels} = graphdb_mgr:get_relationships(InstA), - Targets = [R#relationship.target_nref || R <- Rels, - R#relationship.characterization =:= CharNref], + Targets = mutate_conn_targets(Project, InstA, CharNref), ?assertEqual([InstB], Targets). %%----------------------------------------------------------------------------- @@ -1066,10 +1103,11 @@ mutate_single_retire_and_unretire(_Config) -> %% effect is present after commit. %%----------------------------------------------------------------------------- mutate_mixed_all_succeed(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MMixed", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MMA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MMB", ClassNref, 5), - {ok, InstC, _} = graphdb_instance:create_instance(sess(), "MMC", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MMA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MMB", ClassNref, root()), + {ok, InstC, _} = graphdb_instance:create_instance(Project, "MMC", ClassNref, root()), {ok, {Ch1, Re1}} = graphdb_attr:create_relationship_attribute_pair("MM1", "MM1r", instance), {ok, {Ch2, Re2}} = @@ -1077,13 +1115,14 @@ mutate_mixed_all_succeed(_Config) -> Batch = [{add_relationship, InstA, Ch1, InstB, Re1}, {add_relationship, InstA, Ch2, InstC, Re2}, {retire_node, InstB}], - ?assertEqual({ok, [ok, ok, ok]}, graphdb_mgr:mutate(Batch)), - {ok, Rels} = graphdb_mgr:get_relationships(InstA), + ?assertEqual({ok, [ok, ok, ok]}, graphdb_mgr:mutate(Project, Batch)), + Rels = mutate_conn_rows(Project, InstA), Chars = lists:sort([R#relationship.characterization || R <- Rels, R#relationship.characterization =:= Ch1 orelse R#relationship.characterization =:= Ch2]), ?assertEqual(lists:sort([Ch1, Ch2]), Chars), - [#node{attribute_value_pairs = BAVPs}] = mnesia:dirty_read(nodes, InstB), + [#node{attribute_value_pairs = BAVPs}] = + mnesia:dirty_read(graphdb_ns:node_table(Project), InstB), ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, BAVPs)). %%----------------------------------------------------------------------------- @@ -1092,19 +1131,21 @@ mutate_mixed_all_succeed(_Config) -> %% first mutation wrote is absent (the whole batch rolled back). %%----------------------------------------------------------------------------- mutate_atomic_rollback(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MRollback", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MRA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MRB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MRA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MRB", ClassNref, root()), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", instance), - BadNref = ?NREF_START + 999999, + %% A project instance nref can numerically collide with an environment + %% nref, but retire_node inside mutate/2 resolves against Project's own + %% table -- use a nref clearly outside the project allocator's range. + BadNref = 999999, Batch = [{add_relationship, InstA, CharNref, InstB, RecipNref}, {retire_node, BadNref}], - ?assertEqual({error, not_found}, graphdb_mgr:mutate(Batch)), - {ok, Rels} = graphdb_mgr:get_relationships(InstA), - Targets = [R#relationship.target_nref || R <- Rels, - R#relationship.characterization =:= CharNref], + ?assertEqual({error, not_found}, graphdb_mgr:mutate(Project, Batch)), + Targets = mutate_conn_targets(Project, InstA, CharNref), ?assertEqual([], Targets). %%----------------------------------------------------------------------------- @@ -1114,17 +1155,19 @@ mutate_atomic_rollback(_Config) -> %% retired afterward. %%----------------------------------------------------------------------------- mutate_read_your_writes_rollback(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MRYW", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MRYWA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MRYWB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MRYWA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MRYWB", ClassNref, root()), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MRYWK", "MRYWKr", instance), Batch = [{retire_node, InstA}, {add_relationship, InstA, CharNref, InstB, RecipNref}], ?assertEqual({error, {endpoint_retired, InstA}}, - graphdb_mgr:mutate(Batch)), - [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, InstA), + graphdb_mgr:mutate(Project, Batch)), + [#node{attribute_value_pairs = AVPs}] = + mnesia:dirty_read(graphdb_ns:node_table(Project), InstA), ?assertEqual(false, lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs)). @@ -1134,18 +1177,20 @@ mutate_read_your_writes_rollback(_Config) -> %% batch writes nothing (phase 1 rejects the whole batch before phase 2/3). %%----------------------------------------------------------------------------- mutate_malformed_term(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MBad", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MBadA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MBadB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MBadA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MBadB", ClassNref, root()), {ok, {CharNref, RecipNref}} = graphdb_attr:create_relationship_attribute_pair("MBadK", "MBadKr", instance), Bad = {frobnicate, 1, 2}, Batch = [{add_relationship, InstA, CharNref, InstB, RecipNref}, Bad], - ?assertEqual({error, {bad_mutation, Bad}}, graphdb_mgr:mutate(Batch)), - {ok, Rels} = graphdb_mgr:get_relationships(InstA), - Targets = [R#relationship.target_nref || R <- Rels, - R#relationship.characterization =:= CharNref], + %% Project instances -- use mutate/2 so a "nothing written" check below + %% reads the table the batch would actually have touched, not the + %% (always-untouched-by-this-batch) environment table. + ?assertEqual({error, {bad_mutation, Bad}}, graphdb_mgr:mutate(Project, Batch)), + Targets = mutate_conn_targets(Project, InstA, CharNref), ?assertEqual([], Targets). %%----------------------------------------------------------------------------- @@ -1163,24 +1208,49 @@ mutate_permanent_tier_guard(_Config) -> fun(#{attribute := A, value := true}) when A =:= RetAttr -> true; (_) -> false end, AVPs)). +%%----------------------------------------------------------------------------- +%% mutate/2 (SP2 T11): a Project-aware batch runs add_relationship against +%% Project's own tables -- the two instances and the relationship it creates +%% are readable back through the Project-taking get_node/2 twin. +%%----------------------------------------------------------------------------- +mutate_2_batches_within_one_project(_Config) -> + Project = proj(), + {ok, Root, _} = graphdb_instance:create_instance(Project, "Root", + widget_class(), root_instance(Project)), + {ok, A, _} = graphdb_instance:create_instance(Project, "A", widget_class(), + Root), + {ok, B, _} = graphdb_instance:create_instance(Project, "B", widget_class(), + Root), + {Char, Recip} = connects_to_attrs(), + {ok, [ok]} = graphdb_mgr:mutate(Project, + [{add_relationship, A, Char, B, Recip}]), + {ok, #node{}} = graphdb_mgr:get_node(Project, A). + +%%----------------------------------------------------------------------------- +%% mutate/1's behaviour is unchanged by the mutate/2 Home-threading refactor: +%% it still refuses the permanent tier via the environment-shaped tier_guard. +%%----------------------------------------------------------------------------- +mutate_1_still_rejects_permanent_tier(_Config) -> + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:mutate([{retire_node, ?NREF_ROOT}])). + %%----------------------------------------------------------------------------- %% mutate accepts the 6-element add_relationship form with an explicit %% template nref; the Template AVP on the written arc is that template. %%----------------------------------------------------------------------------- mutate_add_relationship_explicit_template(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MTmplClass", 3), {ok, AltTmpl} = graphdb_class:add_template(ClassNref, "msocial"), - {ok, A, _} = graphdb_instance:create_instance(sess(), "MTA", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "MTB", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(Project, "MTA", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "MTB", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MTKnows", "MTKnownBy", instance), ?assertEqual({ok, [ok]}, - graphdb_mgr:mutate( + graphdb_mgr:mutate(Project, [{add_relationship, A, Char, B, Recip, AltTmpl}])), - {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) - end), + ARels = mutate_conn_rows(Project, A), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, R#relationship.target_nref =:= B], @@ -1193,10 +1263,11 @@ mutate_add_relationship_explicit_template(_Config) -> %% on the reverse arc only. %%----------------------------------------------------------------------------- mutate_add_relationship_with_avps(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MAvpClass", 3), {ok, DefaultTmpl} = graphdb_class:default_template(ClassNref), - {ok, A, _} = graphdb_instance:create_instance(sess(), "MAvA", ClassNref, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "MAvB", ClassNref, 5), + {ok, A, _} = graphdb_instance:create_instance(Project, "MAvA", ClassNref, root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "MAvB", ClassNref, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MAvKnows", "MAvKnownBy", instance), @@ -1205,20 +1276,16 @@ mutate_add_relationship_with_avps(_Config) -> FwdOnly = #{attribute => Source, value => "research-paper"}, RevOnly = #{attribute => Confidence, value => 0.42}, ?assertEqual({ok, [ok]}, - graphdb_mgr:mutate( + graphdb_mgr:mutate(Project, [{add_relationship, A, Char, B, Recip, DefaultTmpl, {[FwdOnly], [RevOnly]}}])), - {atomic, ARels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, A, #relationship.source_nref) - end), + ARels = mutate_conn_rows(Project, A), [Fwd] = [R || R <- ARels, R#relationship.characterization =:= Char, R#relationship.target_nref =:= B], ?assert(lists:member(FwdOnly, Fwd#relationship.avps)), ?assertNot(lists:member(RevOnly, Fwd#relationship.avps)), - {atomic, BRels} = mnesia:transaction(fun() -> - mnesia:index_read(relationships, B, #relationship.source_nref) - end), + BRels = mutate_conn_rows(Project, B), [Rev] = [R || R <- BRels, R#relationship.characterization =:= Recip, R#relationship.target_nref =:= A], @@ -1230,34 +1297,35 @@ mutate_add_relationship_with_avps(_Config) -> %% A single update_node_avps mutation returns {ok, [ok]} and writes the AVP. %%----------------------------------------------------------------------------- mutate_single_update_node_avps(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MUAClass", 3), - {ok, Inst, _} = graphdb_instance:create_instance(sess(), "MUAInst", ClassNref, 5), + {ok, Inst, _} = graphdb_instance:create_instance(Project, "MUAInst", ClassNref, root()), {ok, Attr} = graphdb_attr:create_literal_attribute("MUAAttr", string), ?assertEqual({ok, [ok]}, - graphdb_mgr:mutate([{update_node_avps, Inst, + graphdb_mgr:mutate(Project, [{update_node_avps, Inst, [#{attribute => Attr, value => "blue"}]}])), - [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, Inst), + [#node{attribute_value_pairs = AVPs}] = + mnesia:dirty_read(graphdb_ns:node_table(Project), Inst), ?assert(lists:member(#{attribute => Attr, value => "blue"}, AVPs)). %%----------------------------------------------------------------------------- %% A mixed batch (add_relationship + update_node_avps) all succeeds. %%----------------------------------------------------------------------------- mutate_mixed_add_rel_and_update_avps(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MMUAClass", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MMUAA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MMUAB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MMUAA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MMUAB", ClassNref, root()), {ok, {Ch, Re}} = graphdb_attr:create_relationship_attribute_pair("MMUAk", "MMUAkb", instance), {ok, Attr} = graphdb_attr:create_literal_attribute("MMUAAttr", string), Batch = [{add_relationship, InstA, Ch, InstB, Re}, {update_node_avps, InstA, [#{attribute => Attr, value => "green"}]}], - ?assertEqual({ok, [ok, ok]}, graphdb_mgr:mutate(Batch)), - {ok, Rels} = graphdb_mgr:get_relationships(InstA), - ?assertEqual([InstB], - [R#relationship.target_nref || R <- Rels, - R#relationship.characterization =:= Ch]), - [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, InstA), + ?assertEqual({ok, [ok, ok]}, graphdb_mgr:mutate(Project, Batch)), + ?assertEqual([InstB], mutate_conn_targets(Project, InstA, Ch)), + [#node{attribute_value_pairs = AVPs}] = + mnesia:dirty_read(graphdb_ns:node_table(Project), InstA), ?assert(lists:member(#{attribute => Attr, value => "green"}, AVPs)). %%----------------------------------------------------------------------------- @@ -1266,9 +1334,10 @@ mutate_mixed_add_rel_and_update_avps(_Config) -> %% first mutation wrote is absent. %%----------------------------------------------------------------------------- mutate_update_avps_rollback(_Config) -> + Project = proj(), {ok, ClassNref} = graphdb_class:create_class("MUARbClass", 3), - {ok, InstA, _} = graphdb_instance:create_instance(sess(), "MUARbA", ClassNref, 5), - {ok, InstB, _} = graphdb_instance:create_instance(sess(), "MUARbB", ClassNref, 5), + {ok, InstA, _} = graphdb_instance:create_instance(Project, "MUARbA", ClassNref, root()), + {ok, InstB, _} = graphdb_instance:create_instance(Project, "MUARbB", ClassNref, root()), {ok, {Ch, Re}} = graphdb_attr:create_relationship_attribute_pair("MUARbk", "MUARbkb", instance), @@ -1276,11 +1345,8 @@ mutate_update_avps_rollback(_Config) -> Batch = [{add_relationship, InstA, Ch, InstB, Re}, {update_node_avps, InstA, [#{attribute => BadAttr, value => 1}]}], ?assertEqual({error, {unknown_attribute, BadAttr}}, - graphdb_mgr:mutate(Batch)), - {ok, Rels} = graphdb_mgr:get_relationships(InstA), - ?assertEqual([], - [R#relationship.target_nref || R <- Rels, - R#relationship.characterization =:= Ch]). + graphdb_mgr:mutate(Project, Batch)), + ?assertEqual([], mutate_conn_targets(Project, InstA, Ch)). %%----------------------------------------------------------------------------- %% A malformed update_node_avps mutation is rejected in static validation @@ -1315,13 +1381,14 @@ mutate_update_avps_not_found(_Config) -> ua_setup(Name) -> {ok, ClassNref} = graphdb_class:create_class("UAClass" ++ Name, 3), {ok, InstNref, _} = - graphdb_instance:create_instance(sess(), "UAInst" ++ Name, ClassNref, 5), + graphdb_instance:create_instance(proj(), "UAInst" ++ Name, ClassNref, root()), {ok, AttrNref} = graphdb_attr:create_literal_attribute("UAAttr" ++ Name, string), {InstNref, AttrNref}. ua_avps(Nref) -> - [#node{attribute_value_pairs = AVPs}] = mnesia:dirty_read(nodes, Nref), + [#node{attribute_value_pairs = AVPs}] = + mnesia:dirty_read(graphdb_ns:node_table(proj()), Nref), AVPs. ua_value(Nref, AttrNref) -> @@ -1337,7 +1404,7 @@ ua_value(Nref, AttrNref) -> update_node_avps_upsert_roundtrip(_Config) -> {Inst, Attr} = ua_setup("RT"), ?assertEqual(ok, - graphdb_mgr:update_node_avps(Inst, [#{attribute => Attr, value => "red"}])), + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => Attr, value => "red"}])), ?assertEqual("red", ua_value(Inst, Attr)). %%----------------------------------------------------------------------------- @@ -1347,7 +1414,7 @@ update_node_avps_overwrite_preserves_head(_Config) -> {Inst, _Attr} = ua_setup("Head"), [#{attribute := NameAttr} | _] = ua_avps(Inst), ?assertEqual(ok, - graphdb_mgr:update_node_avps(Inst, + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => NameAttr, value => "Renamed"}])), [#{attribute := NameAttr, value := "Renamed"} | _] = ua_avps(Inst). @@ -1356,10 +1423,10 @@ update_node_avps_overwrite_preserves_head(_Config) -> %%----------------------------------------------------------------------------- update_node_avps_delete(_Config) -> {Inst, Attr} = ua_setup("Del"), - ok = graphdb_mgr:update_node_avps(Inst, [#{attribute => Attr, value => "x"}]), + ok = graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => Attr, value => "x"}]), ?assertEqual("x", ua_value(Inst, Attr)), ?assertEqual(ok, - graphdb_mgr:update_node_avps(Inst, [#{attribute => Attr}])), + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => Attr}])), ?assertEqual(not_found, ua_value(Inst, Attr)). %%----------------------------------------------------------------------------- @@ -1369,7 +1436,7 @@ update_node_avps_delete_absent_noop(_Config) -> {Inst, Attr} = ua_setup("DelAbsent"), Before = ua_avps(Inst), ?assertEqual(ok, - graphdb_mgr:update_node_avps(Inst, [#{attribute => Attr}])), + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => Attr}])), ?assertEqual(Before, ua_avps(Inst)). %%----------------------------------------------------------------------------- @@ -1378,7 +1445,7 @@ update_node_avps_delete_absent_noop(_Config) -> update_node_avps_undefined_retained(_Config) -> {Inst, Attr} = ua_setup("Undef"), ?assertEqual(ok, - graphdb_mgr:update_node_avps(Inst, + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => Attr, value => undefined}])), AVPs = ua_avps(Inst), ?assert(lists:member(#{attribute => Attr, value => undefined}, AVPs)). @@ -1390,7 +1457,7 @@ update_node_avps_unknown_attribute(_Config) -> {Inst, _Attr} = ua_setup("Unknown"), BadAttr = ?NREF_START + 888888, ?assertEqual({error, {unknown_attribute, BadAttr}}, - graphdb_mgr:update_node_avps(Inst, [#{attribute => BadAttr, value => 1}])). + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => BadAttr, value => 1}])). %%----------------------------------------------------------------------------- %% Targeting the seeded `retired` attribute is rejected -> use_retire_api. @@ -1399,7 +1466,7 @@ update_node_avps_retired_marker_rejected(_Config) -> {Inst, _Attr} = ua_setup("Ret"), {ok, #{retired := RetAttr}} = graphdb_attr:seeded_nrefs(), ?assertEqual({error, use_retire_api}, - graphdb_mgr:update_node_avps(Inst, + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => RetAttr, value => true}])). %%----------------------------------------------------------------------------- @@ -1427,7 +1494,7 @@ update_node_avps_atomic_rollback(_Config) -> {Inst, Attr} = ua_setup("Atomic"), BadAttr = ?NREF_START + 888888, ?assertEqual({error, {unknown_attribute, BadAttr}}, - graphdb_mgr:update_node_avps(Inst, + graphdb_mgr:update_node_avps(proj(), Inst, [#{attribute => Attr, value => "red"}, #{attribute => BadAttr, value => "boom"}])), ?assertEqual(not_found, ua_value(Inst, Attr)). @@ -1478,39 +1545,54 @@ mutate_rejects_instance_only(_Config) -> ?assert(lists:member( #{attribute => Attr, value => undefined, instance_only => true}, AVPs)). -%% count outgoing connection rows Source--Char-->Target via the public read API -mutate_conn_count(Source, Char, Target) -> - {ok, Rels} = graphdb_mgr:get_relationships(Source), - length([R || R <- Rels, +%% all outgoing connection rows from Source, read from Project's own +%% relationships table directly. graphdb_mgr:get_relationships/1 is +%% environment-only and has no project-taking twin (SP2 T10 only added one +%% for get_node/retire_node/unretire_node/update_node_avps/delete_node). +mutate_conn_rows(Project, Source) -> + {atomic, Rows} = mnesia:transaction(fun() -> + mnesia:index_read(graphdb_ns:rel_table(Project), Source, + #relationship.source_nref) + end), + Rows. + +%% outgoing connection arc targets from Source with characterization Char. +mutate_conn_targets(Project, Source, Char) -> + [R#relationship.target_nref || R <- mutate_conn_rows(Project, Source), R#relationship.kind =:= connection, - R#relationship.characterization =:= Char, - R#relationship.target_nref =:= Target]). + R#relationship.characterization =:= Char]. + +%% count outgoing connection rows Source--Char-->Target. +mutate_conn_count(Project, Source, Char, Target) -> + length([T || T <- mutate_conn_targets(Project, Source, Char), T =:= Target]). mutate_remove_relationship(_Config) -> + Project = proj(), {ok, Class} = graphdb_class:create_class("MRemoveOrg", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "MRA", Class, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "MRB", Class, 5), + {ok, A, _} = graphdb_instance:create_instance(Project, "MRA", Class, root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "MRB", Class, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MRKnows", "MRKnownBy", instance), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), - ?assertEqual(1, mutate_conn_count(A, Char, B)), + ok = graphdb_instance:add_relationship(Project, A, Char, B, Recip), + ?assertEqual(1, mutate_conn_count(Project, A, Char, B)), ?assertEqual({ok, [ok]}, - graphdb_mgr:mutate([{remove_relationship, A, Char, B}])), - ?assertEqual(0, mutate_conn_count(A, Char, B)), - ?assertEqual(0, mutate_conn_count(B, Recip, A)), + graphdb_mgr:mutate(Project, [{remove_relationship, A, Char, B}])), + ?assertEqual(0, mutate_conn_count(Project, A, Char, B)), + ?assertEqual(0, mutate_conn_count(Project, B, Recip, A)), ok = graphdb_mgr:verify_caches(). mutate_update_relationship(_Config) -> + Project = proj(), {ok, Class} = graphdb_class:create_class("MUpdOrg", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "MUA", Class, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "MUB", Class, 5), + {ok, A, _} = graphdb_instance:create_instance(Project, "MUA", Class, root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "MUB", Class, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MUKnows", "MUKnownBy", instance), {ok, Note} = graphdb_attr:create_literal_attribute("munote", string), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), - ?assertEqual({ok, [ok, ok]}, graphdb_mgr:mutate([ + ok = graphdb_instance:add_relationship(Project, A, Char, B, Recip), + ?assertEqual({ok, [ok, ok]}, graphdb_mgr:mutate(Project, [ {update_relationship, A, Char, B, [#{attribute => Note, value => "f"}]}, {update_relationship_both, A, Char, B, {[#{attribute => Note, value => "F"}], @@ -1518,36 +1600,182 @@ mutate_update_relationship(_Config) -> ok = graphdb_mgr:verify_caches(). mutate_mixed_rollback(_Config) -> + Project = proj(), {ok, Class} = graphdb_class:create_class("MMixOrg", 3), - {ok, A, _} = graphdb_instance:create_instance(sess(), "MMA", Class, 5), - {ok, B, _} = graphdb_instance:create_instance(sess(), "MMB", Class, 5), + {ok, A, _} = graphdb_instance:create_instance(Project, "MMA", Class, root()), + {ok, B, _} = graphdb_instance:create_instance(Project, "MMB", Class, root()), {ok, {Char, Recip}} = graphdb_attr:create_relationship_attribute_pair("MMKnows", "MMKnownBy", instance), - ok = graphdb_instance:add_relationship(sess(), A, Char, B, Recip), + ok = graphdb_instance:add_relationship(Project, A, Char, B, Recip), %% second mutation removes a non-existent edge -> whole batch rolls back - ?assertEqual({error, relationship_not_found}, graphdb_mgr:mutate([ + ?assertEqual({error, relationship_not_found}, graphdb_mgr:mutate(Project, [ {remove_relationship, A, Char, B}, {remove_relationship, A, Char, B}])), %% the first remove was rolled back -- the edge is still present - ?assertEqual(1, mutate_conn_count(A, Char, B)), - ?assertEqual(1, mutate_conn_count(B, Recip, A)), + ?assertEqual(1, mutate_conn_count(Project, A, Char, B)), + ?assertEqual(1, mutate_conn_count(Project, B, Recip, A)), ok = graphdb_mgr:verify_caches(). +%%============================================================================= +%% Project-taking twins (SP2 T10): get_node/2, retire_node/2, +%% update_node_avps/3, delete_node/2. +%% +%% proj() (defined below, near the end of this file) is a normal per-suite +%% CT helper -- each SP2-era suite defines its own memoised copy. +%%============================================================================= + +get_node_2_reads_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + {ok, #node{nref = Nref, kind = instance}} = + graphdb_mgr:get_node(Project, Nref). + +get_node_2_does_not_leak_into_environment_table(_Config) -> + Project = proj(), + %% A project instance's nref must not resolve to whatever node carries + %% that same integer in the environment's own nodes table -- get_node/2 + %% reads must stay scoped to Project's own table (never fall through + %% to, or collide with, the environment's nref space). Nref is bound to + %% whatever the project allocator actually returns rather than assumed + %% to be 1 -- root_instance/1 draws the project's first nref for its + %% seeded root, so the real instance created below does not land on 1. + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "First", + widget_class(), root_instance(Project)), + {ok, #node{kind = instance}} = graphdb_mgr:get_node(Project, Nref), + {ok, #node{kind = category}} = graphdb_mgr:get_node(?NREF_ROOT). + +retire_node_2_retires_a_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + ok = graphdb_mgr:retire_node(Project, Nref), + {ok, #node{attribute_value_pairs = AVPs}} = graphdb_mgr:get_node(Project, Nref), + ?assert(lists:any(fun(#{value := true}) -> true; (_) -> false end, AVPs)). + +update_node_avps_3_edits_a_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + Colour = ensure_colour_attribute(), + ok = graphdb_mgr:update_node_avps(Project, Nref, + [#{attribute => Colour, value => "blue"}]), + {ok, #node{attribute_value_pairs = AVPs}} = + graphdb_mgr:get_node(Project, Nref), + ?assertEqual({ok, "blue"}, find_avp(AVPs, Colour)). + +delete_node_2_reports_not_implemented(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + ?assertEqual({error, not_implemented}, graphdb_mgr:delete_node(Project, Nref)). + +%%--------------------------------------------------------------------- +%% widget_class() -> ClassNref +%% +%% Throwaway environment-scoped class for the project-twin tests. Classes +%% live in the environment regardless of which project instantiates them. +%%--------------------------------------------------------------------- +widget_class() -> + {ok, ClassNref} = graphdb_class:create_class("T10Widget", 3), + ClassNref. + +%%--------------------------------------------------------------------- +%% root_instance(Project) -> Nref +%% +%% Seeds a throwaway compositional-root instance directly into Project's +%% own (initially empty) nodes table, bypassing create_instance's parent +%% validation (do_validate_parent/3 requires the parent to already exist, +%% and a fresh project store has nothing yet to point at). +%%--------------------------------------------------------------------- +root_instance(Project) -> + Nref = graphdb_project:next_nref(Project), + Node = #node{nref = Nref, kind = instance, attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), Node), + Nref. + +%%--------------------------------------------------------------------- +%% ensure_colour_attribute() -> AttrNref +%% +%% Throwaway environment-scoped literal attribute, memoised per test-case +%% process. +%%--------------------------------------------------------------------- +ensure_colour_attribute() -> + case get(t10_colour_attr) of + undefined -> + {ok, AttrNref} = + graphdb_attr:create_literal_attribute("T10Colour", string), + put(t10_colour_attr, AttrNref), + AttrNref; + AttrNref -> + AttrNref + end. + +%%--------------------------------------------------------------------- +%% connects_to_attrs() -> {CharNref, RecipNref} +%% +%% Throwaway environment-scoped reciprocal connection-attribute pair for +%% mutate/2's add_relationship test, memoised per test-case process. +%% Follows the same pattern as add_relationship_delegates/1's inline +%% create_relationship_attribute_pair call. +%%--------------------------------------------------------------------- +connects_to_attrs() -> + case get(t11_connects_to_attrs) of + undefined -> + {ok, {CharNref, RecipNref}} = + graphdb_attr:create_relationship_attribute_pair("T11ConnectsTo", + "T11ConnectedBy", instance), + put(t11_connects_to_attrs, {CharNref, RecipNref}), + {CharNref, RecipNref}; + Pair -> + Pair + end. + +%% find_avp(AVPs, AttrNref) -> {ok, Value} | not_found +%% Searches an AVP list for an entry whose attribute key equals AttrNref; +%% returns {ok, Value} on the first match, not_found if absent. +find_avp(AVPs, A) -> + case lists:search(fun(#{attribute := X}) -> X =:= A end, AVPs) of + {value, #{value := V}} -> {ok, V}; + false -> not_found + end. + +%%--------------------------------------------------------------------- +%% proj() -> Project +%% +%% SP2 test helper: returns a project handle, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens it; subsequent calls in the same process reuse it. +%%--------------------------------------------------------------------- +proj() -> + case get(sp2_project) of + undefined -> + {ok, P} = graphdb_project:register_project("SP2 test project"), + {ok, Project} = graphdb_project:open(P), + put(sp2_project, Project), + Project; + Project -> + Project + end. + %%--------------------------------------------------------------------- -%% sess() -> Session +%% root() -> Nref %% -%% SP1 test helper: returns a project session, memoised per test-case -%% process. Registers a project under Projects (nref 5) on first use and -%% opens a session against it; subsequent calls in the same process reuse it. +%% SP2 test helper: returns a shared compositional-root instance nref for +%% proj(), memoised per test-case process (mirrors proj()'s own memo +%% pattern) so every create_instance/4 call in one test case that used +%% to pass the old single-store stand-in parent (bare 5 -- an environment +%% category nref that happened to always exist in the pre-SP2 shared +%% table) shares the SAME project-local parent. Seeds via root_instance/1 +%% on first use. %%--------------------------------------------------------------------- -sess() -> - case get(sp1_session) of +root() -> + case get(sp2_root) of undefined -> - {ok, P} = graphdb_project:register_project("SP1 test session"), - {ok, S} = graphdb_project:open_session(P), - put(sp1_session, S), - S; - S -> - S + Nref = root_instance(proj()), + put(sp2_root, Nref), + Nref; + Nref -> + Nref end. diff --git a/apps/graphdb/test/graphdb_ns_tests.erl b/apps/graphdb/test/graphdb_ns_tests.erl index c376dc1..3f789f8 100644 --- a/apps/graphdb/test/graphdb_ns_tests.erl +++ b/apps/graphdb/test/graphdb_ns_tests.erl @@ -1,24 +1,42 @@ -module(graphdb_ns_tests). -include_lib("eunit/include/eunit.hrl"). -namespace_of_environment_roles_test() -> - [ ?assertEqual(environment, graphdb_ns:namespace_of(R)) - || R <- [characterization, reciprocal, avp_attribute, - node_classes, taxonomy_parent] ]. +-define(PROJECT, #{anchor => 42, nodes => nodes_42, rels => relationships_42, counters => counters_42}). -namespace_of_project_roles_test() -> - ?assertEqual(project, graphdb_ns:namespace_of(compositional_parent)). +namespace_of_environment_roles_test() -> + [ ?assertEqual(environment, graphdb_ns:namespace_of(Home, R)) + || Home <- [environment, ?PROJECT], + R <- [characterization, reciprocal, avp_attribute, + node_classes, taxonomy_parent] ]. -namespace_of_home_roles_test() -> - [ ?assertEqual(home, graphdb_ns:namespace_of(R)) - || R <- [node_nref, source_nref] ]. +namespace_of_home_relative_roles_test() -> + [ ?assertEqual(Home, graphdb_ns:namespace_of(Home, R)) + || Home <- [environment, ?PROJECT], + R <- [compositional_parent, node_nref] ]. -target_namespace_instance_is_project_test() -> - ?assertEqual(project, graphdb_ns:target_namespace(instance)). +target_namespace_instance_is_home_test() -> + [ ?assertEqual(Home, graphdb_ns:target_namespace(Home, instance)) + || Home <- [environment, ?PROJECT] ]. target_namespace_others_are_environment_test() -> - [ ?assertEqual(environment, graphdb_ns:target_namespace(K)) - || K <- [category, attribute, class] ]. + [ ?assertEqual(environment, graphdb_ns:target_namespace(Home, K)) + || Home <- [environment, ?PROJECT], + K <- [category, attribute, class] ]. namespace_of_unknown_role_crashes_test() -> - ?assertError(function_clause, graphdb_ns:namespace_of(bogus_role)). + ?assertError(function_clause, graphdb_ns:namespace_of(environment, bogus_role)). + +target_namespace_unknown_kind_crashes_test() -> + ?assertError(function_clause, graphdb_ns:target_namespace(environment, bogus_kind)). + +node_table_environment_is_literal_test() -> + ?assertEqual(nodes, graphdb_ns:node_table(environment)). + +node_table_project_is_its_own_table_test() -> + ?assertEqual(nodes_42, graphdb_ns:node_table(?PROJECT)). + +rel_table_environment_is_literal_test() -> + ?assertEqual(relationships, graphdb_ns:rel_table(environment)). + +rel_table_project_is_its_own_table_test() -> + ?assertEqual(relationships_42, graphdb_ns:rel_table(?PROJECT)). diff --git a/apps/graphdb/test/graphdb_project_SUITE.erl b/apps/graphdb/test/graphdb_project_SUITE.erl index 9316014..810a978 100644 --- a/apps/graphdb/test/graphdb_project_SUITE.erl +++ b/apps/graphdb/test/graphdb_project_SUITE.erl @@ -35,6 +35,16 @@ attribute_value_pairs }). +-record(relationship, { + id, + kind, + source_nref, + characterization, + target_nref, + reciprocal, + avps +}). + %%--------------------------------------------------------------------- %% Common Test callbacks @@ -55,9 +65,18 @@ %%--------------------------------------------------------------------- -export([ register_project_creates_child_of_projects/1, + register_project_creates_tables/1, + register_project_is_idempotent/1, is_project_false_for_non_child/1, - open_session_on_registered_project/1, - open_session_rejects_non_project/1 + open_returns_project_handle/1, + open_rejects_non_project/1, + open_rejects_project_without_store/1, + open_rejects_project_with_partial_tables/1, + require_project_accepts_valid_handle/1, + require_project_rejects_malformed_term/1, + next_nref_starts_at_one/1, + next_nref_is_sequential/1, + next_rel_id_pair_returns_two_consecutive_ids/1 ]). @@ -70,9 +89,18 @@ suite() -> all() -> [register_project_creates_child_of_projects, + register_project_creates_tables, + register_project_is_idempotent, is_project_false_for_non_child, - open_session_on_registered_project, - open_session_rejects_non_project]. + open_returns_project_handle, + open_rejects_non_project, + open_rejects_project_without_store, + open_rejects_project_with_partial_tables, + require_project_accepts_valid_handle, + require_project_rejects_malformed_term, + next_nref_starts_at_one, + next_nref_is_sequential, + next_rel_id_pair_returns_two_consecutive_ids]. %%----------------------------------------------------------------------------- @@ -178,19 +206,150 @@ is_project_false_for_non_child(_Config) -> ?assertNot(graphdb_project:is_project(?NREF_CLASSES)). %%----------------------------------------------------------------------------- -%% open_session returns {ok, Session} for a registered project. +%% register_project creates the three physical tables, all initially empty. +%%----------------------------------------------------------------------------- +register_project_creates_tables(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + Tables = mnesia:system_info(tables), + ?assert(lists:member(list_to_atom("nodes_" ++ integer_to_list(P)), Tables)), + ?assert(lists:member(list_to_atom("relationships_" ++ integer_to_list(P)), + Tables)), + ?assert(lists:member(list_to_atom("counters_" ++ integer_to_list(P)), + Tables)). + +%%----------------------------------------------------------------------------- +%% register_project/1 is create-if-absent: a retried call for the same +%% Name converges on the same anchor nref and the same table handle, and +%% does NOT leave a duplicate project node under Projects. +%%----------------------------------------------------------------------------- +register_project_is_idempotent(_Config) -> + {ok, P1} = graphdb_project:register_project("Acme"), + {ok, P2} = graphdb_project:register_project("Acme"), + ?assertEqual(P1, P2), + {ok, Handle1} = graphdb_project:open(P1), + {ok, Handle2} = graphdb_project:open(P2), + ?assertEqual(Handle1, Handle2), + ?assertEqual(1, count_projects_named("Acme")). + +%%----------------------------------------------------------------------------- +%% open/1 returns a Project handle carrying the three table atoms. +%%----------------------------------------------------------------------------- +open_returns_project_handle(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(#{anchor => P, + nodes => list_to_atom("nodes_" ++ integer_to_list(P)), + rels => list_to_atom("relationships_" ++ integer_to_list(P)), + counters => list_to_atom("counters_" ++ integer_to_list(P))}, + Project). + +%%----------------------------------------------------------------------------- +%% open/1 rejects a non-project nref. +%%----------------------------------------------------------------------------- +open_rejects_non_project(_Config) -> + ?assertEqual({error, not_a_project}, graphdb_project:open(?NREF_CLASSES)). + +%%----------------------------------------------------------------------------- +%% open/1 reports {error, no_store} for a registered anchor whose tables +%% were never created (the SP1-era state) -- simulated by writing an anchor +%% node directly under Projects without calling register_project/1. +%%----------------------------------------------------------------------------- +open_rejects_project_without_store(_Config) -> + Nref = graphdb_nref:get_next(), + {Id1, Id2} = rel_id_server:get_id_pair(), + Node = #node{nref = Nref, kind = instance, parents = [?NREF_PROJECTS], + attribute_value_pairs = []}, + F = fun() -> + ok = mnesia:write(nodes, Node, write), + ok = mnesia:write(relationships, + #relationship{id = Id1, kind = composition, + source_nref = ?NREF_PROJECTS, + characterization = ?ARC_CAT_CHILD, + target_nref = Nref, reciprocal = ?ARC_CAT_PARENT, + avps = []}, write), + ok = mnesia:write(relationships, + #relationship{id = Id2, kind = composition, + source_nref = Nref, + characterization = ?ARC_CAT_PARENT, + target_nref = ?NREF_PROJECTS, + reciprocal = ?ARC_CAT_CHILD, avps = []}, write) + end, + {ok, ok} = graphdb_mgr:transaction(F), + ?assert(graphdb_project:is_project(Nref)), + ?assertEqual({error, no_store}, graphdb_project:open(Nref)). + +%%----------------------------------------------------------------------------- +%% open/1 reports {error, no_store} when only a subset of the three tables +%% exist -- e.g. ensure_tables/1 failed partway through, leaving nodes_ +%% created but relationships_/counters_ absent. tables_exist/1 must +%% check all three, not just the nodes table. +%%----------------------------------------------------------------------------- +open_rejects_project_with_partial_tables(_Config) -> + Nref = graphdb_nref:get_next(), + {Id1, Id2} = rel_id_server:get_id_pair(), + Node = #node{nref = Nref, kind = instance, parents = [?NREF_PROJECTS], + attribute_value_pairs = []}, + F = fun() -> + ok = mnesia:write(nodes, Node, write), + ok = mnesia:write(relationships, + #relationship{id = Id1, kind = composition, + source_nref = ?NREF_PROJECTS, + characterization = ?ARC_CAT_CHILD, + target_nref = Nref, reciprocal = ?ARC_CAT_PARENT, + avps = []}, write), + ok = mnesia:write(relationships, + #relationship{id = Id2, kind = composition, + source_nref = Nref, + characterization = ?ARC_CAT_PARENT, + target_nref = ?NREF_PROJECTS, + reciprocal = ?ARC_CAT_CHILD, avps = []}, write) + end, + {ok, ok} = graphdb_mgr:transaction(F), + NodesTable = list_to_atom("nodes_" ++ integer_to_list(Nref)), + {atomic, ok} = mnesia:create_table(NodesTable, [ + {record_name, node}, + {attributes, record_info(fields, node)}, + {disc_copies, [node()]} + ]), + ?assert(graphdb_project:is_project(Nref)), + ?assertEqual({error, no_store}, graphdb_project:open(Nref)). + +%%----------------------------------------------------------------------------- +%% require_project accepts a well-formed handle, rejects everything else. +%%----------------------------------------------------------------------------- +require_project_accepts_valid_handle(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(ok, graphdb_project:require_project(Project)). + +require_project_rejects_malformed_term(_Config) -> + ?assertEqual({error, invalid_project}, graphdb_project:require_project(undefined)), + ?assertEqual({error, invalid_project}, graphdb_project:require_project(#{})). + +%%----------------------------------------------------------------------------- +%% next_nref/1: first allocation yields 1; no seeding needed. %%----------------------------------------------------------------------------- -open_session_on_registered_project(_Config) -> +next_nref_starts_at_one(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(1, graphdb_project:next_nref(Project)). + +next_nref_is_sequential(_Config) -> {ok, P} = graphdb_project:register_project("Acme"), - {ok, S} = graphdb_project:open_session(P), - ?assertEqual(P, graphdb_project:session_project(S)). + {ok, Project} = graphdb_project:open(P), + ?assertEqual(1, graphdb_project:next_nref(Project)), + ?assertEqual(2, graphdb_project:next_nref(Project)), + ?assertEqual(3, graphdb_project:next_nref(Project)). %%----------------------------------------------------------------------------- -%% open_session returns {error, not_a_project} for a non-project nref. +%% next_rel_id_pair/1: two consecutive ids, independent of the nref counter. %%----------------------------------------------------------------------------- -open_session_rejects_non_project(_Config) -> - ?assertEqual({error, not_a_project}, - graphdb_project:open_session(?NREF_CLASSES)). +next_rel_id_pair_returns_two_consecutive_ids(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual({1, 2}, graphdb_project:next_rel_id_pair(Project)), + ?assertEqual({3, 4}, graphdb_project:next_rel_id_pair(Project)), + ?assertEqual(1, graphdb_project:next_nref(Project)). %%============================================================================= @@ -225,6 +384,31 @@ setup_isolated_env(Config) -> [{tmp_dir, TmpDir}, {mnesia_dir, MnesiaDir} | Config]. +%%----------------------------------------------------------------------------- +%% count_projects_named(Name) -> non_neg_integer() +%% +%% Counts the direct children of Projects (nref 5) whose instance-name AVP +%% equals Name. Used to assert register_project/1's create-if-absent +%% behaviour never leaves a duplicate anchor for a retried call. +%%----------------------------------------------------------------------------- +count_projects_named(Name) -> + {ok, Arcs} = graphdb_mgr:get_relationships(?NREF_PROJECTS, outgoing), + ChildNrefs = [T || #relationship{characterization = C, target_nref = T} + <- Arcs, C =:= ?ARC_CAT_CHILD], + length([N || N <- ChildNrefs, node_named(N, Name)]). + +node_named(Nref, Name) -> + case graphdb_mgr:get_node(Nref) of + {ok, #node{attribute_value_pairs = AVPs}} -> + lists:any(fun + (#{attribute := ?NAME_ATTR_INSTANCE, value := V}) -> V =:= Name; + (_) -> false + end, AVPs); + _ -> + false + end. + + %%----------------------------------------------------------------------------- %% verify_cache_invariant(TC) -> ok %%----------------------------------------------------------------------------- diff --git a/apps/graphdb/test/graphdb_query_SUITE.erl b/apps/graphdb/test/graphdb_query_SUITE.erl index 6041bb0..6ad7b2a 100644 --- a/apps/graphdb/test/graphdb_query_SUITE.erl +++ b/apps/graphdb/test/graphdb_query_SUITE.erl @@ -22,6 +22,19 @@ -define(DIR_PREFIX, "query_"). +%%--------------------------------------------------------------------- +%% Record definition (matches graphdb_query.erl's own local #node{} — +%% needed here only by root_instance/1, which writes a raw #node{} into +%% a project's nodes table directly, bypassing create_instance). +%%--------------------------------------------------------------------- +-record(node, { + nref, + kind, + parents = [], + classes = [], + attribute_value_pairs +}). + %%--------------------------------------------------------------------- %% Common Test callbacks %%--------------------------------------------------------------------- @@ -66,6 +79,7 @@ q2_includes_labels_default_english/1, q2_not_found_returns_error/1, q2_rejects_non_attribute_nref/1, + q2_label_not_dropped_under_project_session/1, %% describe_class q3_describes_class_with_superclasses/1, q3_lists_subclasses/1, @@ -90,7 +104,15 @@ q6_arc_kind_filter/1, q6_find_path_3_public_api/1, %% resume / snapshot_expired - resume_against_refreshed_session_fails/1 + resume_against_refreshed_session_fails/1, + %% SP2 T12 — session binds a Project; resolve_home/2 + new_session_1_binds_a_project/1, + q_get_node_reads_a_project_instance/1, + q_get_node_still_reads_environment_when_project_bound/1, + resolve_home_prefers_project_and_logs_on_collision/1, + %% SP2 review wave B Fix 2 — malformed-handle read-path gating + execute_query_2_rejects_bad_session_project/1, + resume_rejects_bad_session_project/1 ]). suite() -> @@ -100,7 +122,7 @@ all() -> [{group, skeleton}, {group, q1_get_node}, {group, q1b_get_arcs}, {group, q2_describe_attribute}, {group, q3_describe_class}, {group, q4_describe_instance}, {group, q5_list_instances_of}, - {group, q6_find_path}]. + {group, q6_find_path}, {group, sp2_project_session}]. groups() -> [{skeleton, [], [ @@ -132,7 +154,8 @@ groups() -> q2_includes_parent_and_taxonomy, q2_includes_labels_default_english, q2_not_found_returns_error, - q2_rejects_non_attribute_nref + q2_rejects_non_attribute_nref, + q2_label_not_dropped_under_project_session ]}, {q3_describe_class, [], [ q3_describes_class_with_superclasses, @@ -161,6 +184,14 @@ groups() -> q6_arc_kind_filter, q6_find_path_3_public_api, resume_against_refreshed_session_fails + ]}, + {sp2_project_session, [], [ + new_session_1_binds_a_project, + q_get_node_reads_a_project_instance, + q_get_node_still_reads_environment_when_project_bound, + resolve_home_prefers_project_and_logs_on_collision, + execute_query_2_rejects_bad_session_project, + resume_rejects_bad_session_project ]}]. @@ -498,6 +529,43 @@ q2_rejects_non_attribute_nref(_Config) -> graphdb_query:execute_query( #q_describe{nref = ?NREF_ROOT, labels = default}). +%% SP2 review wave B Fix 1 regression: reproduces the reviewer's exact +%% repro. describe_attribute's own resolve_labels call used to route +%% N/Parent/Children (all attribute nrefs, always environment-resident) +%% through resolve_home/2, so a project-bound session whose project had +%% minted enough instances to collide numerically with a low bootstrap +%% nref (here, 8 = "Relationships") silently dropped that nref's label +%% instead of erroring -- resolve_home/2 found a project instance at key +%% 8, read it as kind=instance, and NAME_ATTR_INSTANCE (20) doesn't match +%% the environment's real Relationships node, so resolve_label missed. +%% +%% Mint 9 project nrefs (a compositional root plus 8 filler instances) so +%% the project's own allocator has assigned nref 8 to one of them -- a +%% genuine key collision with the environment's bootstrap "Relationships" +%% attribute (also nref 8). Then describe a relationship-type attribute +%% whose parent is nref 8 under a project-bound session and confirm BOTH +%% the described attribute's own label AND its parent's (nref 8's) label +%% are present -- pre-fix, nref 8's label was silently missing. +q2_label_not_dropped_under_project_session(_Config) -> + Project = proj(), + Class = widget_class(), + Root = root_instance(Project), + lists:foreach(fun(I) -> + {ok, _, _} = graphdb_instance:create_instance(Project, + "Filler" ++ integer_to_list(I), Class, Root) + end, lists:seq(1, 8)), + {ok, RelType} = graphdb_attr:create_relationship_type("QRelType"), + Session = graphdb_query:new_session(Project), + {ok, R, _Session1} = graphdb_query:execute_query( + #q_describe{nref = RelType, labels = default}, Session), + ?assertEqual(?NREF_RELATIONSHIPS, maps:get(parent, R)), + Labels = maps:get(labels, R), + ?assert(maps:is_key(RelType, Labels)), + %% This is the assertion that failed before the fix: nref 8's label + %% ("Relationships") was silently absent from Labels. + ?assert(maps:is_key(?NREF_RELATIONSHIPS, Labels)), + ?assertEqual("Relationships", maps:get(?NREF_RELATIONSHIPS, Labels)). + %%--------------------------------------------------------------------- %% describe_class %%--------------------------------------------------------------------- @@ -546,12 +614,19 @@ q3_class_not_found(_Config) -> %%--------------------------------------------------------------------- %% describe_instance %%--------------------------------------------------------------------- +%% describe_instance's ephemeral execute_query/1 form binds a project-less +%% session (project => undefined), so resolve_home/2 always resolves a bare +%% nref against the environment table. A project instance nref no longer +%% lives there under SP2 (or worse, numerically collides with an unrelated +%% environment node) -- these q4/q5/q6 tests bind a session to proj() and +%% use the session-threaded execute_query/2 form instead. q4_describes_instance_with_class(_Config) -> {ok, Vehicle} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Taurus, _} = graphdb_instance:create_instance(sess(), - "Taurus", Vehicle, ?NREF_PROJECTS), - {ok, R} = graphdb_query:execute_query( - #q_describe{nref = Taurus, labels = default}), + {ok, Taurus, _} = graphdb_instance:create_instance(proj(), + "Taurus", Vehicle, root()), + Session = graphdb_query:new_session(proj()), + {ok, R, _Session1} = graphdb_query:execute_query( + #q_describe{nref = Taurus, labels = default}, Session), ?assertEqual(instance, maps:get(kind, R)), ?assertEqual([Vehicle], maps:get(classes, R)), ?assert(lists:member(Vehicle, maps:get(class_ancestors, R))). @@ -562,10 +637,11 @@ q4_resolves_inherited_attributes(_Config) -> ok = graphdb_class:add_qualifying_characteristic(Vehicle, WeightA), %% Bind a class-level value (Task 0 adds bind_qc_value/3) ok = graphdb_class:bind_qc_value(Vehicle, WeightA, 3500), - {ok, Taurus, _} = graphdb_instance:create_instance(sess(), - "Taurus", Vehicle, ?NREF_PROJECTS), - {ok, R} = graphdb_query:execute_query( - #q_describe{nref = Taurus, labels = default}), + {ok, Taurus, _} = graphdb_instance:create_instance(proj(), + "Taurus", Vehicle, root()), + Session = graphdb_query:new_session(proj()), + {ok, R, _Session1} = graphdb_query:execute_query( + #q_describe{nref = Taurus, labels = default}, Session), Resolved = maps:get(resolved_attributes, R), Weight = maps:get(WeightA, Resolved), ?assertEqual(3500, maps:get(value, Weight)), @@ -574,17 +650,18 @@ q4_resolves_inherited_attributes(_Config) -> q4_outgoing_and_incoming_connections(_Config) -> {ok, Mfr} = graphdb_class:create_class("Manufacturer", ?NREF_CLASSES), {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Ford, _} = graphdb_instance:create_instance(sess(), - "Ford", Mfr, ?NREF_PROJECTS), - {ok, Tau, _} = graphdb_instance:create_instance(sess(), - "Taurus", Veh, ?NREF_PROJECTS), + {ok, Ford, _} = graphdb_instance:create_instance(proj(), + "Ford", Mfr, root()), + {ok, Tau, _} = graphdb_instance:create_instance(proj(), + "Taurus", Veh, root()), %% create_relationship_attribute/3 atomically creates BOTH directions %% in one call and returns {ok, {FwdNref, RevNref}}. {ok, {MakesA, MadeByA}} = graphdb_attr:create_relationship_attribute_pair( "makes", "made_by", instance), - ok = graphdb_instance:add_relationship(sess(), Ford, MakesA, Tau, MadeByA), - {ok, R} = graphdb_query:execute_query( - #q_describe{nref = Tau, labels = default}), + ok = graphdb_instance:add_relationship(proj(), Ford, MakesA, Tau, MadeByA), + Session = graphdb_query:new_session(proj()), + {ok, R, _Session1} = graphdb_query:execute_query( + #q_describe{nref = Tau, labels = default}, Session), Outgoing = maps:get(outgoing_connections, R), Incoming = maps:get(incoming_connections, R), %% Taurus points at Ford via MadeByA (outgoing). @@ -598,12 +675,13 @@ q4_outgoing_and_incoming_connections(_Config) -> q4_compositional_ancestors(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Car, _} = graphdb_instance:create_instance(sess(), - "Car", Veh, ?NREF_PROJECTS), - {ok, Engine, _} = graphdb_instance:create_instance(sess(), + {ok, Car, _} = graphdb_instance:create_instance(proj(), + "Car", Veh, root()), + {ok, Engine, _} = graphdb_instance:create_instance(proj(), "Engine", Veh, Car), - {ok, R} = graphdb_query:execute_query( - #q_describe{nref = Engine, labels = default}), + Session = graphdb_query:new_session(proj()), + {ok, R, _Session1} = graphdb_query:execute_query( + #q_describe{nref = Engine, labels = default}, Session), ?assertEqual(Car, maps:get(compositional_parent, R)), ?assert(lists:member(Car, maps:get(compositional_ancestors, R))). @@ -615,40 +693,64 @@ q4_instance_not_found(_Config) -> %%--------------------------------------------------------------------- %% list_instances_of %%--------------------------------------------------------------------- +%% #q_instances_of{}'s dispatch routes explicitly through the session's +%% bound Project (graphdb_query.erl's ProjectHome / session_read_arcs_home/5) +%% rather than resolve_home/2's bare-nref guess, since the class->instance +%% membership row (C2I) is always written into the PROJECT's own +%% relationships table regardless of where the class node itself resolves +%% (fixed by commit a419628; formerly a known gap in this suite's own +%% comments here, now stale and removed). q5_lists_direct_instances(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - {ok, Tau, _} = graphdb_instance:create_instance(sess(), - "Taurus", Veh, ?NREF_PROJECTS), - {ok, Acc, _} = graphdb_instance:create_instance(sess(), - "Accord", Veh, ?NREF_PROJECTS), - {ok, Insts} = graphdb_query:execute_query( - #q_instances_of{class = Veh, recursive = false}), + {ok, Tau, _} = graphdb_instance:create_instance(proj(), + "Taurus", Veh, root()), + {ok, Acc, _} = graphdb_instance:create_instance(proj(), + "Accord", Veh, root()), + Session = graphdb_query:new_session(proj()), + {ok, Insts, _Session1} = graphdb_query:execute_query( + #q_instances_of{class = Veh, recursive = false}, Session), ?assert(lists:member(Tau, Insts)), ?assert(lists:member(Acc, Insts)). q5_recursive_includes_subclass_instances(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), {ok, Car} = graphdb_class:create_class("Car", Veh), - {ok, Tau, _} = graphdb_instance:create_instance(sess(), - "Taurus", Car, ?NREF_PROJECTS), - {ok, Insts} = graphdb_query:execute_query( - #q_instances_of{class = Veh, recursive = true}), + {ok, Tau, _} = graphdb_instance:create_instance(proj(), + "Taurus", Car, root()), + Session = graphdb_query:new_session(proj()), + {ok, Insts, _Session1} = graphdb_query:execute_query( + #q_instances_of{class = Veh, recursive = true}, Session), ?assert(lists:member(Tau, Insts)). q5_non_recursive_excludes_subclasses(_Config) -> {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), {ok, Car} = graphdb_class:create_class("Car", Veh), - {ok, Tau, _} = graphdb_instance:create_instance(sess(), - "Taurus", Car, ?NREF_PROJECTS), - {ok, Insts} = graphdb_query:execute_query( - #q_instances_of{class = Veh, recursive = false}), + {ok, Tau, _} = graphdb_instance:create_instance(proj(), + "Taurus", Car, root()), + Session = graphdb_query:new_session(proj()), + {ok, Insts, _Session1} = graphdb_query:execute_query( + #q_instances_of{class = Veh, recursive = false}, Session), ?assertNot(lists:member(Tau, Insts)). +%% SP2 review wave B Fix 3: project-bound, not the project-less +%% execute_query/1 form. Under execute_query/1 the session carries no +%% Project (project => undefined), so #q_instances_of{}'s dispatch takes +%% its ProjectHome =:= undefined branch and reads instantiation arcs from +%% the environment, where NO class ever has project-resident instances -- +%% every class returns [] there, proven or not, so the assertion could +%% never fail. Binding to proj() -- AND populating that same project with +%% an instance of an unrelated class -- makes this test actually exercise +%% "a real, non-empty project still returns [] for a genuinely empty +%% class", matching its q5 siblings. q5_class_with_no_instances(_Config) -> - {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), - ?assertMatch({ok, []}, + {ok, Veh} = graphdb_class:create_class("Vehicle", ?NREF_CLASSES), + {ok, Other} = graphdb_class:create_class("Other", ?NREF_CLASSES), + {ok, _, _} = graphdb_instance:create_instance(proj(), + "Something", Other, root()), + Session = graphdb_query:new_session(proj()), + ?assertMatch({ok, [], _}, graphdb_query:execute_query( - #q_instances_of{class = Veh, recursive = true})). + #q_instances_of{class = Veh, recursive = true}, Session)). %%--------------------------------------------------------------------- %% find_path @@ -705,22 +807,27 @@ q6_resume_continues_from_frontier(_Config) -> q6_arc_kind_filter(_Config) -> %% B (child) -> A (parent) via composition; restricting to taxonomy - %% yields no_path because the path is purely compositional. + %% yields no_path because the path is purely compositional. A/B are + %% project instances, so their compositional arcs live in Project's own + %% relationships table -- bind a session to proj() (resolve_home/2 + %% correctly routes both A and B's arcs there, since A/B are found in + %% Project's own node table). {ok, Cls} = graphdb_class:create_class("Cls", ?NREF_CLASSES), - {ok, A, _} = graphdb_instance:create_instance(sess(), - "A", Cls, ?NREF_PROJECTS), - {ok, B, _} = graphdb_instance:create_instance(sess(), "B", Cls, A), - {ok, [_|_]} = graphdb_query:execute_query( + {ok, A, _} = graphdb_instance:create_instance(proj(), + "A", Cls, root()), + {ok, B, _} = graphdb_instance:create_instance(proj(), "B", Cls, A), + Session = graphdb_query:new_session(proj()), + {ok, [_|_], Session1} = graphdb_query:execute_query( #q_find_path{from = B, to = A, max_depth = 5, - arc_kinds = [composition]}), - ?assertMatch({ok, no_path}, + arc_kinds = [composition]}, Session), + ?assertMatch({ok, no_path, _}, graphdb_query:execute_query( #q_find_path{from = B, to = A, max_depth = 5, - arc_kinds = [taxonomy]})). + arc_kinds = [taxonomy]}, Session1)). q6_find_path_3_public_api(_Config) -> {ok, A} = graphdb_class:create_class("A", ?NREF_CLASSES), @@ -743,20 +850,153 @@ resume_against_refreshed_session_fails(_Config) -> ?assertEqual({error, snapshot_expired}, graphdb_query:resume(Cont, S2)). + +%%===================================================================== +%% SP2 T12 — session binds a Project; resolve_home/2 +%%===================================================================== + +new_session_1_binds_a_project(_Config) -> + Project = proj(), + Session = graphdb_query:new_session(Project), + ?assertEqual(Project, maps:get(project, Session)). + +q_get_node_reads_a_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + Session = graphdb_query:new_session(Project), + {ok, #{nref := Nref, kind := instance}, _Session1} = + graphdb_query:execute_query(#q_get_node{nref = Nref}, Session). + +q_get_node_still_reads_environment_when_project_bound(_Config) -> + Project = proj(), + Session = graphdb_query:new_session(Project), + {ok, #{nref := ?NREF_ROOT, kind := category}, _Session1} = + graphdb_query:execute_query(#q_get_node{nref = ?NREF_ROOT}, Session). + +resolve_home_prefers_project_and_logs_on_collision(_Config) -> + Project = proj(), + %% root_instance/1 seeds a compositional-root instance directly at the + %% project's first allocated nref -- for a freshly-registered project + %% the counter allocator starts at 1 (graphdb_project:next_nref/1), so + %% this instance's nref genuinely collides in KEY (not identity) with + %% the environment's Root category node, which is also nref 1. + %% Bind to whatever root_instance/1 actually returns rather than + %% asserting against the literal 1 -- create_instance/create-order + %% quirks aside, resolve_home/2's contract is about the returned nref, + %% not about a specific integer. + ProjRootNref = root_instance(Project), + Session = graphdb_query:new_session(Project), + {ok, #{nref := FoundNref, kind := instance}, _Session1} = + graphdb_query:execute_query(#q_get_node{nref = ProjRootNref}, Session), + ?assertEqual(ProjRootNref, FoundNref). + +%%----------------------------------------------------------------------------- +%% SP2 review wave B Fix 2 regression -- reproduces the reviewer's "bogus +%% session" transcript. new_session/1 stores its Project argument +%% unvalidated; before this fix, feeding that session straight into +%% execute_query/2 (or resume/2) reached resolve_home/2's +%% mnesia:dirty_read(graphdb_ns:node_table(Project), _) INSIDE this +%% gen_server's own handle_call (dispatch/2 runs synchronously, not via a +%% nested call), and graphdb_ns:node_table/1's bare two-clause match +%% crashed the graphdb_query singleton itself. validate_session_home/1 +%% now gates both entry points on the caller side, before the +%% gen_server:call, returning a clean {error, invalid_project} instead. +%% The empirical proof: the worker's registered pid is unchanged +%% before/after (this suite starts graphdb_query directly, not under a +%% supervisor, so a crash would unregister the name rather than restart +%% it -- either divergence from the pre-call pid proves the crash). +%%----------------------------------------------------------------------------- +execute_query_2_rejects_bad_session_project(_Config) -> + PidBefore = whereis(graphdb_query), + ?assert(is_pid(PidBefore)), + Session = graphdb_query:new_session(not_a_project), + ?assertEqual({error, invalid_project}, + graphdb_query:execute_query( + #q_get_node{nref = ?NREF_ROOT}, Session)), + ?assertEqual(PidBefore, whereis(graphdb_query)). + +resume_rejects_bad_session_project(_Config) -> + {ok, A} = graphdb_class:create_class("A", ?NREF_CLASSES), + {ok, B} = graphdb_class:create_class("B", A), + {ok, C} = graphdb_class:create_class("C", B), + {ok, _D} = graphdb_class:create_class("D", C), + Q = #q_find_path{from = C, to = A, max_depth = 1, + arc_kinds = [taxonomy]}, + S0 = graphdb_query:new_session(), + {partial, _, Cont, S1} = graphdb_query:execute_query(Q, S0), + %% Corrupt the already-valid, already-in-flight session's Home after + %% the fact -- resume/2 must re-validate, not trust a session just + %% because execute_query/2 accepted it earlier. + BadSession = S1#{project => not_a_project}, + PidBefore = whereis(graphdb_query), + ?assertEqual({error, invalid_project}, + graphdb_query:resume(Cont, BadSession)), + ?assertEqual(PidBefore, whereis(graphdb_query)). + + +%%--------------------------------------------------------------------- +%% proj() -> Project +%% +%% SP2 test helper: returns a project handle, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens it; subsequent calls in the same process reuse it. +%%--------------------------------------------------------------------- +proj() -> + case get(sp2_project) of + undefined -> + {ok, P} = graphdb_project:register_project("SP2 test project"), + {ok, Project} = graphdb_project:open(P), + put(sp2_project, Project), + Project; + Project -> + Project + end. + +%%--------------------------------------------------------------------- +%% root() -> Nref +%% +%% SP2 test helper: returns a shared compositional-root instance nref for +%% proj(), memoised per test-case process (mirrors proj()'s own memo +%% pattern) so every create_instance/4 call in one test case that used +%% to pass the old single-store stand-in parent (bare 5 / ?NREF_PROJECTS +%% -- an environment category nref that happened to always exist in the +%% pre-SP2 shared table) shares the SAME project-local parent. Seeds via +%% root_instance/1 on first use. +%%--------------------------------------------------------------------- +root() -> + case get(sp2_root) of + undefined -> + Nref = root_instance(proj()), + put(sp2_root, Nref), + Nref; + Nref -> + Nref + end. + +%%--------------------------------------------------------------------- +%% widget_class() -> ClassNref +%% +%% Throwaway environment-scoped class for the SP2 T12 session tests. +%% Classes live in the environment regardless of which project +%% instantiates them. +%%--------------------------------------------------------------------- +widget_class() -> + {ok, ClassNref} = graphdb_class:create_class("T12Widget", 3), + ClassNref. + %%--------------------------------------------------------------------- -%% sess() -> Session +%% root_instance(Project) -> Nref %% -%% SP1 test helper: returns a project session, memoised per test-case -%% process. Registers a project under Projects (nref 5) on first use and -%% opens a session against it; subsequent calls in the same process reuse it. +%% Seeds a throwaway compositional-root instance directly into Project's +%% own (initially empty) nodes table, bypassing create_instance's parent +%% validation (do_validate_parent/3 requires the parent to already exist, +%% and a fresh project store has nothing yet to point at). For a freshly +%% registered project, next_nref/1's first call returns 1 -- callers must +%% never assume this and should bind/assert on the returned Nref. %%--------------------------------------------------------------------- -sess() -> - case get(sp1_session) of - undefined -> - {ok, P} = graphdb_project:register_project("SP1 test session"), - {ok, S} = graphdb_project:open_session(P), - put(sp1_session, S), - S; - S -> - S - end. +root_instance(Project) -> + Nref = graphdb_project:next_nref(Project), + Node = #node{nref = Nref, kind = instance, attribute_value_pairs = []}, + ok = mnesia:dirty_write(graphdb_ns:node_table(Project), Node), + Nref. diff --git a/apps/graphdb/test/graphdb_rules_SUITE.erl b/apps/graphdb/test/graphdb_rules_SUITE.erl index ede90cd..5f874b0 100644 --- a/apps/graphdb/test/graphdb_rules_SUITE.erl +++ b/apps/graphdb/test/graphdb_rules_SUITE.erl @@ -42,6 +42,17 @@ avps }). +%%--------------------------------------------------------------------- +%% Dummy project-scope fixture -- a synthetic but realistically-shaped +%% Project handle (see graphdb_project:register_project/1), used by the +%% {project, _} stub-path scope tests below. graphdb_rules does not +%% dereference any of these fields; the stub path matches on the +%% {project, _} wildcard, so the shape only needs to read as "a project +%% scope" rather than an arbitrary term. +%%--------------------------------------------------------------------- +-define(DUMMY_PROJECT, #{anchor => 1, nodes => nodes_1, + rels => relationships_1, counters => counters_1}). + %%--------------------------------------------------------------------- %% Common Test callbacks @@ -955,22 +966,22 @@ project_scope_rejected_on_create(_Config) -> Child = make_class("Engine"), ?assertEqual({error, project_rules_not_yet_supported}, graphdb_rules:create_composition_rule( - {project, 1}, "x", Parent, Child, mandatory, {1, 1})), + {project, ?DUMMY_PROJECT}, "x", Parent, Child, mandatory, {1, 1})), Source = make_class("Order"), Target = make_class("Customer"), {Char, Recip} = make_rel_pair("placed_by", "placed"), ?assertEqual({error, project_rules_not_yet_supported}, graphdb_rules:create_connection_rule( - {project, 1}, "x", Source, Char, Recip, Target, mandatory, {1, 1})). + {project, ?DUMMY_PROJECT}, "x", Source, Char, Recip, Target, mandatory, {1, 1})). project_scope_returns_empty_on_retrieve(_Config) -> Car = make_class("Car"), ?assertEqual({ok, []}, - graphdb_rules:rules_for_class({project, 1}, Car)), + graphdb_rules:rules_for_class({project, ?DUMMY_PROJECT}, Car)), ?assertEqual({ok, []}, - graphdb_rules:composition_rules_for_class({project, 1}, Car)), - ?assertEqual({ok, []}, graphdb_rules:list_rules({project, 1})), - ?assertEqual(not_found, graphdb_rules:get_rule({project, 1}, 999999)). + graphdb_rules:composition_rules_for_class({project, ?DUMMY_PROJECT}, Car)), + ?assertEqual({ok, []}, graphdb_rules:list_rules({project, ?DUMMY_PROJECT})), + ?assertEqual(not_found, graphdb_rules:get_rule({project, ?DUMMY_PROJECT}, 999999)). %%============================================================================= @@ -1205,7 +1216,7 @@ mixed_kinds_returned(_Config) -> project_scope_empty(_Config) -> Car = make_class("Car"), ?assertEqual({ok, []}, - graphdb_rules:effective_rules_for_class({project, 1}, Car)). + graphdb_rules:effective_rules_for_class({project, ?DUMMY_PROJECT}, Car)). unknown_class_empty(_Config) -> %% Non-existent nref: ancestors/1 -> {error, not_found}, mapped to []. @@ -1246,7 +1257,7 @@ effective_connection_rules_excludes_composition(_Config) -> effective_connection_rules_project_scope_empty(_Config) -> Source = make_class("Car"), ?assertEqual({ok, []}, - graphdb_rules:effective_connection_rules({project, p1}, Source)). + graphdb_rules:effective_connection_rules({project, ?DUMMY_PROJECT}, Source)). %%============================================================================= @@ -1363,7 +1374,7 @@ plan_project_scope_is_leaf(Config) -> {ok, _} = graphdb_rules:create_composition_rule( environment, "OB", Owner, Bolt, mandatory, {1, 1}), {ok, #{class := Owner, mandatory_children := [], auto_rules := []}} = - graphdb_rules:plan_composition_firing({project, p1}, Owner). + graphdb_rules:plan_composition_firing({project, ?DUMMY_PROJECT}, Owner). %%============================================================================= diff --git a/docs/Architecture.md b/docs/Architecture.md index d6fd33c..9318d16 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -21,14 +21,15 @@ SPDX-License-Identifier: GPL-2.0-or-later | `nref` subsystem | Fully implemented; backed by DETS (Disk-based Erlang Term Storage); `set_floor/1` API | | `dictionary_imp` | Implemented; not yet wired to `dictionary_server` / `term_server` | | `graphdb_bootstrap` | Implemented — Mnesia schema, table creation, scaffold loader | -| `graphdb_mgr` | Implemented — bootstrap startup, read API, category guard, cache audit/repair; `retire_node/1` / `unretire_node/1` soft-retire runtime nodes via a boolean `retired` marker AVP; public `get_node/1` returns `{error, retired}` for retired nodes; `delete_node/1` remains unimplemented, reserved for a future hard delete. | +| `graphdb_ns` / `graphdb_project` | Implemented (SP1+SP2) — home-relative namespace routing (`graphdb_ns`) and project registry + physical per-project store (`graphdb_project`); see §6. | +| `graphdb_mgr` | Implemented — bootstrap startup, read API, category guard, cache audit/repair; `retire_node/1` / `unretire_node/1` soft-retire runtime nodes via a boolean `retired` marker AVP; public `get_node/1` returns `{error, retired}` for retired nodes; `delete_node/1` remains unimplemented, reserved for a future hard delete. SP2 adds Project-taking twins `get_node/2`, `retire_node/2`, `unretire_node/2`, `update_node_avps/3`, `delete_node/2`, `mutate/2`, gated by `with_project/2`; the `/1` (and `/2` for `update_node_avps`) forms stay environment-only. | | `graphdb_attr` | Implemented — attribute library (name, literal, relationship attributes); seeds the `retired` lifecycle marker literal-attribute | | `graphdb_class` | Implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph); abstract (non-instantiable) classes via the `instantiable` marker | -| `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; refuses retired nodes as new instance targets, compositional parents, and arc endpoints; fires composition rules on `create_instance/3` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/4`; applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | +| `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; refuses retired nodes as new instance targets, compositional parents, and arc endpoints; fires composition rules on `create_instance/4` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/5`; applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/6`. SP2: every public function, including the instance reads, takes a leading `Project` handle — fully Project-routed (§6) | | `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing, horizontal conflict precedence | | `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | -| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS | -| Tests | 537 passing (432 Common Test + 105 EUnit) | +| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS. SP2: `new_session/1` binds a `Project`; bare-nref reads resolve `Home` via `resolve_home/2`. | +| Tests | 677 passing (532 Common Test + 145 EUnit) | The kernel is functional under multi-inheritance, multi-class- membership, and per-class template semantics. Multilingual label @@ -54,19 +55,40 @@ implemented; hard delete (`delete_node/1`) remains reserved. ### Mnesia tables ``` -nodes — one row per concept node (primary key: nref) -relationships — one row per directed arc (primary key: id) +nodes — one row per environment concept node (primary key: nref) +relationships — one row per environment directed arc (primary key: id) ``` -Two tables cover the entire graph. Bidirectional logical edges are stored -as two directed rows in `relationships`, written atomically. +The environment (ontology) tables above are the single shared store for +categories, attributes, classes, languages, templates, and rules. +Bidirectional logical edges are stored as two directed rows in +`relationships`, written atomically. + +Each **registered project** (SP2) owns its own physical table set, created +by `graphdb_project:register_project/1`: + +``` +nodes_ — one row per project concept node (primary key: nref) +relationships_ — one row per project directed arc (primary key: id) +counters_ — project-local nref/rel-id allocator (dirty counters) +``` + +`` is the project's environment anchor nref (a child of `Projects`, +nref 5). A project's node and relationship nrefs are allocated from **1** +via `mnesia:dirty_update_counter/3` on `counters_` — independent of, +and numerically overlapping with, both the environment's nref space and +every other project's. This is safe only because every read/write is routed +to a specific table by `Home :: environment | Project` (see §6); no query +crosses tables without going through that routing. See §6 for the +per-project allocator and the home-relative field-routing rule. **Indexes:** -- `relationships` — secondary on `source_nref` and `target_nref` for O(1) - forward and reverse traversal. -- `nodes` carries no secondary index. Downward queries ("children of X") - read outgoing arcs from `relationships` filtered by kind + - characterization (see §3 cache invariant). +- `relationships` (and each project's `relationships_`) — secondary + on `source_nref` and `target_nref` for O(1) forward and reverse traversal. +- `nodes` (and each project's `nodes_`) carries no secondary index. + Downward queries ("children of X") read outgoing arcs from the same-table + relationships, filtered by kind + characterization (see §3 cache + invariant). Embedding relationships inside the node record (Dallas's original DETS design) is rejected: it makes reverse-lookup an O(N) full-scan and @@ -211,12 +233,12 @@ Every `kind = connection` arc carries a `Template` AVP — `#{attribute semantic context. The AVP attribute is bootstrap-seeded at nref 31; it is forbidden on relationships of any other kind. Template nodes are compositional children of class nodes (see §3 cache field -sources). API: `graphdb_instance:add_relationship/5,6,7` (session-first — SP1). +sources). API: `graphdb_instance:add_relationship/5,6,7` (Project-first — SP1/SP2). Connection edges are mutated through `graphdb_instance` (connection-arcs -only — these never touch the `parents`/`classes` caches; all take a project -`Session` first arg — SP1 — and reject an invalid one with -`{error, invalid_session}`): `remove_relationship/4,5` deletes **both** +only — these never touch the `parents`/`classes` caches; all take a +`Project` handle first arg — SP1/SP2 — and reject an invalid one with +`{error, invalid_project}`): `remove_relationship/4,5` deletes **both** directed rows of a logical edge atomically; `update_relationship/5,6` and `update_relationship_both/5,6` edit the per-direction AVP metadata, reusing the slice-B AVP merge grammar. Remove @@ -277,7 +299,8 @@ maintains. `graphdb_mgr` is the public entry point and routes to the workers — read path and soft-retire implemented; remaining write-side routing is pending (see [`../TASKS.md`](../TASKS.md)). -The tier-3 batch entry point `graphdb_mgr:mutate/1` applies an ordered list +The tier-3 batch entry point `graphdb_mgr:mutate/1` (environment) / +`mutate/2` (a `Project`, SP2) applies an ordered list of `add_relationship` / `retire_node` / `unretire_node` / `update_node_avps` / `remove_relationship` / `update_relationship` / `update_relationship_both` mutations atomically in one transaction, composing the tier-1 primitives @@ -316,14 +339,18 @@ exist on the same node, each with its own Mnesia schema. ### Cross-database nref resolution -Nrefs are plain `integer()`s with no embedded database tag. Context -determines routing: +Nrefs are plain `integer()`s with no embedded database tag, and are **not** +globally unique — every project's allocator restarts at 1 (§7), so a +project nref routinely coincides numerically with an environment nref or +another project's nref. Routing is **home-relative**: every read/write +carries (explicitly or via context) a `Home :: environment | Project`, and +that `Home` — not the nref's numeric value — selects the physical table. -| Relationship field | Resolves to | -| -------------------------------- | ------------------------------------------- | -| `source_nref` | Same database as the relationship row | -| `characterization`, `reciprocal` | Always the ontology | -| `target_nref` | Routed by the arc label's `target_kind` AVP | +| Relationship field | Resolves to | +| ---------------------------------- | ---------------------------------------------------------------- | +| `source_nref` | `Home` — the store the containing row was read from/written to | +| `characterization`, `reciprocal` | Always the environment | +| `target_nref` | Routed by the arc label's `target_kind` AVP: `category`/`attribute`/`class` → always the environment; `instance` → `Home` | `target_kind :: category | attribute | class | instance` is stored as a literal AVP on every arc-label attribute node. Built-in arc labels @@ -331,9 +358,11 @@ literal AVP on every arc-label attribute node. Built-in arc labels requires it for runtime additions. This routing table is the code contract of the pure module `graphdb_ns` -(`namespace_of/1`, `target_namespace/1` → `environment | project | home`) — -see the SP1 model below. Against today's single store it is behaviour- -preserving; SP2 gives it physical teeth. +(`namespace_of/2`, `target_namespace/2` — both `Home`-first — plus +`node_table/1` / `rel_table/1`, which resolve a `Home` to its physical +Mnesia table atom) — see the SP1/SP2 model below. `environment` resolves to +the literal `nodes`/`relationships` tables; a `Project` handle carries its +own `nodes_`/`relationships_` atoms directly. Every `graphdb_attr` creator takes an explicit, validated `ParentNref` (must name an existing `kind=attribute` node); the named functions @@ -351,49 +380,69 @@ project's permanent cross-system identity token — used to scope project-side language overlay tables and as the stable reference point for cross-database arcs. -Projects may be **remote**: all project-side Mnesia tables (`nodes`, -`relationships`, per-language overlays) reside on the project's own -node, which may differ from the environment node. Mnesia handles -transparent remote access within a cluster; fully independent remote -projects are a future distribution concern. +SP2 puts every project's `nodes_` / `relationships_` / +`counters_` tables on the same Mnesia node as the environment. +Projects may become **remote** in the future (SP3): the project-side +tables (plus per-language overlays) would reside on the project's own +node, which may differ from the environment's. Mnesia handles transparent +remote access within a cluster; fully independent remote projects are a +future distribution concern. Visibility of the anchor node is governed by ACL AVPs on that node (not yet implemented). Globally visible projects have no access restriction; owner-specific projects have a permissioned ACL. The node always exists regardless of its visibility. -### Reference & namespace model (SP1) +### Reference & namespace model (SP1+SP2) The environment/project separation is a four-sub-project program -(design: `designs/project-env-reference-namespace-model-design.md`; tracking: -`../TASKS.md` → *Multi-project sessions*). **SP1 is implemented at the API/code -layer only — no `node`/`relationship` record changes:** +(design: `designs/project-env-reference-namespace-model-design.md`, amended +for SP2 by `designs/sp2-physical-project-store-design.md`; tracking: +`../TASKS.md` → *Multi-project sessions*). SP1 (reference & namespace model) +and SP2 (physical project store) are both implemented: - **`graphdb_ns`** — pure namespace-resolution module encoding the routing - table above; every nref field resolves to `environment | project | home`. -- **`graphdb_project`** — project registry (`register_project/1`, - `is_project/1`) creating the nref-5 anchor, plus the project **session** - (`open_session/1`, `session_project/1`, `require_session/1`) and the - canonical project-scoped relationship API surface. A session is an opaque - value threaded as data — the workers are shared singletons, so project - context cannot be ambient. -- **Required session on the project write path** — `create_instance`, + table above; `namespace_of/2` / `target_namespace/2` take a leading `Home` + and resolve every nref field to `environment | Home`; `node_table/1` / + `rel_table/1` map a `Home` to its physical table atom. +- **`graphdb_project`** — project registry and physical store. + `register_project/1` creates the nref-5-child anchor node **and** the + project's three Mnesia tables (`nodes_`, `relationships_`, + `counters_`). `open/1` resolves a registered project nref into a + `Project` handle `#{anchor, nodes, rels, counters}`; `require_project/1` + validates that handle. `next_nref/1` / `next_rel_id_pair/1` are the + project-local allocators (`mnesia:dirty_update_counter/3` on + `counters_`, starting at 1) — plus the canonical project-scoped + relationship API surface. A `Project` handle is an opaque value threaded + as data — the workers are shared singletons, so project context cannot be + ambient. +- **Project write path takes `Project`** — `create_instance`, `add_relationship`, `remove_relationship`, `update_relationship`(`_both`), - and `add_class_membership` take a `Session` first arg and reject a missing/ - invalid one with `{error, invalid_session}`. + and `add_class_membership` take a `Project` handle first arg and reject a + missing/invalid one with `{error, invalid_project}`. As of SP2 the + instance reads (`get_instance` / `children` / `compositional_ancestors` / + `class_of` / `class_memberships` / `resolve_value`) take a leading + `Project` too — the whole of `graphdb_instance` is Project-routed, no + longer namespace-agnostic. +- **`graphdb_mgr` Project-taking twins (SP2)** — `get_node/2`, + `retire_node/2`, `unretire_node/2`, `update_node_avps/3`, `delete_node/2`, + and `mutate/2` route through a private `with_project/2` gate; the `/1` + forms (and `/2` for `update_node_avps`) stay environment-only. `mutate/1` + in particular stays environment-only by design — a batch mixing + environment and project mutations has no single physical table set to run + against once the store is split. `get_relationships` has no + Project-taking twin yet (tracked in `../TASKS.md`). +- **`graphdb_query` sessions bind a `Project` (SP2)** — `new_session/1`; + bare-nref reads resolve `Home` per nref via `resolve_home/2`, trying the + bound project first and falling back to the environment. - **Proxy contract** — a cross-project link is a local node of the seeded "Remote Reference" class carrying `remote_project` / `remote_nref` AVP payload; no structural reference crosses a project boundary. Recognized by - `graphdb_instance:is_proxy/1` / `proxy_coordinates/1`. Representation only; - creation/dereference are SP2/SP3. -- **Namespace-agnostic in SP1** — `mutate/1` and the instance reads - (`get_instance` / `children` / `compositional_ancestors` / `resolve_value`), - like `get_node` / `get_relationships`, are not session-gated: `mutate/1` is a - mixed env/project batch, and the reads are consumed by `graphdb_query`. - Their per-namespace routing lands in SP2. + `graphdb_instance:is_proxy/1` / `proxy_coordinates/1`. Representation + only; creation/dereference remain SP3 work. -SP2 (physical per-project store + allocator-from-1), SP3 (distribution / -residency + proxy dereference), and SP4 (migration) remain. +SP3 (distribution / residency + proxy dereference) and SP4 (migration) +remain. --- @@ -436,11 +485,15 @@ permanent tier has roughly 990 000 free slots — spill-over is not expected. ### Project allocators -Per-project; start at **1**; no bootstrap floor. The project allocator -layer is not yet implemented — when added, the simplest design mirrors -the ontology allocator with a per-project DETS file. Numerical nref -overlap with the ontology is not a problem because every lookup is -routed to a specific database (see §6 cross-database resolution). +Per-project; start at **1**; no bootstrap floor. Implemented (SP2) as an +in-store Mnesia counter, not a DETS file: `graphdb_project:next_nref/1` / +`next_rel_id_pair/1` call `mnesia:dirty_update_counter/3` against the +project's own `counters_` table (§2), which creates the counter on +first use with the increment as its value — no explicit seeding needed at +`register_project/1` time. Numerical nref overlap with the environment (and +with every other project) is not a problem because every lookup is routed +to a specific table by `Home` (see §6 cross-database resolution), never by +nref value alone. --- diff --git a/docs/designs/project-env-reference-namespace-model-design.md b/docs/designs/project-env-reference-namespace-model-design.md index 3e7993b..d02e57a 100644 --- a/docs/designs/project-env-reference-namespace-model-design.md +++ b/docs/designs/project-env-reference-namespace-model-design.md @@ -74,26 +74,80 @@ correct from day one. Every structural (graph-traversable) nref field resolves to exactly one of `{environment, current-project}`. The namespace is determined by the field's -**role**, plus the arc-label's `target_kind` for the single polymorphic field -(`target_nref`). +**role**, **relative to the home store of the record being read**, plus the +arc label's `target_kind` for the two polymorphic endpoint fields +(`source_nref`, `target_nref`). + +### Home-relative routing — amendment (2026-08-02, SP2 brainstorm) + +The original map routed `target_nref` by `target_kind` alone and `source_nref` +to "the row's home DB". Both are wrong in cases that only become visible once +project tables physically exist: + +- **`kind = instance` does not imply project residency.** Three instance nodes + live in the *environment*: the project anchor under `Projects` (nref 5), + language instance nodes under `Languages` (nref 4), and rule instance nodes + (instances of the rule meta-classes). Routing `instance → project-local` + unconditionally misroutes all three. +- **`source_nref` is not always the row's home.** The class→instance membership + row (`characterization = 30`) is written in the **project** table, but its + `source_nref` is an environment class nref. Under a per-project allocator + from 1 this is a live collision hazard: an index read on `source_nref` can + match a project instance and an environment class sharing the same integer. + +The corrected rule — **each endpoint field routes by the arc label pointing at +it, interpreted relative to the row's home store**: + +```erlang +namespace_of(target_nref) = target_namespace(Home, target_kind(Characterization)) +namespace_of(source_nref) = target_namespace(Home, target_kind(Reciprocal)) + +%% environment home => every reference resolves environment +%% project home => instance => that project +%% category | attribute | class => environment +``` + +`characterization` and `reciprocal` remain environment-always, unchanged. +Every relationship writer populates `reciprocal` (no row ships with it +`undefined`), so deriving `source_nref`'s namespace from it is total. + +Checked against every arc shape in the system: + +| Row shape | `source_nref` | `target_nref` | +| ---------------------------------------------------- | ------------- | ------------- | +| membership `src=Inst, char=29, tgt=Class, recip=30` | project | environment | +| membership `src=Class, char=30, tgt=Inst, recip=29` | environment | project | +| composition `src=Inst, char=28, tgt=Inst, recip=27` | project | project | +| any environment-home row (anchor, language, rule, …) | environment | environment | + +The same correction applies to `node.parents`: compositional parents are +project-local **only in a project-home node**. The environment-resident project +anchor's compositional parent is `Projects` (nref 5), an environment nref. + +**Residency and interpretation are one rule seen from two sides:** *residency +follows the API path* (a write carrying a `Project` handle lands in that +project's tables; an environment op lands in the environment tables), and +*interpretation follows the row's home* (when reading a record, its references +resolve relative to the store it came from). Neither side requires a record +change or a new `kind` atom. ### Field-role namespace map -| Field | Namespace | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | -| `node.nref` | the node's own DB — environment node → environment; instance → project-local | -| `node.classes` | environment (instances point at environment class nodes) | -| `node.parents` — compositional | project-local (instance part-of instance) | -| `node.parents` — taxonomy | environment (class / attribute is-a) | -| `relationship.characterization` | environment (arc label) — always | -| `relationship.reciprocal` | environment (arc label) — always | -| `relationship.target_nref` | **routed** by the arc-label's `target_kind`: `instance` → project-local; `category`/`attribute`/`class` → environment | -| `relationship.source_nref` | the row's home DB | -| AVP `attribute` keys | environment — always | -| AVP values that are nrefs (e.g. template, `reciprocal_nref`) | environment, per the attribute's definition | - -This table is authoritative. The resolution seam (§7) is the code expression -of it. +| Field | Namespace | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `node.nref` | the store it was read from — environment table → environment; project table → that project | +| `node.classes` | environment — always (instances point at environment class nodes) | +| `node.parents` — compositional | home-relative: project-home → that project; environment-home → environment | +| `node.parents` — taxonomy | environment (class / attribute is-a) | +| `relationship.characterization` | environment (arc label) — always | +| `relationship.reciprocal` | environment (arc label) — always | +| `relationship.target_nref` | `target_namespace(Home, target_kind(Characterization))` — see the amendment above | +| `relationship.source_nref` | `target_namespace(Home, target_kind(Reciprocal))` — see the amendment above | +| AVP `attribute` keys | environment — always | +| AVP values that are nrefs (e.g. template, `reciprocal_nref`) | environment, per the attribute's definition | + +This table is authoritative. The resolution seam (§7) is the code expression of +it; `graphdb_ns` gains the home-store parameter in SP2. ## 4. Cross-project links are indirected, never structural diff --git a/docs/designs/sp2-physical-project-store-design.md b/docs/designs/sp2-physical-project-store-design.md new file mode 100644 index 0000000..3a5eae0 --- /dev/null +++ b/docs/designs/sp2-physical-project-store-design.md @@ -0,0 +1,369 @@ +> **Note (2026-08-02).** `Session` in this document is used only for the +> *deferred* higher-level concept described in §8. The parameter that SP1 +> shipped under the name `Session` is renamed `Project` by this sub-project — +> see §5. + +# Project/Environment Separation — SP2: Physical Project Store + +**Status:** Design (approved for planning) +**Date:** 2026-08-02 +**Scope:** Sub-project 2 of the project/environment separation program. +**Parent:** `docs/designs/project-env-reference-namespace-model-design.md` + +## 1. Context + +SP1 established, at the API/code layer only, that every nref reference has a +derived namespace, that project write operations carry a session, and that +cross-project links indirect through local proxy nodes. It was deliberately +behaviour-preserving: the namespace contract became correct, but every node and +relationship still lived in one shared `nodes` / `relationships` pair, and every +instance still allocated from the environment's runtime tier +(`?NREF_START` = 1000000). + +SP2 gives that contract physical teeth. Each registered project gets its own +table set and its own nref space **starting at 1**. The environment keeps the +shared pair unchanged. + +### Position in the program + +| # | Sub-project | Status | +| --- | --------------------------- | --------------------------------------------- | +| 1 | Reference & namespace model | Implemented (PR #52) | +| 2 | **Physical project store** | **This spec** | +| 3 | Distribution & residency | Projects on separate nodes; proxy dereference | +| 4 | Migration | Move existing instances into project storage | + +SP2 is confined to **a single Erlang/Mnesia node**. Standing up separate +schemas, fragmentation, or cross-node placement now would front-load SP3's +hardest concerns without the distribution requirements that justify them. + +## 2. Goal + +Make a project's instance space physically separate and independently +numbered, so that: + +- the nref-collision correctness defect is closed by construction — project A's + instance `5`, project B's instance `5`, and environment `5` (Root) are three + distinct records in three distinct tables; +- the SP1 resolution seam performs real environment-vs-project routing; +- the project handle binds to physical storage; +- a project's storage is a **single relocatable unit** (tables plus its own + allocator), which is precisely what SP3 needs to move. + +## 3. Storage layout + +| Store | Tables | Allocation | +| ------------- | ------------------------------------------------ | -------------------------------------------- | +| Environment | `nodes`, `relationships` | `graphdb_nref` + `rel_id_server` — unchanged | +| Project `` | `nodes_`, `relationships_`, `counters_` | in-store counters, from 1 | + +`` is the project's **environment anchor nref** — the `kind = instance` node +under `Projects` (bootstrap nref 5) created by `register_project/1`. It is +already unique and already persistent, so project storage needs no new identity +scheme. + +`relationships_` carries the same secondary indexes as the environment's +table (`source_nref`, `target_nref`), preserving O(1) forward and reverse +traversal within a project. + +All three tables are `disc_copies`, matching the environment. + +### Why per-project tables rather than a discriminator column + +Adding a project column to the shared tables would leave the primary key +globally scoped, which is the defect SP2 exists to close: allocator-from-1 is +only safe when the key space is physically partitioned. Separate tables also +make a project's storage a unit SP3 can relocate or replicate on its own. + +## 4. Identity and allocation + +`counters_` holds two keys, `nref` and `rel_id`, each bumped with +`mnesia:dirty_update_counter/3`. + +The counter lives **in the project's own store** rather than in a process or a +shared allocator table. Three consequences, all intentional: + +1. **The allocator travels with the store.** SP3 relocates a project by moving + its tables; the allocator comes along with no separate migration step. +2. **No new process.** A central allocator gen_server keyed by project would be + one more shared singleton that SP3 would have to split apart again. +3. **No gen_server call on the write path.** This is load-bearing. The project + write path currently calls `graphdb_nref:get_next/0` and + `rel_id_server:get_id_pair/0` — both gen_server calls, both of which must be + made *outside* any Mnesia transaction fun. Replacing them with a dirty + counter update **removes** two gen_server calls from the project write path. + +Allocation still happens **outside** the transaction fun. +`mnesia:dirty_update_counter/3` is a dirty operation that does not participate +in the surrounding transaction, so calling it inside a transaction that Mnesia +restarts would burn ids for no benefit. Ids orphaned by an aborted transaction +remain the accepted tradeoff, unchanged from today and harmless given an +unbounded monotonic space. + +**First allocation yields 1.** `mnesia:dirty_update_counter/3` on a key that has +never been written creates it with the increment as its value, so the first +`dirty_update_counter(counters_, nref, 1)` returns `1`. `register_project/1` +therefore does **not** need to seed the counter keys, and the first instance in +a project is nref 1 — not 0, and not 2. + +The environment's own allocation is untouched: `graphdb_nref` and +`rel_id_server` continue to serve environment nodes and environment +relationship rows, including the environment-resident instances listed in §6. + +## 5. The `Project` handle + +### Naming correction + +SP1 shipped a `Session` first argument on the project write path. That value +was in fact a *project handle* wearing the wrong name. SP2 renames it +`Project` across the project-scoped API. Because the SP1 value was inert +(validated for shape, never used to route), the rename carries no behavioural +risk. + +`Session` is reclaimed for the genuine higher-level concept — see §8. + +### Shape and lifecycle + +```erlang +graphdb_project:open(ProjectNref) -> + {ok, Project} | {error, not_a_project} | {error, no_store}. +``` + +`Project` is an **opaque plain value** carrying the resolved table names: + +```erlang +#{anchor => Nref, + nodes => nodes_N, + rels => relationships_N, + counters => counters_N} +``` + +Plain value, resolved once at `open/1`, threaded as data — consistent with the +transaction seam, which runs in the caller's process. Resolution is **pure**, +so dereferencing the handle inside a transaction fun involves no gen_server +call and cannot deadlock. This is the same discipline SP1's session followed, +now made load-bearing. + +`register_project(Name)` creates the anchor node in the environment **and** +creates the three tables. `mnesia:create_table/2` is a schema operation and +cannot run inside a transaction, so table creation happens outside the anchor +write; registration is therefore not atomic end-to-end and must be idempotent +on re-run (create-if-absent for both anchor and tables). + +Because the tables are `disc_copies`, they survive restart: `open/1` works on a +later boot with no re-registration. + +`open/1` returns `{error, no_store}` for an anchor that exists without tables — +the state a project registered before SP2 would be in. SP4's migration is what +resolves that state; SP2 reports it rather than silently creating an empty +store. + +`is_project/1` is unchanged: membership under `Projects` (nref 5). + +## 6. Routing + +The authoritative rule is the **home-relative** map in the parent design's §3 +(amended 2026-08-02 alongside this spec). SP2 is where it becomes executable. + +`graphdb_ns` gains the home-store parameter: + +```erlang +target_namespace(Home, TargetKind) + +namespace_of(target_nref) = target_namespace(Home, target_kind(Characterization)) +namespace_of(source_nref) = target_namespace(Home, target_kind(Reciprocal)) +``` + +This is where `graphdb_ns` stops being the intentionally-unused classifier SP1 +shipped and becomes the live router. + +**The arity-1 forms are replaced, not kept alongside.** `namespace_of/1` and +`target_namespace/1` become `namespace_of/2` and `target_namespace/2`, both +taking the home store as the first argument. `graphdb_ns` has zero production +callers today (SP1 shipped it as a tested classifier ahead of its consumer), so +replacement costs nothing and avoids leaving a subtly-wrong arity-1 form in the +tree for someone to call. The six EUnit tests written against the arity-1 forms +are rewritten table-driven across both home values, which also gives the +amendment in the parent design's §3 direct test coverage. + +### Environment-resident instances + +`kind = instance` does **not** imply project residency. Three instance node +populations live in the environment and stay there in SP2: + +| Population | Location | +| ----------------------- | ---------------------------------- | +| Project anchor nodes | under `Projects` (nref 5) | +| Language instance nodes | under `Languages` (nref 4) | +| Rule instance nodes | instances of the rule meta-classes | + +The home-relative rule handles these correctly without special-casing: they +live in environment rows, so their references resolve environment. + +### Threading boundary + +Environment-only modules keep the **literal** table names `nodes` and +`relationships`: + +| Module | Sites | Scope | +| ------------------- | ----- | ----------------------------------------- | +| `graphdb_class` | 26 | environment only | +| `graphdb_rules` | 23 | environment only (rule instances are env) | +| `graphdb_attr` | 20 | environment only | +| `graphdb_language` | 16 | environment only | +| `graphdb_bootstrap` | 3 | environment only | +| `graphdb_project` | 3 | environment only (anchor writes) | +| `graphdb_nrefs` | 1 | environment only | +| `graphdb_instance` | 25 | **project** | +| `graphdb_mgr` | 13 | **mixed** | +| `graphdb_query` | 5 | **mixed** | + +Roughly 43 project-touching sites take their table names from the handle; +roughly 92 environment-only sites are untouched. + +This asymmetry is deliberate, not merely economical. Under SP3 the environment +is replicated by ordinary Mnesia `disc_copies` across nodes and **keeps its +table name**; Mnesia provides location transparency. The environment table name +therefore never needs to be a variable, even under distribution. +Parameterising it would add ~92 sites of churn to buy symmetry with a singleton +that is permanent by design. A module using the literal name is thereby +self-documenting as environment-only. + +The two modules whose environment-only status carried real risk were checked +against the tree rather than assumed, because if either were mixed the split +above and the self-documenting claim would both be wrong: + +- **`graphdb_class`** — all 26 sites read `ClassNref`, `AttrNref`, or a + taxonomy `ParentNref`. Qualifying-characteristic inheritance walks the + taxonomic DAG over class nodes only. No instance read. Confirmed + environment-only. +- **`graphdb_rules`** — all 23 sites read rule nodes, class nodes, template + nodes, relationship-attribute nodes, or rule-meta-class instances (which are + themselves environment-resident, per §6). Rule *planning* is driven by a + `ClassNref` and produces a plan that `graphdb_instance` executes; the module + never reads the instance graph and never calls `create_instance`. Confirmed + environment-only. + +The remaining per-module counts are a survey of the current tree, not a +contract; the plan re-checks a module's scope before threading it. + +### Reconciling the `graphdb_rules` scope tag + +`graphdb_rules` already carries a scope parameter on its entire public API — +`Scope :: environment | {project, _}` — predating SP1. Every `{project, _}` +clause is currently a stub returning `{ok, []}`, `not_found`, or a rejection; +project-scoped rules are deferred. + +This is a **third** name for the concept SP1 called `Session` and SP2 calls +`Project`. SP2 aligns it: the project arm becomes `{project, Project}`, +carrying the handle rather than an unspecified term. The stub clauses stay +stubs — SP2 adds no project-rule behaviour — but the shape is then correct for +whenever that work lands, and the codebase stops carrying three vocabularies +for one idea. + +### Transactions span both stores + +`graphdb_mgr:transaction/1` is unchanged. A single Mnesia transaction on one +node may touch the environment tables and one project's tables together, which +is what makes the atomicity guarantee in §7 for `mutate/2` and for +instance-to-class membership writes hold without new machinery. + +## 7. API shape + +| Surface | Change | +| ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| project writes — `create_instance`, `add_relationship`, `remove_relationship`, `update_relationship(_both)`, `add_class_membership` | `Session` parameter renamed `Project`; now routes rather than merely validating | +| instance reads — `get_instance`, `children`, `compositional_ancestors`, `resolve_value` | gain a `Project` parameter | +| `graphdb_query` | the query session gains a `Project` field, set when the query session opens | +| `graphdb_mgr:get_node/1` | stays environment-only; new `get_node/2` takes a `Project` | +| `mutate/1` | new `mutate/2` takes a `Project`; `mutate/1` remains for environment-only batches | +| environment ops — `create_class`, `create_attribute`, rules, language registration | unchanged; no handle | +| `graphdb_rules` scope tag | `{project, _}` becomes `{project, Project}`; the clauses stay stubs — see §6 | + +### Why instance reads are forced + +Under allocator-from-1 a bare nref is meaningless at an API entry point: +`get_instance(5)` has no global table to look in. SP1 deferred read routing; +SP2 cannot. This also means SP2 **cannot be sliced** into "write path first, +reads later" — writes landing in project tables while reads still hit `nodes` +would leave the system incoherent between slices. Project tables, allocator, +routing, write path, and instance reads form one unit. + +### Why the query engine takes its `Project` from the query session + +`graphdb_query` consumes the instance reads, so it must supply a `Project`. +Placing it in the existing query session rather than in every entry-point +signature keeps the change to a single argument at query-session open. It is +also a step toward §8: the query session is already a per-client state bag, so +holding the primary project is the shape that concept wants, reached from the +consumer side rather than designed top-down. + +### Why one project per `mutate` batch + +A batch may mix environment and project mutations, but spans **at most one +project plus the environment**. Allowing a per-mutation project would let one +atomic batch touch two project stores. That is satisfiable today — one node, +one transaction — but SP3 places projects on different nodes, potentially in +different data centers with an air gap between them, at which point such a +batch becomes a distributed transaction across stores that may not be mutually +reachable. "One project plus the environment" stays satisfiable under SP3 +because the environment is reachable or replicated at every location by design. +Constraining this now avoids building a capability SP3 would have to withdraw. + +## 8. `Session` — deferred + +`Session` is reserved for a higher-level, per-user/per-client concept: a +container for the state needed to disambiguate that client's operations — +a primary `Project`, a primary language, and whatever else later proves to need +disambiguation. It is decomposed at a high level into the concrete handles the +lower functions take: store-touching functions receive a `Project`; +language-sensitive functions receive a language. + +Consequently the multi-project ambiguity that a session-as-parameter would +create cannot arise: by the time a store function is called, the caller holds a +`Project`, which names exactly one store. "Several projects open at once" +becomes purely a `Session`-level concern — which project is primary, how a +client switches — entirely outside the routing path. + +SP2 does **not** build `Session`. Its content is driven by consumers that do +not exist yet, so building the container now would mean guessing its shape. +It is tracked as its own item in `TASKS.md` for design later. + +## 9. Out of scope + +- **Existing instance data.** Instances already sitting in the shared `nodes` + table are orphaned the moment writes route to project tables. SP4 owns + migration; development and test environments start fresh. Recorded here so it + is not discovered during implementation. +- **Project-scoped language overlays** (`language__`) and + project-scoped rule instances. Language and rule instances stay + environment-resident in SP2. +- **Proxy creation and dereference** — SP3. SP1's `proxy_coordinates/1` still + assumes a well-formed proxy; the malformed case is handled when creation + lands. +- **Distribution, residency, environment replication** — SP3. +- **Multi-project sessions** — see §8. + +## 10. Test surface + +Every CT suite writes instances through the `sess()` helper introduced in SP1; +it becomes `proj()`, returning a `Project` handle over a registered project. + +`graphdb_instance_SUITE`'s table-size delta assertions measure `nodes` and +`relationships` directly and must move to the project tables. SP1 hit a related +problem — a lazily-registered project wrote arc rows inside a measured +before/after window, requiring a pre-warm in `init_per_testcase`. The same +hazard applies to project table creation, which is why this is an explicit plan +item rather than absorbed as incidental churn. + +Suites that assert on exact nrefs must be reviewed: instance nrefs move from +the environment runtime tier (≥ 1000000) to per-project values starting at 1. + +## 11. Risks + +| Risk | Mitigation | +| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| A module surveyed as environment-only turns out to read instance nodes | The two high-risk modules (`graphdb_class`, `graphdb_rules`) are verified against the tree in §6; the plan re-checks the rest before threading | +| Registration is not atomic (schema ops cannot run in a transaction) | `register_project/1` is idempotent: create-if-absent for both anchor and tables | +| Table proliferation — three Mnesia tables per project | Accepted at SP2's scale; fragmentation and placement are SP3 concerns | +| Test churn larger than estimated, since instance nrefs change value | Scoped as an explicit plan item (§10) rather than absorbed mid-implementation | diff --git a/docs/superpowers/plans/2026-08-05-sp2-physical-project-store.md b/docs/superpowers/plans/2026-08-05-sp2-physical-project-store.md new file mode 100644 index 0000000..f26ba9e --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-sp2-physical-project-store.md @@ -0,0 +1,3045 @@ +# SP2: Physical Project Store — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give each registered project its own physical Mnesia table set +(`nodes_` / `relationships_` / `counters_`) and its own nref +allocator starting at 1, so a project's instance space is a distinct, +relocatable unit and the nref-collision defect (project nref 5 vs +environment nref 5) is closed by construction. + +**Architecture:** Every project write op and every instance read gains a +`Project` handle (`#{anchor, nodes, rels, counters}`, resolved once by +`graphdb_project:open/1`) that names the three table atoms to use instead of +the literal `nodes`/`relationships`. `graphdb_ns:target_namespace/2` becomes +the live home-relative router (`Home :: environment | Project`). Tier-1 +primitives shared between `graphdb_instance`'s project write path and +`graphdb_mgr:mutate/1,2` take a general `Home`; primitives that only ever +run inside a project's own `create_instance`/`add_relationship` cascade take +a concrete `Project` directly, since that cascade is never environment-only. + +**Tech Stack:** Erlang/OTP 28, Mnesia (`disc_copies`), rebar3 3.27, Common +Test + EUnit. + +## Global Constraints + +- Hard tabs in every `apps/graphdb/` source and test file — **except** + `apps/graphdb/src/graphdb_query.erl` and its test suite, which already use + space indentation throughout (verified: zero tab characters in the file + today). Match each file's own existing indentation; do not convert + `graphdb_query.erl` to tabs as part of this work. +- No shared graphdb records header — every module keeps its own inline + `-record(node, {...})` / `-record(relationship, {...})` copy. Do not + introduce a shared `.hrl` for these. +- Small predicates that would otherwise be shared across workers (e.g. + `is_retired/2`) are deliberately duplicated per module (YAGNI, no new + shared util module) — follow this precedent for the new `Home`-dispatch + helpers described in Task 9 rather than centralizing them. +- LOAD-BEARING INVARIANT: no gen_server call may run inside an Mnesia + transaction fun. `graphdb_nref:get_next()`, `rel_id_server:get_id_pair()`, + and `graphdb_attr:seeded_nrefs()` are gen_server calls and must happen + outside the transaction; `mnesia:dirty_update_counter/3` (the new project + allocator) is a dirty op and is exempt from this rule but must still run + outside the transaction for the same reason stated in the design (§4): + calling it inside a transaction Mnesia might restart would burn ids. + `graphdb_class` reads inside a firing-engine transaction must stay + `dirty_read`/`dirty_index_read` (existing convention — do not change). +- Every `mutate/1`-composed tier-1 primitive keeps signalling failure via + `mnesia:abort/1` and never opens its own transaction. +- Design source: `docs/designs/sp2-physical-project-store-design.md` + (amendments to `docs/designs/project-env-reference-namespace-model-design.md` + §3 already merged). Read both before starting; this plan assumes their + content. +- `Project` is always a real handle from `graphdb_project:open/1` in the + project-scoped write path (`graphdb_instance`) — it is never the atom + `environment` there. Only `graphdb_mgr:mutate/1,2` and `graphdb_query` + need the `Home :: environment | Project` duality. +- Run tests with `./rebar3 eunit --app=graphdb` and + `./rebar3 ct --app=graphdb` (or `make test-ct-parallel` for the fast path) + from the repo root. `./rebar3` is repo-local; no `source ~/.bashrc` prefix + needed. + +--- + +## File Structure + +| File | Responsibility after this plan | +| --- | --- | +| `apps/graphdb/src/graphdb_ns.erl` | Home-relative routing: `namespace_of/2`, `target_namespace/2`, `node_table/1`, `rel_table/1`. Pure — no gen_server calls, no Mnesia I/O beyond table-name string building. | +| `apps/graphdb/test/graphdb_ns_tests.erl` | Table-driven EUnit coverage of the arity-2 forms across both `Home` values. | +| `apps/graphdb/src/graphdb_project.erl` | Project registry + physical store: `register_project/1` (anchor + tables), `open/1`, `is_project/1`, `require_project/1`, `next_nref/1`, `next_rel_id_pair/1`; canonical project-scoped relationship API (renamed `Session`→`Project`). | +| `apps/graphdb/test/graphdb_project_SUITE.erl` | CT coverage for table creation, `open/1` (including `no_store`), and the counters. | +| `apps/graphdb/src/graphdb_instance.erl` | Every project write/read routes through `Project`; internal cascade (`Ctx`) carries it; tier-1 primitives shared with `mutate` take `Home`. | +| `apps/graphdb/test/graphdb_instance_SUITE.erl` | `proj()` replaces `sess()`; delta assertions read project tables; `invalid_session` → `invalid_project`. | +| `apps/graphdb/src/graphdb_mgr.erl` | New `Project`-taking twins: `get_node/2`, `retire_node/2`, `unretire_node/2`, `update_node_avps/3`, `delete_node/2`, `mutate/2`. Existing `/1` forms stay environment-only, unchanged behaviour. | +| `apps/graphdb/test/graphdb_mgr_SUITE.erl` | `proj()` replaces `sess()`; new tests for the six new entry points. | +| `apps/graphdb/src/graphdb_query.erl` | `new_session/1` binds a `Project`; `session_read_node`/`session_read_arcs` resolve `Home` per nref instead of assuming the environment table. | +| `apps/graphdb/test/graphdb_query_SUITE.erl` | `proj()` replaces `sess()`; new tests for project-scoped reads and the collision-log path. | +| `apps/graphdb/src/graphdb_rules.erl` | Moduledoc/comment polish only — `{project, _}` becomes `{project, Project}` in prose; stub clauses unchanged (still pattern-match `_`). | +| `apps/graphdb/test/graphdb_rules_SUITE.erl` | Dummy `{project, 1}` placeholders become a realistic synthetic `Project` map shape. | +| `apps/graphdb/CLAUDE.md`, `docs/Architecture.md`, `TASKS.md` | Reflect the shipped SP2 surface. | + +--- + +## Task 1: `graphdb_ns` — home-relative routing (arity-2) + +**Files:** +- Modify: `apps/graphdb/src/graphdb_ns.erl` +- Test: `apps/graphdb/test/graphdb_ns_tests.erl` + +**Interfaces:** +- Produces: `graphdb_ns:namespace_of/2 :: (Home, Role) -> environment | Home` + where `Role :: characterization | reciprocal | avp_attribute | + node_classes | taxonomy_parent | compositional_parent | node_nref`. + `graphdb_ns:target_namespace/2 :: (Home, TargetKind) -> environment | Home` + where `TargetKind :: category | attribute | class | instance`. + `graphdb_ns:node_table/1 :: (Home) -> atom()`, + `graphdb_ns:rel_table/1 :: (Home) -> atom()`, where + `Home :: environment | #{anchor := integer(), nodes := atom(), + rels := atom(), counters := atom()}`. Every later task depends on these + four functions. + +- [ ] **Step 1: Write the failing tests** + +Replace the whole file (the arity-1 forms it tests are being removed, so +this is a full rewrite, not an addition): + +```erlang +-module(graphdb_ns_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(PROJECT, #{anchor => 42, nodes => nodes_42, + rels => relationships_42, counters => counters_42}). + +namespace_of_environment_roles_test() -> + [ ?assertEqual(environment, graphdb_ns:namespace_of(Home, R)) + || Home <- [environment, ?PROJECT], + R <- [characterization, reciprocal, avp_attribute, + node_classes, taxonomy_parent] ]. + +namespace_of_home_relative_roles_test() -> + [ ?assertEqual(Home, graphdb_ns:namespace_of(Home, R)) + || Home <- [environment, ?PROJECT], + R <- [compositional_parent, node_nref] ]. + +target_namespace_instance_is_home_test() -> + [ ?assertEqual(Home, graphdb_ns:target_namespace(Home, instance)) + || Home <- [environment, ?PROJECT] ]. + +target_namespace_others_are_environment_test() -> + [ ?assertEqual(environment, graphdb_ns:target_namespace(Home, K)) + || Home <- [environment, ?PROJECT], + K <- [category, attribute, class] ]. + +namespace_of_unknown_role_crashes_test() -> + ?assertError(function_clause, graphdb_ns:namespace_of(environment, bogus_role)). + +target_namespace_unknown_kind_crashes_test() -> + ?assertError(function_clause, graphdb_ns:target_namespace(environment, bogus_kind)). + +node_table_environment_is_literal_test() -> + ?assertEqual(nodes, graphdb_ns:node_table(environment)). + +node_table_project_is_its_own_table_test() -> + ?assertEqual(nodes_42, graphdb_ns:node_table(?PROJECT)). + +rel_table_environment_is_literal_test() -> + ?assertEqual(relationships, graphdb_ns:rel_table(environment)). + +rel_table_project_is_its_own_table_test() -> + ?assertEqual(relationships_42, graphdb_ns:rel_table(?PROJECT)). +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./rebar3 eunit --app=graphdb --module=graphdb_ns_tests` +Expected: compile error (`namespace_of/2`, `target_namespace/2`, +`node_table/1`, `rel_table/1` undefined) — `graphdb_ns.erl` still only +exports the arity-1 forms. + +- [ ] **Step 3: Rewrite `graphdb_ns.erl`** + +```erlang +%%--------------------------------------------------------------------- +%% Copyright (c) 2008 SeerStone, Inc. +%% Copyright (c) 2026 David W. Thomas +%% SPDX-License-Identifier: GPL-2.0-or-later +%%--------------------------------------------------------------------- +%% Author: David W. Thomas +%% Created: 2026-06-29 +%% Description: Pure namespace resolution module. Encodes which +%% database namespace each kind of nref reference belongs +%% to, and resolves a Home into its physical table atoms. +%% No dependencies on other modules; fixed lookup table +%% based on the project-environment separation model. +%%--------------------------------------------------------------------- +%% Revision History +%%--------------------------------------------------------------------- +%% Rev PA1 Date: 2026-06-29 Author: David W. Thomas +%% Initial implementation. +%% Rev PA2 Date: 2026-08-05 Author: David W. Thomas +%% SP2: home-relative routing. namespace_of/1 and target_namespace/1 +%% replaced by /2 forms taking a Home (environment | project handle). +%% node_table/1 and rel_table/1 added. +%%--------------------------------------------------------------------- + +-module(graphdb_ns). + +-export([namespace_of/2, target_namespace/2, node_table/1, rel_table/1]). + +%%--------------------------------------------------------------------- +%% NYI / UEM Macros +%%--------------------------------------------------------------------- +-define(NYI(X), (begin + io:format("*** NYI ~p ~p ~p~n",[?MODULE, ?LINE, X]), + exit(nyi) +end)). +-define(UEM(F, X), (begin + io:format("*** UEM ~p:~p ~p ~p~n",[?MODULE, F, ?LINE, X]), + exit(uem) +end)). + + +%%--------------------------------------------------------------------- +%% namespace_of(Home, Role) -> environment | Home +%% +%% Encodes docs/designs/project-env-reference-namespace-model-design.md §3 +%% (amended 2026-08-02 for home-relative routing). `Home` is the store the +%% containing record was read from: `environment` or a `graphdb_project` +%% handle. target_nref and source_nref are NOT roles here — they need the +%% arc label's target_kind too, so they route through target_namespace/2 +%% directly at the call site (see that design's §6 code block). +%%--------------------------------------------------------------------- +namespace_of(_Home, characterization) -> environment; +namespace_of(_Home, reciprocal) -> environment; +namespace_of(_Home, avp_attribute) -> environment; +namespace_of(_Home, node_classes) -> environment; +namespace_of(_Home, taxonomy_parent) -> environment; +namespace_of(Home, compositional_parent) -> Home; +namespace_of(Home, node_nref) -> Home. + + +%%--------------------------------------------------------------------- +%% target_namespace(Home, TargetKind) -> environment | Home +%% +%% The routed-field resolver: category/attribute/class targets are always +%% environment; an instance target is home-relative (Home itself — whatever +%% that Home is, environment or a specific project). +%%--------------------------------------------------------------------- +target_namespace(_Home, category) -> environment; +target_namespace(_Home, attribute) -> environment; +target_namespace(_Home, class) -> environment; +target_namespace(Home, instance) -> Home. + + +%%--------------------------------------------------------------------- +%% node_table(Home) -> atom() +%% rel_table(Home) -> atom() +%% +%% Resolves a Home into its physical Mnesia table atoms. `environment` +%% resolves to the literal shared tables; a project handle (as returned by +%% graphdb_project:open/1) carries its own table atoms directly. +%%--------------------------------------------------------------------- +node_table(environment) -> nodes; +node_table(#{nodes := T}) -> T. + +rel_table(environment) -> relationships; +rel_table(#{rels := T}) -> T. +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `./rebar3 eunit --app=graphdb --module=graphdb_ns_tests` +Expected: PASS, 10/10. + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_ns.erl apps/graphdb/test/graphdb_ns_tests.erl +git commit -m "SP2 T1: graphdb_ns home-relative routing (arity-2)" +``` + +--- + +## Task 2: `graphdb_project` — physical store + handle + allocators + +**Files:** +- Modify: `apps/graphdb/src/graphdb_project.erl` +- Modify: `apps/graphdb/test/graphdb_project_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_ns:node_table/1`, `graphdb_ns:rel_table/1` (Task 1). +- Produces: `graphdb_project:register_project/1 :: (string()) -> {ok, + integer()} | {error, term()}` (now also creates the three tables). + `graphdb_project:open/1 :: (integer()) -> {ok, Project} | {error, + not_a_project} | {error, no_store}` where `Project :: #{anchor := + integer(), nodes := atom(), rels := atom(), counters := atom()}` — this + is the `Project` shape every later task threads. + `graphdb_project:require_project/1 :: (term()) -> ok | {error, + invalid_project}`. + `graphdb_project:next_nref/1 :: (Project) -> pos_integer()`. + `graphdb_project:next_rel_id_pair/1 :: (Project) -> {pos_integer(), + pos_integer()}`. + `is_project/1` unchanged. `open_session/1`, `session_project/1`, + `require_session/1` are REMOVED (no deprecation shim — SP1 had zero + external callers outside this tree, all of which Task 12 updates). + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/graphdb/test/graphdb_project_SUITE.erl`. First update the +`all/0` and export list, then append the new test bodies: + +```erlang +-export([ + register_project_creates_child_of_projects/1, + register_project_creates_tables/1, + register_project_is_idempotent/1, + is_project_false_for_non_child/1, + open_returns_project_handle/1, + open_rejects_non_project/1, + open_rejects_project_without_store/1, + require_project_accepts_valid_handle/1, + require_project_rejects_malformed_term/1, + next_nref_starts_at_one/1, + next_nref_is_sequential/1, + next_rel_id_pair_returns_two_consecutive_ids/1 +]). +``` + +```erlang +all() -> + [register_project_creates_child_of_projects, + register_project_creates_tables, + register_project_is_idempotent, + is_project_false_for_non_child, + open_returns_project_handle, + open_rejects_non_project, + open_rejects_project_without_store, + require_project_accepts_valid_handle, + require_project_rejects_malformed_term, + next_nref_starts_at_one, + next_nref_is_sequential, + next_rel_id_pair_returns_two_consecutive_ids]. +``` + +```erlang +%%----------------------------------------------------------------------------- +%% register_project creates the three physical tables, all initially empty. +%%----------------------------------------------------------------------------- +register_project_creates_tables(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + Tables = mnesia:system_info(tables), + ?assert(lists:member(list_to_atom("nodes_" ++ integer_to_list(P)), Tables)), + ?assert(lists:member(list_to_atom("relationships_" ++ integer_to_list(P)), + Tables)), + ?assert(lists:member(list_to_atom("counters_" ++ integer_to_list(P)), + Tables)). + +%%----------------------------------------------------------------------------- +%% Calling ensure_tables again for an already-registered project's anchor +%% (simulated by opening twice) does not error. +%%----------------------------------------------------------------------------- +register_project_is_idempotent(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, P1} = graphdb_project:open(P), + {ok, P2} = graphdb_project:open(P), + ?assertEqual(P1, P2). + +%%----------------------------------------------------------------------------- +%% open/1 returns a Project handle carrying the three table atoms. +%%----------------------------------------------------------------------------- +open_returns_project_handle(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(#{anchor => P, + nodes => list_to_atom("nodes_" ++ integer_to_list(P)), + rels => list_to_atom("relationships_" ++ integer_to_list(P)), + counters => list_to_atom("counters_" ++ integer_to_list(P))}, + Project). + +%%----------------------------------------------------------------------------- +%% open/1 rejects a non-project nref. +%%----------------------------------------------------------------------------- +open_rejects_non_project(_Config) -> + ?assertEqual({error, not_a_project}, graphdb_project:open(?NREF_CLASSES)). + +%%----------------------------------------------------------------------------- +%% open/1 reports {error, no_store} for a registered anchor whose tables +%% were never created (the SP1-era state) -- simulated by writing an anchor +%% node directly under Projects without calling register_project/1. +%%----------------------------------------------------------------------------- +open_rejects_project_without_store(_Config) -> + Nref = graphdb_nref:get_next(), + {Id1, Id2} = rel_id_server:get_id_pair(), + Node = #node{nref = Nref, kind = instance, parents = [?NREF_PROJECTS], + attribute_value_pairs = []}, + F = fun() -> + ok = mnesia:write(nodes, Node, write), + ok = mnesia:write(relationships, + #relationship{id = Id1, kind = composition, + source_nref = ?NREF_PROJECTS, + characterization = ?ARC_CAT_CHILD, + target_nref = Nref, reciprocal = ?ARC_CAT_PARENT, + avps = []}, write), + ok = mnesia:write(relationships, + #relationship{id = Id2, kind = composition, + source_nref = Nref, + characterization = ?ARC_CAT_PARENT, + target_nref = ?NREF_PROJECTS, + reciprocal = ?ARC_CAT_CHILD, avps = []}, write) + end, + {ok, ok} = graphdb_mgr:transaction(F), + ?assert(graphdb_project:is_project(Nref)), + ?assertEqual({error, no_store}, graphdb_project:open(Nref)). + +%%----------------------------------------------------------------------------- +%% require_project accepts a well-formed handle, rejects everything else. +%%----------------------------------------------------------------------------- +require_project_accepts_valid_handle(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(ok, graphdb_project:require_project(Project)). + +require_project_rejects_malformed_term(_Config) -> + ?assertEqual({error, invalid_project}, graphdb_project:require_project(undefined)), + ?assertEqual({error, invalid_project}, graphdb_project:require_project(#{})). + +%%----------------------------------------------------------------------------- +%% next_nref/1: first allocation yields 1; no seeding needed. +%%----------------------------------------------------------------------------- +next_nref_starts_at_one(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(1, graphdb_project:next_nref(Project)). + +next_nref_is_sequential(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual(1, graphdb_project:next_nref(Project)), + ?assertEqual(2, graphdb_project:next_nref(Project)), + ?assertEqual(3, graphdb_project:next_nref(Project)). + +%%----------------------------------------------------------------------------- +%% next_rel_id_pair/1: two consecutive ids, independent of the nref counter. +%%----------------------------------------------------------------------------- +next_rel_id_pair_returns_two_consecutive_ids(_Config) -> + {ok, P} = graphdb_project:register_project("Acme"), + {ok, Project} = graphdb_project:open(P), + ?assertEqual({1, 2}, graphdb_project:next_rel_id_pair(Project)), + ?assertEqual({3, 4}, graphdb_project:next_rel_id_pair(Project)), + ?assertEqual(1, graphdb_project:next_nref(Project)). +``` + +- [ ] **Step 2: Run to verify these fail** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_project_SUITE` +Expected: compile errors / undef for `register_project/1` creating tables, +`open/1`, `require_project/1`, `next_nref/1`, `next_rel_id_pair/1`. + +- [ ] **Step 3: Rewrite `graphdb_project.erl`** + +Replace the exports and add the new sections (keep the moduledoc header, +copyright block, and NYI/UEM macros unchanged; keep the inline `-record` +definitions unchanged): + +```erlang +-export([register_project/1, is_project/1, open/1, + require_project/1, next_nref/1, next_rel_id_pair/1, + add_relationship/5, add_relationship/6, add_relationship/7, + add_class_membership/3, + remove_relationship/4, remove_relationship/5, + update_relationship/5, update_relationship/6, + update_relationship_both/5, update_relationship_both/6]). + + +%%===================================================================== +%% Public API +%%===================================================================== + +%%--------------------------------------------------------------------- +%% register_project(Name) -> {ok, ProjectNref} | {error, term()} +%% +%% Creates the project's anchor node (a kind=instance node in the +%% environment under the Projects category, nref 5) then its three +%% physical tables (nodes_, relationships_, counters_). +%% +%% mnesia:create_table/2 is a schema operation and cannot run inside a +%% transaction, so table creation happens AFTER the anchor write, not +%% atomically with it. ensure_tables/1 is idempotent (already_exists is +%% not an error), so a retried register_project/1 call converges. +%% +%% The anchor's nref and rel-id pair are allocated OUTSIDE the transaction +%% fun: calling gen_servers (graphdb_nref, rel_id_server) inside a Mnesia +%% activity is a latent deadlock -- load-bearing invariant in this +%% codebase. +%%--------------------------------------------------------------------- +register_project(Name) when is_list(Name) -> + case create_anchor(Name) of + {ok, Nref} -> + try + ok = ensure_tables(Nref), + {ok, Nref} + catch + throw:{error, _} = Err -> Err + end; + {error, _} = Err -> + Err + end. + +create_anchor(Name) -> + Nref = graphdb_nref:get_next(), + {Id1, Id2} = rel_id_server:get_id_pair(), + NameAVP = #{attribute => ?NAME_ATTR_INSTANCE, value => Name}, + Node = #node{nref = Nref, kind = instance, + parents = [?NREF_PROJECTS], + attribute_value_pairs = [NameAVP]}, + P2C = #relationship{id = Id1, kind = composition, + source_nref = ?NREF_PROJECTS, + characterization = ?ARC_CAT_CHILD, + target_nref = Nref, reciprocal = ?ARC_CAT_PARENT, + avps = []}, + C2P = #relationship{id = Id2, kind = composition, + source_nref = Nref, + characterization = ?ARC_CAT_PARENT, + target_nref = ?NREF_PROJECTS, reciprocal = ?ARC_CAT_CHILD, + avps = []}, + Fun = fun() -> + ok = mnesia:write(nodes, Node, write), + ok = mnesia:write(relationships, P2C, write), + ok = mnesia:write(relationships, C2P, write), + Nref + end, + graphdb_mgr:transaction(Fun). + + +%%--------------------------------------------------------------------- +%% ensure_tables(Anchor) -> ok (throws {error, {create_table_failed, ...}}) +%% +%% Creates the project's three physical tables if absent. Mirrors +%% graphdb_bootstrap:create_tables/0's shape (disc_copies, record_info- +%% derived attributes, source_nref/target_nref index on relationships). +%% The counters table has no fixed record shape -- it is looked up by a +%% bare {Key, Value} tuple via mnesia:dirty_update_counter/3. +%%--------------------------------------------------------------------- +ensure_tables(Anchor) -> + NodeList = [node()], + ok = ensure_table(nodes_table(Anchor), [ + {record_name, node}, + {attributes, record_info(fields, node)}, + {disc_copies, NodeList} + ]), + ok = ensure_table(rels_table(Anchor), [ + {record_name, relationship}, + {attributes, record_info(fields, relationship)}, + {disc_copies, NodeList}, + {index, [source_nref, target_nref]} + ]), + ok = ensure_table(counters_table(Anchor), [ + {disc_copies, NodeList} + ]), + ok. + +ensure_table(Name, Opts) -> + case mnesia:create_table(Name, Opts) of + {atomic, ok} -> ok; + {aborted, {already_exists, Name}} -> ok; + {aborted, Reason} -> + throw({error, {create_table_failed, Name, Reason}}) + end. + +nodes_table(Anchor) -> list_to_atom("nodes_" ++ integer_to_list(Anchor)). +rels_table(Anchor) -> list_to_atom("relationships_" ++ integer_to_list(Anchor)). +counters_table(Anchor) -> list_to_atom("counters_" ++ integer_to_list(Anchor)). + + +%%--------------------------------------------------------------------- +%% is_project(Nref) -> boolean() +%% +%% Returns true iff the node at Nref has ?NREF_PROJECTS (5) in its +%% parents cache -- i.e. it was registered as a project anchor node. +%%--------------------------------------------------------------------- +is_project(Nref) -> + case graphdb_mgr:get_node(Nref) of + {ok, #node{parents = Parents}} -> lists:member(?NREF_PROJECTS, Parents); + _ -> false + end. + + +%%--------------------------------------------------------------------- +%% open(ProjectNref) -> +%% {ok, Project} | {error, not_a_project} | {error, no_store} +%% +%% Resolves a registered project's nref into its physical store handle. +%% {error, no_store} covers an anchor that predates SP2 (registered, but +%% without tables); SP4's migration resolves that state -- open/1 reports +%% it rather than silently creating an empty store. +%%--------------------------------------------------------------------- +open(ProjectNref) -> + case is_project(ProjectNref) of + false -> + {error, not_a_project}; + true -> + case tables_exist(ProjectNref) of + true -> + {ok, #{anchor => ProjectNref, + nodes => nodes_table(ProjectNref), + rels => rels_table(ProjectNref), + counters => counters_table(ProjectNref)}}; + false -> + {error, no_store} + end + end. + +tables_exist(Anchor) -> + lists:member(nodes_table(Anchor), mnesia:system_info(tables)). + + +%%--------------------------------------------------------------------- +%% require_project(Project) -> ok | {error, invalid_project} +%% +%% Gate for project-scoped operations: a well-formed Project handle +%% passes; any other term is rejected. Pure (no store access) -- the +%% handle was already validated against the registry by open/1. +%%--------------------------------------------------------------------- +require_project(#{anchor := _, nodes := _, rels := _, counters := _}) -> ok; +require_project(_) -> + {error, invalid_project}. + + +%%--------------------------------------------------------------------- +%% next_nref(Project) -> pos_integer() +%% next_rel_id_pair(Project) -> {pos_integer(), pos_integer()} +%% +%% Project-local allocators. mnesia:dirty_update_counter/3 on a key that +%% has never been written creates it with the increment as its value, so +%% the first call to either yields the low end of the unbounded monotonic +%% space -- no seeding needed at register_project/1 time. Dirty ops do not +%% participate in a surrounding transaction; callers must invoke these +%% OUTSIDE any transaction fun, same discipline as graphdb_nref:get_next/0 +%% and rel_id_server:get_id_pair/0 for the environment. +%%--------------------------------------------------------------------- +next_nref(#{counters := Counters}) -> + mnesia:dirty_update_counter(Counters, nref, 1). + +next_rel_id_pair(#{counters := Counters}) -> + Id2 = mnesia:dirty_update_counter(Counters, rel_id, 2), + {Id2 - 1, Id2}. +``` + +Then update the delegator functions (mechanical rename, same bodies, +`Session` → `Project`): + +```erlang +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + graphdb_instance:add_relationship(Project, SourceNref, CharNref, + TargetNref, ReciprocalNref). + +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref) -> + graphdb_instance:add_relationship(Project, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateNref). + +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref, AVPSpec) -> + graphdb_instance:add_relationship(Project, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateNref, AVPSpec). + +add_class_membership(Project, InstanceNref, ClassNref) -> + graphdb_instance:add_class_membership(Project, InstanceNref, ClassNref). + +remove_relationship(Project, SourceNref, CharNref, TargetNref) -> + graphdb_instance:remove_relationship(Project, SourceNref, CharNref, + TargetNref). + +remove_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref) -> + graphdb_instance:remove_relationship(Project, SourceNref, CharNref, + TargetNref, TemplateNref). + +update_relationship(Project, SourceNref, CharNref, TargetNref, Updates) -> + graphdb_instance:update_relationship(Project, SourceNref, CharNref, + TargetNref, Updates). + +update_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref, + Updates) -> + graphdb_instance:update_relationship(Project, SourceNref, CharNref, + TargetNref, TemplateNref, Updates). + +update_relationship_both(Project, SourceNref, CharNref, TargetNref, Pair) -> + graphdb_instance:update_relationship_both(Project, SourceNref, CharNref, + TargetNref, Pair). + +update_relationship_both(Project, SourceNref, CharNref, TargetNref, TemplateNref, + Pair) -> + graphdb_instance:update_relationship_both(Project, SourceNref, CharNref, + TargetNref, TemplateNref, Pair). +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_project_SUITE` +Expected: PASS, 12/12. Also run +`./rebar3 ct --app=graphdb --suite=graphdb_instance_SUITE,graphdb_mgr_SUITE,graphdb_query_SUITE` +to confirm the compile break from removing `open_session/1` / +`session_project/1` / `require_session/1` is visible now (it will be — +those suites' `sess()` helpers call `open_session/1`). This is expected; +Task 12 fixes it. Note the failure and move on. + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_project.erl apps/graphdb/test/graphdb_project_SUITE.erl +git commit -m "SP2 T2: graphdb_project physical store + Project handle + allocators" +``` + +--- + +## Task 3: `graphdb_instance` — rename `Session`→`Project`, thread the handle through the public write API + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Consumes: `graphdb_project:require_project/1` (Task 2). +- Produces: every public write function now takes `Project` first and + ships it into the `gen_server:call` message tuple (previously dropped by + `with_session`). New gen_server message shapes (consumed by Task 4-9's + `handle_call` clauses): `{create_instance, Project, Name, ClassNref, + ParentNref, Resolver, ConflictResolver}`, `{add_relationship, Project, S, + C, T, R, TemplateSpec, AVPSpec}`, `{add_class_membership, Project, + InstanceNref, ClassNref}`. + +This task only touches the **public function heads and the gate**; the +`handle_call` clauses and internal primitives are Tasks 4-9. The module will +not compile standalone after this task — that's expected; Tasks 4-9 land in +the same PR before the suite is expected to pass. + +- [ ] **Step 1: Rename the gate** + +```erlang +%% Gate a project operation on a valid Project handle. A missing or +%% malformed handle short-circuits with {error, invalid_project}; a valid +%% one runs Fun(Project). SP2: Fun now receives Project so it can route. +with_project(Project, Fun) when is_function(Fun, 1) -> + case graphdb_project:require_project(Project) of + ok -> Fun(Project); + {error, _} = Err -> Err + end. +``` + +(Replaces the old `with_session/2`, which took a zero-arity `Fun` and +discarded `Session`.) + +- [ ] **Step 2: Rewrite the public write API heads** + +```erlang +create_instance(Project, Name, ClassNref, ParentNref) -> + create_instance(Project, Name, ClassNref, ParentNref, fun report_only/1). + +create_instance(Project, Name, ClassNref, ParentNref, ConnResolver) + when is_function(ConnResolver, 1) -> + create_instance(Project, Name, ClassNref, ParentNref, ConnResolver, + graphdb_rules:default_conflict_resolver()). + +create_instance(Project, Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver) + when is_function(ConnResolver, 1), is_function(ConflictResolver, 1) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {create_instance, P, Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver}) + end). + +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {add_relationship, P, SourceNref, CharNref, TargetNref, + ReciprocalNref, default, {[], []}}) + end). + +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref) when is_integer(TemplateNref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {add_relationship, P, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, {[], []}}) + end). + +add_relationship(Project, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref, {FwdAVPs, RevAVPs} = AVPSpec) + when is_integer(TemplateNref), is_list(FwdAVPs), is_list(RevAVPs) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {add_relationship, P, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec}) + end). + +add_class_membership(Project, InstanceNref, ClassNref) -> + with_project(Project, fun(P) -> + gen_server:call(?MODULE, + {add_class_membership, P, InstanceNref, ClassNref}) + end). +``` + +- [ ] **Step 3: Rewrite `remove_relationship`/`update_relationship`/`update_relationship_both` heads** + +```erlang +remove_relationship(Project, SourceNref, CharNref, TargetNref) -> + with_project(Project, fun(P) -> + txn_ok(fun() -> + remove_relationship_in_txn(P, SourceNref, CharNref, TargetNref, any) + end) + end). + +remove_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref) + when is_integer(TemplateNref) -> + with_project(Project, fun(P) -> + txn_ok(fun() -> + remove_relationship_in_txn(P, SourceNref, CharNref, TargetNref, + TemplateNref) + end) + end). + +update_relationship(Project, SourceNref, CharNref, TargetNref, Updates) -> + with_project(Project, fun(P) -> + do_update_relationship(P, SourceNref, CharNref, TargetNref, any, Updates) + end). + +update_relationship(Project, SourceNref, CharNref, TargetNref, TemplateNref, + Updates) when is_integer(TemplateNref) -> + with_project(Project, fun(P) -> + do_update_relationship(P, SourceNref, CharNref, TargetNref, + TemplateNref, Updates) + end). + +update_relationship_both(Project, SourceNref, CharNref, TargetNref, + {Fwd, Rev}) -> + with_project(Project, fun(P) -> + do_update_both(P, SourceNref, CharNref, TargetNref, any, Fwd, Rev) + end). + +update_relationship_both(Project, SourceNref, CharNref, TargetNref, TemplateNref, + {Fwd, Rev}) when is_integer(TemplateNref) -> + with_project(Project, fun(P) -> + do_update_both(P, SourceNref, CharNref, TargetNref, TemplateNref, Fwd, Rev) + end). +``` + +Note `do_update_relationship/6` and `do_update_both/7` gain a leading +`Project`/`Home` argument here — their bodies are rewritten in Task 6. + +- [ ] **Step 4: Commit (module intentionally non-compiling until Task 9)** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T3: graphdb_instance public write API takes Project (WIP, compiles after T4-T9)" +``` + +If your workflow requires green-at-every-commit, squash Tasks 3-9 into one +commit instead — they are one reviewable unit (the design's "SP2 cannot be +sliced" constraint applies at the code level too: the public heads, the +create/connection cascade, and the reads must land together for the module +to compile and its own test suite to run at all). + +--- + +## Task 4: `graphdb_instance` — creation cascade routes through `Project` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Consumes: `graphdb_project:next_nref/1`, `next_rel_id_pair/1` (Task 2). +- Produces: `Ctx` map gains a `project` key, threaded unchanged through the + whole cascade (same pattern as the existing `root_parent`/`root_source` + anchors). `allocate_plan/2`, `instance_records/5`, `plan_writes/3`, + `write_children/4` all take `Project` explicitly (not buried only in + `Ctx`) so their signatures self-document. + +- [ ] **Step 1: `handle_call` for `create_instance` binds `Project` into `Ctx`** + +```erlang +handle_call({create_instance, Project, Name, ClassNref, ParentNref, Resolver, + ConflictResolver}, _From, + #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> + Ctx = #{inst_attr => InstAttr, ret_attr => RetAttr, on_path => [], + resolver => Resolver, conflict_resolver => ConflictResolver, + project => Project, root_parent => ParentNref, + root_source => undefined}, + {reply, do_create_instance(Name, ClassNref, ParentNref, Ctx), State}; +``` + +- [ ] **Step 2: `do_create_instance`/`do_validate_parent` route the parent read** + +`do_create_instance/4` is unchanged except it now has a `project` key +available in `Ctx`; pass it to `do_validate_parent/3`: + +```erlang +do_create_instance(Name, ClassNref, ParentNref, Ctx) -> + InstAttr = maps:get(inst_attr, Ctx), + RetAttr = maps:get(ret_attr, Ctx), + Project = maps:get(project, Ctx), + case do_validate_class(ClassNref, InstAttr, RetAttr) of + ok -> + case do_validate_parent(Project, ParentNref, RetAttr) of + ok -> + fire_create(Name, ClassNref, ParentNref, Ctx); + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end. +``` + +`do_validate_class/3` is unchanged (class reads are always environment — +no `Project` needed, per the design's §6 verification that `graphdb_class` +and every class-nref read in this module stay environment-only). + +`do_validate_parent/3` gains `Project` and reads the project's own nodes +table (the compositional parent is always another instance in the same +project — see design §6, "node.parents ... home-relative"): + +```erlang +do_validate_parent(Project, ParentNref, RetAttr) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), ParentNref) of + [#node{attribute_value_pairs = AVPs}] -> + case is_retired(AVPs, RetAttr) of + true -> {error, {parent_retired, ParentNref}}; + false -> ok + end; + [] -> {error, parent_not_found} + end. +``` + +- [ ] **Step 3: `allocate_plan/1` → `/2`, allocates from the project counter** + +```erlang +%%----------------------------------------------------------------------------- +%% allocate_plan(PlanNode, Project) -> InstPlanNode (same tree + nref per node) +%% +%% Depth-first pre-order walk: allocates one nref per node from Project's +%% own counter, OUTSIDE the Mnesia transaction. +%%----------------------------------------------------------------------------- +allocate_plan(#{mandatory_children := Kids} = Node, Project) -> + Nref = graphdb_project:next_nref(Project), + Node#{nref => Nref, + mandatory_children => [allocate_plan(K, Project) || K <- Kids]}. +``` + +- [ ] **Step 4: `plan_writes/2` → `/3`, `write_children/3` → `/4`, `instance_records/4` → `/5`** + +```erlang +%%----------------------------------------------------------------------------- +%% plan_writes(InstPlan, RootParent, Project) -> {Writes, Outcomes} +%%----------------------------------------------------------------------------- +plan_writes(#{nref := RootNref, class := Class, name := Name, + mandatory_children := Kids}, RootParent, Project) -> + Acc0 = {instance_records(RootNref, Class, Name, RootParent, Project), []}, + write_children(Kids, RootNref, Acc0, Project). + +write_children(Siblings, OwnerNref, Acc, Project) -> + {_Counts, Result} = + lists:foldl( + fun(#{nref := CNref, class := CClass, name := CName, + rule := Rule, deploy := Deploy, + mandatory_children := GKids}, {Counts, {W, O}}) -> + Idx = maps:get(rule_key(Rule), Counts, 0) + 1, + W1 = W ++ instance_records(CNref, CClass, CName, OwnerNref, + Project), + O1 = add_outcome(O, Rule, Deploy, + #{owner => OwnerNref, index => Idx, + status => fired, child => CNref}), + {W2, O2} = write_children(GKids, CNref, {W1, O1}, Project), + {Counts#{rule_key(Rule) => Idx}, {W2, O2}} + end, {#{}, Acc}, Siblings), + Result. + +%%----------------------------------------------------------------------------- +%% instance_records(Nref, ClassNref, Name, ParentNref, Project) -> [{Tab, Rec}] +%% +%% Builds the five Mnesia records for one instance node. Rel-IDs come from +%% Project's own counter (allocated here, outside the transaction, same as +%% before -- only the source changed from rel_id_server to +%% graphdb_project:next_rel_id_pair/1). Node record and both composition rows +%% tag their table via graphdb_ns:node_table/rel_table; the instantiation +%% pair's class-side row (C2I) still writes to Project's own relationships +%% table -- the row lives wherever its SOURCE lives (source_nref = ClassNref +%% would suggest environment, but per the design's arc-shape table the +%% class->instance membership row's source_nref routes environment while its +%% home store is still recorded with the instance -- see Task 5's +%% add_relationship_in_txn for the general rule; membership rows are written +%% here directly rather than through that general primitive, and both rows +%% of this specific arc pair are written to the SAME table as the instance +%% they describe, matching how SP1/pre-SP2 always wrote them together). +%%----------------------------------------------------------------------------- +instance_records(Nref, ClassNref, Name, ParentNref, Project) -> + {MembId1, MembId2} = graphdb_project:next_rel_id_pair(Project), + {CompId1, CompId2} = graphdb_project:next_rel_id_pair(Project), + NodesTab = graphdb_ns:node_table(Project), + RelsTab = graphdb_ns:rel_table(Project), + NameAVP = #{attribute => ?NAME_ATTR_INSTANCE, value => Name}, + Node = #node{nref = Nref, kind = instance, parents = [ParentNref], + classes = [ClassNref], attribute_value_pairs = [NameAVP]}, + %% Instance -> Class (char=29, reciprocal=30) + I2C = #relationship{id = MembId1, kind = instantiation, source_nref = Nref, + characterization = ?ARC_INST_TO_CLASS, target_nref = ClassNref, + reciprocal = ?ARC_CLASS_TO_INST, avps = []}, + %% Class -> Instance (char=30, reciprocal=29) + C2I = #relationship{id = MembId2, kind = instantiation, + source_nref = ClassNref, characterization = ?ARC_CLASS_TO_INST, + target_nref = Nref, reciprocal = ?ARC_INST_TO_CLASS, avps = []}, + %% Parent -> Child (char=28, reciprocal=27) + P2C = #relationship{id = CompId1, kind = composition, + source_nref = ParentNref, characterization = ?ARC_INST_CHILD, + target_nref = Nref, reciprocal = ?ARC_INST_PARENT, avps = []}, + %% Child -> Parent (char=27, reciprocal=28) + C2P = #relationship{id = CompId2, kind = composition, source_nref = Nref, + characterization = ?ARC_INST_PARENT, target_nref = ParentNref, + reciprocal = ?ARC_INST_CHILD, avps = []}, + [{NodesTab, Node}, {RelsTab, I2C}, {RelsTab, C2I}, + {RelsTab, P2C}, {RelsTab, C2P}]. +``` + +**Design note to record verbatim in the module comment above +`instance_records/5`:** the class→instance membership row (`C2I`, whose +`source_nref` is the environment `ClassNref`) is written to the **project's** +`relationships` table, not split across two stores. This is a deliberate, +narrow exception to "route by source's home": SP2 keeps both directions of +one arc-write co-located with the instance they describe so a project +remains a genuinely single relocatable unit (design §2's stated goal) — a +project's full membership history lives with it. Reads of this row still +resolve correctly under the home-relative rule because a reader who already +knows the row is a `char=30` reciprocal reaches it via `target_nref` from the +project side, never by scanning the environment's `relationships` table by +`source_nref=ClassNref` for this purpose. `add_relationship_in_txn` (Task 5) +does NOT follow this exception — it is the general connection-arc primitive +and routes each row by its own endpoints. + +- [ ] **Step 5: `execute/5` and `fire_create/4` pass `Project` down** + +```erlang +fire_create(Name, ClassNref, ParentNref, Ctx) -> + case graphdb_rules:plan_composition_firing(?RULE_SCOPE, ClassNref, + maps:get(conflict_resolver, Ctx)) of + {ok, PlanTree} -> + case execute(Name, ClassNref, ParentNref, Ctx, PlanTree) of + {ok, RootNref, MandOutcomes, InstPlan, AutoConnPlan} -> + Ctx1 = bind_root_source(Ctx, RootNref), + AutoReport = fire_auto(InstPlan, Ctx1), + ProposeReport = fire_propose(InstPlan, + maps:get(on_path, Ctx1)), + ConnAutoReport = fire_connections(AutoConnPlan), + Merged = merge_reports( + merge_reports( + merge_reports(MandOutcomes, AutoReport), + ProposeReport), + ConnAutoReport), + {ok, RootNref, Merged}; + {error, R, Report} -> + {error, R, Report} + end; + {error, R, Failure} -> + {error, R, report_not_attempted(R, Failure)} + end. + +execute(RootName, _RootClass, RootParent, Ctx, PlanTree) -> + Project = maps:get(project, Ctx), + InstPlan = allocate_plan(PlanTree#{name => RootName}, Project), + {Writes, CompOutcomes} = plan_writes(InstPlan, RootParent, Project), + RootNref = maps:get(nref, InstPlan), + Ctx1 = bind_root_source(Ctx, RootNref), + case resolve_connections(InstPlan, Ctx1) of + {ok, MandRows, AutoConnPlan, ConnReport} -> + Txn = fun() -> + lists:foreach( + fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, + Writes ++ MandRows) + end, + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> + {ok, RootNref, + merge_reports(CompOutcomes, ConnReport), + InstPlan, AutoConnPlan}; + {error, R} -> + {error, R, + report_not_attempted(R, + #{plan_so_far => PlanTree, culprit => undefined})} + end; + {error, Reason, ConnReport} -> + CompNA = report_not_attempted(Reason, + #{plan_so_far => InstPlan, culprit => undefined}), + {error, Reason, merge_reports(CompNA, ConnReport)} + end. +``` + +`fire_create/4` itself is textually unchanged (shown for context/diff +clarity) — only `execute/5`'s body changes, extracting `Project` from `Ctx` +once and threading it to `allocate_plan/2` and `plan_writes/3`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T4: creation cascade allocates from and writes to Project" +``` + +--- + +## Task 5: `graphdb_instance` — connection-arc write primitives take `Home` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Consumes: `graphdb_ns:node_table/1`, `rel_table/1`, `target_namespace/2` + (Task 1). +- Produces: `add_relationship_in_txn/10` (was `/9` — gains a leading `Home`), + `validate_arc_endpoints_in_txn/7` (was `/6`), `resolve_arc_classes_in_txn/3` + (was `/2`), `class_of_in_txn/2` (was `/1`), `build_connection_rows/8` (was + `/7`) and its `/9` twin (was `/8`), `write_connection_arcs/7` (was `/6`), + `do_add_relationship/8` (was `/7`, gains leading `Home`/`Project`). These + are consumed by Task 8 (remove/update) and by `graphdb_mgr:mutate` + (Task 11), which is why they take the general `Home :: environment | + Project` rather than a concrete `Project` — `graphdb_instance`'s own + callers always pass a concrete `Project` (Home ⊇ Project). + +`Home` here is `environment | Project` — reads of `CharNref`/`ReciprocalNref` +(always environment, per the design's field-role table: characterization and +reciprocal are always environment) stay on the literal `nodes` table +regardless of `Home`; only `SourceNref`/`TargetNref` route through `Home`. + +- [ ] **Step 1: `handle_call` for `add_relationship` passes `Home` through** + +```erlang +handle_call({add_relationship, Home, S, C, T, R, TemplateSpec, AVPSpec}, + _From, State) -> + {reply, + do_add_relationship(Home, S, C, T, R, TemplateSpec, AVPSpec, State), + State}; +``` + +- [ ] **Step 2: `do_add_relationship/8`** + +```erlang +do_add_relationship(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateSpec, AVPSpec, State) -> + TkAttr = State#state.target_kind_avp_nref, + RetAttr = State#state.retired_nref, + IdPair = case Home of + environment -> rel_id_server:get_id_pair(); + _ -> graphdb_project:next_rel_id_pair(Home) + end, + case graphdb_mgr:transaction(fun() -> + add_relationship_in_txn(Home, IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, + RetAttr) + end) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +- [ ] **Step 3: `add_relationship_in_txn/10`** + +```erlang +add_relationship_in_txn(Home, {_Id1, _Id2} = IdPair, SourceNref, CharNref, + TargetNref, ReciprocalNref, TemplateSpec, AVPSpec, TkAttr, RetAttr) -> + ok = validate_arc_endpoints_in_txn(Home, SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, RetAttr), + {SourceClass, TargetClass} = + resolve_arc_classes_in_txn(Home, SourceNref, TargetNref), + TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), + ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, + SourceClass, TargetClass), + Rows = build_connection_rows(Home, IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec), + lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, + Rows). +``` + +- [ ] **Step 4: `validate_arc_endpoints_in_txn/7`** + +Only the two reads of `Source`/`Target` change table; `Char`/`Recip` stay +literal `nodes` (always environment attribute nodes): + +```erlang +validate_arc_endpoints_in_txn(Home, SourceNref, CharNref, TargetNref, + ReciprocalNref, TkAttr, RetAttr) -> + Source = mnesia:read(graphdb_ns:node_table(Home), SourceNref), + Target = mnesia:read(graphdb_ns:node_table(Home), TargetNref), + Char = mnesia:read(nodes, CharNref), + Recip = mnesia:read(nodes, ReciprocalNref), + case {Source, Target, Char, Recip} of + {[], _, _, _} -> + mnesia:abort({source_not_found, SourceNref}); + {_, [], _, _} -> + mnesia:abort({target_not_found, TargetNref}); + {_, _, [], _} -> + mnesia:abort({characterization_not_found, CharNref}); + {_, _, _, []} -> + mnesia:abort({reciprocal_not_found, ReciprocalNref}); + {[#node{attribute_value_pairs = SAVPs}], + [#node{kind = TKind, attribute_value_pairs = TAVPs}], + [#node{kind = CKind, attribute_value_pairs = CAVPs} = CharNode], + [#node{kind = RKind, attribute_value_pairs = RAVPs}]} -> + case first_retired([{SourceNref, SAVPs}, {TargetNref, TAVPs}, + {CharNref, CAVPs}, {ReciprocalNref, RAVPs}], + RetAttr) of + {retired, RNref} -> + mnesia:abort({endpoint_retired, RNref}); + none -> + case {CKind, RKind} of + {attribute, attribute} -> + case check_target_kind(CharNode, TKind, TkAttr) of + ok -> ok; + {error, Reason} -> mnesia:abort(Reason) + end; + {attribute, _} -> + mnesia:abort({reciprocal_not_an_attribute, + ReciprocalNref, RKind}); + {_, _} -> + mnesia:abort({characterization_not_an_attribute, + CharNref, CKind}) + end + end + end. +``` + +`first_retired/2` and `check_target_kind/3` are unchanged (they operate on +already-read AVP lists, no table access). + +- [ ] **Step 5: `resolve_arc_classes_in_txn/3` and `class_of_in_txn/2`** + +```erlang +resolve_arc_classes_in_txn(Home, SourceNref, TargetNref) -> + SourceClass = case class_of_in_txn(Home, SourceNref) of + {ok, SC} -> SC; + not_found -> mnesia:abort({source_has_no_class, SourceNref}) + end, + TargetClass = case class_of_in_txn(Home, TargetNref) of + {ok, TC} -> TC; + not_found -> mnesia:abort({target_has_no_class, TargetNref}) + end, + {SourceClass, TargetClass}. + +class_of_in_txn(Home, InstanceNref) -> + Rels = mnesia:index_read(graphdb_ns:rel_table(Home), InstanceNref, + #relationship.source_nref), + case lists:search( + fun(R) -> + R#relationship.characterization =:= ?ARC_INST_TO_CLASS + end, Rels) of + {value, #relationship{target_nref = ClassNref}} -> {ok, ClassNref}; + false -> not_found + end. +``` + +`resolve_template_in_txn/2` is unchanged — it only calls +`graphdb_class:default_template_in_txn/1`, which is environment-only by +design (Task confirms no edit needed; do not touch). + +- [ ] **Step 6: `build_connection_rows` and `write_connection_arcs`** + +```erlang +%%----------------------------------------------------------------------------- +%% build_connection_rows(Home, S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) +%% -> [{RelsTable, #relationship{}}] +%%----------------------------------------------------------------------------- +build_connection_rows(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref, AVPSpec) -> + IdPair = case Home of + environment -> rel_id_server:get_id_pair(); + _ -> graphdb_project:next_rel_id_pair(Home) + end, + build_connection_rows(Home, IdPair, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec). + +%% Pure builder: no allocation. The caller supplies the rel-id pair. +build_connection_rows(Home, {Id1, Id2}, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, {FwdAVPs, RevAVPs}) -> + RelsTab = graphdb_ns:rel_table(Home), + TemplateAVP = #{attribute => ?ARC_TEMPLATE, value => TemplateNref}, + Fwd = #relationship{ + id = Id1, kind = connection, + source_nref = SourceNref, + characterization = CharNref, + target_nref = TargetNref, + reciprocal = ReciprocalNref, + avps = [TemplateAVP | FwdAVPs] + }, + Rev = #relationship{ + id = Id2, kind = connection, + source_nref = TargetNref, + characterization = ReciprocalNref, + target_nref = SourceNref, + reciprocal = CharNref, + avps = [TemplateAVP | RevAVPs] + }, + [{RelsTab, Fwd}, {RelsTab, Rev}]. + +%%----------------------------------------------------------------------------- +%% write_connection_arcs(Home, S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) -> +%% ok | {error, term()} +%%----------------------------------------------------------------------------- +write_connection_arcs(Home, SourceNref, CharNref, TargetNref, ReciprocalNref, + TemplateNref, AVPSpec) -> + Rows = build_connection_rows(Home, SourceNref, CharNref, TargetNref, + ReciprocalNref, TemplateNref, AVPSpec), + Txn = fun() -> + lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, + Rows) + end, + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {error, _} = Err -> Err + end. +``` + +Both connection rows use the SAME `RelsTab` (`SourceNref`/`TargetNref` are +always both-project or both-environment for a connection arc between two +instances of the same home — connection rules never cross project +boundaries per SP1's proxy-indirection contract, and this module never +builds a connection row between an instance and an environment node). + +Grep after this step: `grep -n "rel_id_server:get_id_pair" apps/graphdb/src/graphdb_instance.erl` +should show exactly the two call sites above (inside `do_add_relationship` +via `Home` branch already gone — recheck: `do_add_relationship` now branches +directly, `build_connection_rows/7` branches too) plus the two remaining in +`mandatory_rows`'s caller chain, which Task 6 removes. `mandatory_rows/4` +itself calls `build_connection_rows/6` (the OLD arity) at present — Task 6 +updates that call site since it lives in the connection-RESOLVE code path, +not the write-primitive code path this task covers. + +- [ ] **Step 7: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T5: add_relationship write primitives take Home" +``` + +--- + +## Task 6: `graphdb_instance` — connection-RESOLVE (composition-firing's mandatory/auto connections) and `validate_target` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Consumes: `build_connection_rows/8` (Task 5). +- Produces: `resolve_connections/2` (unchanged signature — reads `Project` + out of `Ctx`), `mandatory_rows/5` (was `/4`, gains `Project`), + `validate_target/4` (was `/3`, gains `Project`), `partition_targets/4` + (was `/3`, gains `Project`), `split_valid/4` (was `/3`, gains `Project`). + +This is entirely inside the `create_instance` cascade, so every site here +takes a concrete `Project` (never `environment`) — extracted once from +`Ctx` at `resolve_nodes/3` and threaded down. + +- [ ] **Step 1: `resolve_nodes/3` extracts `Project` and threads it into `resolve_rules/4`** + +`resolve_nodes/3` itself is unchanged (it already receives `Ctx`); the +change is in what it passes downstream. Update `resolve_rules/4`'s +`connect_targets` calls and `mandatory_rows`/`validate_target` call sites: + +```erlang +resolve_rules([], _SourceNref, _Ctx, Acc) -> + {ok, Acc}; +resolve_rules([{Rule, Deploy, Spec} | Rest], SourceNref, Ctx, Acc) -> + Mode = maps:get(mode, Deploy, mandatory), + case Mode of + propose -> + Acc1 = add_conn_outcome(Acc, Rule, Deploy, + conn_outcome_base(SourceNref, Spec, proposed)), + resolve_rules(Rest, SourceNref, Ctx, Acc1); + _ -> + Resolver = maps:get(resolver, Ctx), + case Resolver(conn_context(Rule, Deploy, Spec, SourceNref, Ctx)) of + defer -> + Status = case Mode of + mandatory -> required; + auto -> not_connected + end, + Acc1 = add_conn_outcome(Acc, Rule, Deploy, + conn_outcome_base(SourceNref, Spec, Status)), + resolve_rules(Rest, SourceNref, Ctx, Acc1); + {connect, List} -> + connect_targets(Mode, List, Rule, Deploy, Spec, SourceNref, + Rest, Ctx, Acc) + end + end. +``` + +(Unchanged — shown for context. `Ctx` already carries `project`; the +functions it calls are what change.) + +- [ ] **Step 2: `connect_targets/9` passes `Project` (from `Ctx`) into `partition_targets`/`split_valid`/`mandatory_rows`** + +```erlang +connect_targets(mandatory, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, + {Rows, Auto, Rep}) -> + Project = maps:get(project, Ctx), + TClass = maps:get(target_class, Spec), + case partition_targets(List, TClass, SourceNref, Project) of + {error, Reason} -> + {error, {invalid_connection_target, Reason}, + conn_fail({invalid_connection_target, Reason}, Rule, Spec, Rep)}; + {ok, Valid} -> + {Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + case length(Valid) < Min of + true -> + Reason = {mandatory_connection_unsatisfied, Rule#node.nref}, + {error, Reason, conn_fail(Reason, Rule, Spec, Rep)}; + false -> + ToWrite = cap(Valid, Max), + Template = maps:get(template, Deploy), + {NewRows, NewOuts} = + mandatory_rows(ToWrite, SourceNref, Spec, Template, + Project), + Rep1 = lists:foldl( + fun(O, R) -> add_outcome(R, Rule, Deploy, O) end, + Rep, NewOuts), + resolve_rules(Rest, SourceNref, Ctx, + {Rows ++ NewRows, Auto, Rep1}) + end + end; + +connect_targets(auto, List, Rule, Deploy, Spec, SourceNref, Rest, Ctx, + {Rows, Auto, Rep}) -> + Project = maps:get(project, Ctx), + TClass = maps:get(target_class, Spec), + {Valid, Invalid} = split_valid(List, TClass, SourceNref, Project), + {_Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + ToConnect = cap(Valid, Max), + Char = maps:get(characterization, Spec), + Rep1 = lists:foldl( + fun({_T, Reason}, R) -> + add_outcome(R, Rule, Deploy, + #{source => SourceNref, index => 1, status => failed, + reason => Reason, characterization => Char, + target_class => TClass}) + end, Rep, Invalid), + AutoEntry = #{rule => Rule, deploy => Deploy, spec => Spec, + source => SourceNref, template => maps:get(template, Deploy), + targets => ToConnect, project => Project}, + resolve_rules(Rest, SourceNref, Ctx, {Rows, Auto ++ [AutoEntry], Rep1}). +``` + +Note the `auto` branch's `AutoEntry` map gains a `project` key — the +post-commit auto-connection writer (`fire_connections/1`, unchanged in this +task, reads `AutoEntry` maps built here) needs it to build its own +connection rows later. Verify `fire_connections/1`'s existing body pulls +`Project` out of each `AutoEntry` when it calls `write_connection_arcs/7` — +grep `fire_connections` and confirm; if it currently calls +`write_connection_arcs` positionally, update that one call site to pass +`maps:get(project, AutoEntry)` as the new leading `Home` argument. + +- [ ] **Step 3: `mandatory_rows/5`, `validate_target/4`, `partition_targets/4`, `split_valid/4`** + +```erlang +mandatory_rows(Targets, SourceNref, Spec, Template, Project) -> + Char = maps:get(characterization, Spec), + Recip = maps:get(reciprocal, Spec), + TClass = maps:get(target_class, Spec), + {Rows, Outs, _} = lists:foldl( + fun(T, {RAcc, OAcc, I}) -> + TNref = target_nref(T), + Rows0 = build_connection_rows(Project, SourceNref, Char, TNref, + Recip, Template, target_avps(T)), + Out = #{source => SourceNref, index => I, status => connected, + target => TNref, characterization => Char, + target_class => TClass}, + {RAcc ++ Rows0, OAcc ++ [Out], I + 1} + end, {[], [], 1}, Targets), + {Rows, Outs}. + +validate_target(Target, TargetClass, _SourceNref, Project) -> + Nref = target_nref(Target), + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [#node{kind = instance, classes = Classes}] -> + case lists:any( + fun(C) -> graphdb_class:class_in_ancestry(TargetClass, C) end, + Classes) of + true -> ok; + false -> {error, {target_class_mismatch, Nref, TargetClass}} + end; + [#node{}] -> {error, {target_not_an_instance, Nref}}; + [] -> {error, {target_not_found, Nref}} + end. + +partition_targets([], _TClass, _SourceNref, _Project) -> + {ok, []}; +partition_targets([T | Rest], TClass, SourceNref, Project) -> + case validate_target(T, TClass, SourceNref, Project) of + ok -> + case partition_targets(Rest, TClass, SourceNref, Project) of + {ok, Vs} -> {ok, [T | Vs]}; + {error, _} = Err -> Err + end; + {error, Reason} -> + {error, Reason} + end. + +split_valid(List, TClass, SourceNref, Project) -> + lists:foldr( + fun(T, {Vs, Is}) -> + case validate_target(T, TClass, SourceNref, Project) of + ok -> {[T | Vs], Is}; + {error, Reason} -> {Vs, [{T, Reason} | Is]} + end + end, {[], []}, List). +``` + +`build_connection_rows(Project, SourceNref, ...)` above calls the Task 5 +`/7` head (`Home` = `Project` here — a concrete project handle satisfies the +`Home` type). + +- [ ] **Step 4: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T6: connection RESOLVE (mandatory/auto) routes through Project" +``` + +--- + +## Task 7: `graphdb_instance` — remove/update relationship primitives take `Home` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Consumes: Task 5's `graphdb_ns:rel_table/1` usage pattern. +- Produces: `resolve_forward_connection/5` (was `/4`, gains leading `Home`), + `remove_relationship_in_txn/5` (was `/4`), `update_relationship_avps_in_txn/6` + (was `/5`), `update_relationship_both_in_txn/7` (was `/6`), + `do_update_relationship/6` (was `/5`), `do_update_both/7` (was `/6`). + +- [ ] **Step 1: `resolve_forward_connection/5`** + +```erlang +resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, TemplateSpec) -> + Rows = mnesia:index_read(graphdb_ns:rel_table(Home), SourceNref, + #relationship.source_nref), + Matches = [R || R <- Rows, + R#relationship.kind =:= connection, + R#relationship.characterization =:= CharNref, + R#relationship.target_nref =:= TargetNref, + template_matches(R, TemplateSpec)], + case Matches of + [] -> not_found; + [Row] -> {ok, Row}; + Many -> {ambiguous, [template_of(R) || R <- Many]} + end. +``` + +`template_matches/2` and `template_of/1` are unchanged (operate on an +already-read record). + +- [ ] **Step 2: `remove_relationship_in_txn/5`** + +```erlang +remove_relationship_in_txn(Home, SourceNref, CharNref, TargetNref, TemplateSpec) -> + case resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, + TemplateSpec) of + not_found -> + mnesia:abort(relationship_not_found); + {ambiguous, Templates} -> + mnesia:abort({ambiguous_relationship, Templates}); + {ok, Fwd} -> + Recip = Fwd#relationship.reciprocal, + Tmpl = template_of(Fwd), + case resolve_forward_connection(Home, TargetNref, Recip, SourceNref, + Tmpl) of + {ok, Rev} -> + RelsTab = graphdb_ns:rel_table(Home), + ok = mnesia:delete_object(RelsTab, Fwd, write), + ok = mnesia:delete_object(RelsTab, Rev, write); + _ -> + mnesia:abort({dangling_half_edge, Fwd#relationship.id}) + end + end. +``` + +- [ ] **Step 3: `update_relationship_avps_in_txn/6`** + +```erlang +update_relationship_avps_in_txn(Home, SourceNref, CharNref, TargetNref, + TemplateSpec, Updates) -> + case has_template_update(Updates) of + true -> + mnesia:abort({protected_relationship_avp, ?ARC_TEMPLATE}); + false -> + case resolve_forward_connection(Home, SourceNref, CharNref, + TargetNref, TemplateSpec) of + not_found -> + mnesia:abort(relationship_not_found); + {ambiguous, Templates} -> + mnesia:abort({ambiguous_relationship, Templates}); + {ok, Row} -> + New = graphdb_mgr:apply_avp_updates( + Row#relationship.avps, Updates), + mnesia:write(graphdb_ns:rel_table(Home), + Row#relationship{avps = New}, write) + end + end. +``` + +- [ ] **Step 4: `do_update_relationship/6` and public `update_relationship/5,6`** + +```erlang +do_update_relationship(Home, SourceNref, CharNref, TargetNref, TemplateSpec, + Updates) -> + case graphdb_mgr:validate_avp_updates(Updates) of + ok -> + txn_ok(fun() -> + update_relationship_avps_in_txn(Home, SourceNref, CharNref, + TargetNref, TemplateSpec, Updates) + end); + {error, _} = Err -> + Err + end. +``` + +(Task 3 already updated the public `update_relationship/5,6` heads to call +`do_update_relationship(P, ...)` — this step is the body those heads call.) + +- [ ] **Step 5: `update_relationship_both_in_txn/7` and `do_update_both/7`** + +```erlang +update_relationship_both_in_txn(Home, SourceNref, CharNref, TargetNref, + TemplateSpec, FwdUpdates, RevUpdates) -> + case resolve_forward_connection(Home, SourceNref, CharNref, TargetNref, + TemplateSpec) of + not_found -> + mnesia:abort(relationship_not_found); + {ambiguous, Templates} -> + mnesia:abort({ambiguous_relationship, Templates}); + {ok, Fwd} -> + Recip = Fwd#relationship.reciprocal, + Tmpl = template_of(Fwd), + case resolve_forward_connection(Home, TargetNref, Recip, SourceNref, + Tmpl) of + {ok, _Rev} -> + ok = update_relationship_avps_in_txn(Home, SourceNref, + CharNref, TargetNref, Tmpl, FwdUpdates), + ok = update_relationship_avps_in_txn(Home, TargetNref, + Recip, SourceNref, Tmpl, RevUpdates); + _ -> + mnesia:abort({dangling_half_edge, Fwd#relationship.id}) + end + end. + +do_update_both(Home, SourceNref, CharNref, TargetNref, TemplateSpec, Fwd, Rev) -> + case {graphdb_mgr:validate_avp_updates(Fwd), + graphdb_mgr:validate_avp_updates(Rev)} of + {ok, ok} -> + txn_ok(fun() -> + update_relationship_both_in_txn(Home, SourceNref, CharNref, + TargetNref, TemplateSpec, Fwd, Rev) + end); + {{error, _} = Err, _} -> Err; + {_, {error, _} = Err} -> Err + end. +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T7: remove/update relationship primitives take Home" +``` + +--- + +## Task 8: `graphdb_instance` — instance reads gain `Project` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Produces: `get_instance/2`, `children/2`, `compositional_ancestors/2`, + `class_of/2`, `class_memberships/2`, `resolve_value/3` (each gains a + leading `Project`; the reads have no environment-only use case so, unlike + Task 5-7, these take a concrete `Project`, not `Home`). + +- [ ] **Step 1: Public API heads** + +```erlang +get_instance(Project, Nref) -> + gen_server:call(?MODULE, {get_instance, Project, Nref}). + +children(Project, Nref) -> + gen_server:call(?MODULE, {children, Project, Nref}). + +compositional_ancestors(Project, Nref) -> + gen_server:call(?MODULE, {compositional_ancestors, Project, Nref}). + +class_of(Project, InstanceNref) -> + gen_server:call(?MODULE, {class_of, Project, InstanceNref}). + +class_memberships(Project, InstanceNref) -> + gen_server:call(?MODULE, {class_memberships, Project, InstanceNref}). + +resolve_value(Project, InstanceNref, AttrNref) -> + gen_server:call(?MODULE, {resolve_value, Project, InstanceNref, AttrNref}). +``` + +- [ ] **Step 2: `handle_call` clauses** + +```erlang +handle_call({get_instance, Project, Nref}, _From, State) -> + {reply, do_get_instance(Project, Nref), State}; + +handle_call({children, Project, Nref}, _From, State) -> + {reply, do_children(Project, Nref), State}; + +handle_call({compositional_ancestors, Project, Nref}, _From, State) -> + {reply, do_compositional_ancestors(Project, Nref), State}; + +handle_call({class_of, Project, Nref}, _From, State) -> + {reply, do_class_of(Project, Nref), State}; + +handle_call({class_memberships, Project, Nref}, _From, State) -> + {reply, do_class_memberships(Project, Nref), State}; + +handle_call({resolve_value, Project, InstNref, AttrNref}, _From, State) -> + {reply, do_resolve_value(Project, InstNref, AttrNref), State}; +``` + +- [ ] **Step 3: `do_get_instance/2`, `do_children/2`, `do_compositional_ancestors/2` + `do_walk_ancestors/3`, `downward_children_by_arc/4`** + +```erlang +do_get_instance(Project, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [#node{kind = instance} = Node] -> {ok, Node}; + [_Other] -> {error, not_an_instance}; + [] -> {error, not_found} + end. + +do_children(Project, Nref) -> + F = fun() -> + Children = downward_children_by_arc(Project, Nref, ?ARC_INST_CHILD, + composition), + [N || N <- Children, N#node.kind =:= instance] + end, + graphdb_mgr:transaction(F). + +do_compositional_ancestors(Project, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [#node{kind = instance, parents = Parents}] -> + do_walk_ancestors(Project, head_parent(Parents), []); + [_] -> + {error, not_an_instance}; + [] -> + {error, not_found} + end. + +do_walk_ancestors(_Project, undefined, Acc) -> + {ok, lists:reverse(Acc)}; +do_walk_ancestors(Project, Nref, Acc) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [#node{kind = instance, parents = Parents} = Node] -> + do_walk_ancestors(Project, head_parent(Parents), [Node | Acc]); + [_] -> + {ok, lists:reverse(Acc)}; + [] -> + {ok, lists:reverse(Acc)} + end. + +downward_children_by_arc(Project, ParentNref, ChildArc, RelKind) -> + Arcs = mnesia:index_read(graphdb_ns:rel_table(Project), ParentNref, + #relationship.source_nref), + Nrefs = [A#relationship.target_nref || A <- Arcs, + A#relationship.kind =:= RelKind, + A#relationship.characterization =:= ChildArc], + lists:flatmap(fun(N) -> mnesia:read(graphdb_ns:node_table(Project), N) end, + Nrefs). +``` + +- [ ] **Step 4: `do_class_of/2`, `do_class_memberships/2` (unchanged body, gains `Project` passthrough), `do_resolve_value/3` + its helpers** + +```erlang +do_class_of(Project, InstanceNref) -> + F = fun() -> + Rels = mnesia:index_read(graphdb_ns:rel_table(Project), InstanceNref, + #relationship.source_nref), + lists:search( + fun(R) -> + R#relationship.characterization =:= ?ARC_INST_TO_CLASS + end, Rels) + end, + case graphdb_mgr:transaction(F) of + {ok, {value, #relationship{target_nref = ClassNref}}} -> + {ok, ClassNref}; + {ok, false} -> not_found; + {error, Reason} -> {error, Reason} + end. + +do_class_memberships(Project, InstanceNref) -> + case do_get_instance(Project, InstanceNref) of + {ok, #node{classes = Classes}} -> {ok, Classes}; + {error, _} = Err -> Err + end. + +do_resolve_value(Project, InstNref, AttrNref) -> + case do_get_instance(Project, InstNref) of + {ok, Node} -> + case find_avp_value(Node#node.attribute_value_pairs, AttrNref) of + {ok, V} -> + {ok, V, local}; + not_found -> + resolve_value_priority_2_and_below(Project, Node, AttrNref) + end; + {error, _} = Err -> + Err + end. +``` + +`resolve_value_priority_2_and_below/3` names whatever internal continuation +`do_resolve_value/2` currently falls through to after the local-AVP check — +**read the current function body from `apps/graphdb/src/graphdb_instance.erl` +starting at `do_resolve_value/2` (around line 2001) before writing this +step**, since only the excerpt above (through the Priority-1 local check) +was captured verbatim during planning; preserve every subsequent priority +branch's logic exactly, threading `Project` into the two helpers below in +place of the bare `mnesia:dirty_read(nodes, ...)` calls they make. + +```erlang +resolve_from_ancestors(_Project, undefined, _AttrNref) -> + not_found; +resolve_from_ancestors(Project, ParentNref, AttrNref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), ParentNref) of + [#node{kind = instance, parents = GrandParents, + attribute_value_pairs = AVPs}] -> + case find_avp_value(AVPs, AttrNref) of + {ok, V} -> {ok, V, ParentNref}; + not_found -> resolve_from_ancestors(Project, + head_parent(GrandParents), AttrNref) + end; + [_] -> + not_found; + [] -> + not_found + end. + +resolve_from_connected(Project, InstNref, AttrNref) -> + F = fun() -> + mnesia:index_read(graphdb_ns:rel_table(Project), InstNref, + #relationship.source_nref) + end, + case graphdb_mgr:transaction(F) of + {ok, Rels} -> + TargetNrefs = lists:usort( + [R#relationship.target_nref + || R <- Rels, R#relationship.kind =:= connection]), + search_targets(Project, TargetNrefs, AttrNref); + {error, _} -> + not_found + end. + +search_targets(_Project, [], _AttrNref) -> + not_found; +search_targets(Project, [Nref | Rest], AttrNref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [#node{attribute_value_pairs = AVPs}] -> + case find_avp_value(AVPs, AttrNref) of + {ok, V} -> {ok, V, Nref}; + not_found -> search_targets(Project, Rest, AttrNref) + end; + _ -> + search_targets(Project, Rest, AttrNref) + end. +``` + +Priority 2 (`graphdb_class:search_class_taxonomy/2`) is unchanged — it takes +a class nref and reads only environment class nodes, no `Project` needed. + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T8: instance reads (get_instance/children/ancestors/class_of/resolve_value) take Project" +``` + +--- + +## Task 9: `graphdb_instance` — `add_class_membership` routes through `Project` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_instance.erl` + +**Interfaces:** +- Produces: `do_add_class_membership/5` (was `/4`, gains leading `Project`), + `do_write_class_membership/3` (was `/2`). + +- [ ] **Step 1: `handle_call`** + +```erlang +handle_call({add_class_membership, Project, InstanceNref, ClassNref}, _From, + #state{instantiable_nref = InstAttr, retired_nref = RetAttr} = State) -> + {reply, do_add_class_membership(Project, InstanceNref, ClassNref, InstAttr, + RetAttr), State}; +``` + +- [ ] **Step 2: `do_add_class_membership/5` and `do_write_class_membership/3`** + +```erlang +do_add_class_membership(Project, InstanceNref, ClassNref, InstAttr, RetAttr) -> + case do_get_instance(Project, InstanceNref) of + {ok, _} -> + case do_validate_class(ClassNref, InstAttr, RetAttr) of + ok -> do_write_class_membership(Project, + InstanceNref, ClassNref); + {error, _} = Err -> Err + end; + {error, _} = Err -> + Err + end. + +do_write_class_membership(Project, InstanceNref, ClassNref) -> + {Id1, Id2} = graphdb_project:next_rel_id_pair(Project), + NodesTab = graphdb_ns:node_table(Project), + RelsTab = graphdb_ns:rel_table(Project), + Txn = fun() -> + [#node{kind = instance, classes = Classes} = Node] = + mnesia:read(NodesTab, InstanceNref), + case lists:member(ClassNref, Classes) of + true -> + already_exists; + false -> + I2C = #relationship{ + id = Id1, kind = instantiation, + source_nref = InstanceNref, + characterization = ?ARC_INST_TO_CLASS, + target_nref = ClassNref, + reciprocal = ?ARC_CLASS_TO_INST, + avps = [] + }, + C2I = #relationship{ + id = Id2, kind = instantiation, + source_nref = ClassNref, + characterization = ?ARC_CLASS_TO_INST, + target_nref = InstanceNref, + reciprocal = ?ARC_INST_TO_CLASS, + avps = [] + }, + Updated = Node#node{classes = Classes ++ [ClassNref]}, + ok = mnesia:write(NodesTab, Updated, write), + ok = mnesia:write(RelsTab, I2C, write), + ok = mnesia:write(RelsTab, C2I, write), + ok + end + end, + case graphdb_mgr:transaction(Txn) of + {ok, ok} -> ok; + {ok, already_exists} -> ok; + {error, _} = Err -> Err + end. +``` + +(`I2C`/`C2I` both go to `RelsTab` — same co-location exception documented in +Task 4, Step 4 for `instance_records/5`.) + +- [ ] **Step 3: Verify the module compiles end-to-end and its own EUnit-in-source helpers still resolve** + +Run: `./rebar3 compile --app=graphdb` (or the whole umbrella). +Expected: `graphdb_instance.erl` compiles cleanly. This is the first point +since Task 3 where the module is expected to be internally consistent — +resolve any remaining arity mismatches now by grepping for the old arities: + +```bash +grep -n "with_session\|open_session\|session_project\|require_session" apps/graphdb/src/graphdb_instance.erl +``` + +Expected: no matches (all renamed in Tasks 3-9). + +- [ ] **Step 4: Commit** + +```bash +git add apps/graphdb/src/graphdb_instance.erl +git commit -m "SP2 T9: add_class_membership routes through Project; module compiles end-to-end" +``` + +--- + +## Task 10: `graphdb_mgr` — `Project`-taking twins of `get_node`, `retire_node`, `unretire_node`, `update_node_avps`, `delete_node` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` + +**Interfaces:** +- Consumes: `graphdb_ns:node_table/1` (Task 1), `graphdb_project:require_project/1` + (Task 2). +- Produces: `get_node/2`, `retire_node/2`, `unretire_node/2`, + `update_node_avps/3`, `delete_node/2`. The existing `/1` (and `/2` for + `update_node_avps`) forms are UNCHANGED — they stay environment-only, + same behaviour as today. This closes the collision gap identified during + planning (approved by the user): before this task, calling e.g. + `update_node_avps(5, AVPs)` for what the caller means as "project + instance 5" would silently edit the environment's nref-5 node instead. + +Unlike `retire_node/1`'s existing `Nref < ?NREF_START` permanent-tier guard, +the new `/2` forms have **no** tier guard — per the design, a project's +allocator has no permanent tier at all (§4: "Project: allocator starts at +1 — no pre-assigned nrefs, no bootstrap file, no floor needed"), so every +project nref is always mutable. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/graphdb/test/graphdb_mgr_SUITE.erl` (exact placement: alongside +the existing `retire_node`/`update_node_avps` test groups; add to `all/0` +and the test `-export`): + +```erlang +get_node_2_reads_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + {ok, #node{nref = Nref, kind = instance}} = + graphdb_mgr:get_node(Project, Nref). + +get_node_2_does_not_leak_into_environment_table(_Config) -> + Project = proj(), + %% Project instance nref 1 must not resolve to the environment's nref 1 + %% (Root, a category node). + {ok, 1, _Report} = graphdb_instance:create_instance(Project, "First", + widget_class(), root_instance(Project)), + {ok, #node{kind = instance}} = graphdb_mgr:get_node(Project, 1), + {ok, #node{kind = category}} = graphdb_mgr:get_node(?NREF_ROOT). + +retire_node_2_retires_a_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + ok = graphdb_mgr:retire_node(Project, Nref), + {ok, Node} = graphdb_mgr:get_node(Project, Nref), + ?assert(graphdb_mgr:has_true_avp(Node)). + +update_node_avps_3_edits_a_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + Colour = ensure_colour_attribute(), + ok = graphdb_mgr:update_node_avps(Project, Nref, + [#{attribute => Colour, value => "blue"}]), + {ok, #node{attribute_value_pairs = AVPs}} = + graphdb_mgr:get_node(Project, Nref), + ?assertEqual({ok, "blue"}, find_avp(AVPs, Colour)). + +delete_node_2_reports_not_implemented(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + ?assertEqual({error, not_implemented}, graphdb_mgr:delete_node(Project, Nref)). +``` + +Note: `widget_class/0`, `root_instance/1`, `ensure_colour_attribute/0`, +`find_avp/2` are placeholders for whatever helper names +`graphdb_mgr_SUITE.erl` already uses for "create a throwaway class" / +"create a throwaway project-root instance" / "look up an AVP by attribute +nref" — **read the existing test bodies in that suite before writing +these** (the CT suite already has instance-creation tests exercising this +exact setup for other purposes; reuse its established helpers rather than +inventing new ones, per this codebase's convention of one helper per +concern reused across a suite's test cases). + +- [ ] **Step 2: Run to verify these fail** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_mgr_SUITE` +Expected: undef errors for `get_node/2`, `retire_node/2`, +`update_node_avps/3`, `delete_node/2` (once `proj()` exists — see Task 12; +if Task 12 hasn't landed yet in your execution order, stub `proj()` locally +in this suite first by copying Task 12's helper, then let Task 12 remove +the duplicate). + +- [ ] **Step 3: Add the exports and public heads** + +```erlang +-export([ + start_link/0, + %% Read operations + get_node/1, + get_node/2, + get_relationships/1, + get_relationships/2, + %% Write operations (delegate to workers) + create_attribute/3, + create_class/2, + create_instance/4, + add_relationship/5, + delete_node/1, + delete_node/2, + retire_node/1, + retire_node/2, + unretire_node/1, + unretire_node/2, + update_node_avps/2, + update_node_avps/3, + %% Batch write (tier-3 entry point) + mutate/1, + mutate/2, + %% Tier-1 in-txn write primitive (composed by mutate/1,2) + update_node_avps_in_txn/4, + %% Transaction helper (write-path seam) + transaction/1, + %% Cache invariant audit / repair + verify_caches/0, + rebuild_caches/0 + ]). +``` + +(`update_node_avps_in_txn/3` becomes `/4` — Step 5 below. `mutate/2` is +Task 11.) + +```erlang +%%----------------------------------------------------------------------------- +%% get_node(Project, Nref) -> {ok, #node{}} | {error, not_found | term()} +%% +%% Reads a single node from Project's own nodes table. Unlike get_node/1, +%% no retired-marker check -- SP1/SP2 have not extended the retired-read +%% guard to the project write path; project reads return the raw node. +%%----------------------------------------------------------------------------- +get_node(Project, Nref) -> + gen_server:call(?MODULE, {get_node, Project, Nref}). + +%%----------------------------------------------------------------------------- +%% delete_node(Project, Nref) -> ok | {error, term()} +%% Project-scoped twin of delete_node/1. Actual deletion not yet implemented. +%%----------------------------------------------------------------------------- +delete_node(Project, Nref) -> + gen_server:call(?MODULE, {delete_node, Project, Nref}). + +%%----------------------------------------------------------------------------- +%% retire_node(Project, Nref) -> ok | {error, Reason} +%% unretire_node(Project, Nref) -> ok | {error, Reason} +%% +%% Project-scoped twins. No permanent-tier guard: a project's allocator has +%% no permanent tier (design §4) -- every project nref is mutable. +%%----------------------------------------------------------------------------- +retire_node(Project, Nref) -> + gen_server:call(?MODULE, {retire_node, Project, Nref}). + +unretire_node(Project, Nref) -> + gen_server:call(?MODULE, {unretire_node, Project, Nref}). + +%%----------------------------------------------------------------------------- +%% update_node_avps(Project, Nref, AVPs) -> ok | {error, term()} +%% Project-scoped twin of update_node_avps/2. +%%----------------------------------------------------------------------------- +-spec update_node_avps(map(), integer(), [map()]) -> ok | {error, term()}. +update_node_avps(Project, Nref, AVPs) -> + case validate_avp_updates(AVPs) of + ok -> + gen_server:call(?MODULE, {update_node_avps, Project, Nref, AVPs}); + {error, _} = Err -> + Err + end. +``` + +- [ ] **Step 4: `handle_call` clauses** + +```erlang +handle_call({get_node, Project, Nref}, _From, State) -> + {reply, do_get_node(Project, Nref), State}; + +handle_call({retire_node, Project, Nref}, _From, State0) -> + {Reply, State} = set_retired(Project, Nref, true, State0), + {reply, Reply, State}; +handle_call({unretire_node, Project, Nref}, _From, State0) -> + {Reply, State} = set_retired(Project, Nref, false, State0), + {reply, Reply, State}; + +handle_call({delete_node, Project, Nref}, _From, State) -> + case check_category_guard(Project, Nref) of + {error, _} = Err -> + {reply, Err, State}; + ok -> + {reply, {error, not_implemented}, State} + end; + +handle_call({update_node_avps, Project, Nref, AVPs}, _From, State) -> + case check_category_guard(Project, Nref) of + {error, _} = Err -> + {reply, Err, State}; + ok -> + {Reply, State1} = do_update_node_avps(Project, Nref, AVPs, State), + {reply, Reply, State1} + end; +``` + +These are NEW clauses added alongside the existing `{get_node, Nref}`, +`{retire_node, Nref}`, `{unretire_node, Nref}`, `{delete_node, Nref}`, +`{update_node_avps, Nref, AVPs}` clauses (which stay byte-for-byte +unchanged — do not touch them). + +- [ ] **Step 5: Internal helpers gain `Home` overloads** + +```erlang +%%----------------------------------------------------------------------------- +%% do_get_node(Home, Nref) -> {ok, #node{}} | {error, not_found} +%%----------------------------------------------------------------------------- +do_get_node(Home, Nref) -> + case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of + [Node] -> {ok, Node}; + [] -> {error, not_found} + end. +``` + +Existing `do_get_node/1` stays; add this `/2` clause alongside it (do not +collapse them — `do_get_node/1` is called from `check_category_guard/1`, +`handle_call({get_node, Nref}, ...)`, `set_retired/3`'s existing arity, and +`ensure_retired_nref` is unrelated; keep both arities distinct rather than +threading `environment` through every existing call site). + +```erlang +%%----------------------------------------------------------------------------- +%% check_category_guard(Home, Nref) -> ok | {error, ...} +%%----------------------------------------------------------------------------- +check_category_guard(Home, Nref) -> + case do_get_node(Home, Nref) of + {ok, #node{kind = category}} -> + {error, category_nodes_are_immutable}; + {ok, _} -> + ok; + {error, _} = Err -> + Err + end. + +%%----------------------------------------------------------------------------- +%% set_retired(Project, Nref, Bool, State) -> {ok | {error, Reason}, State'} +%% No permanent-tier guard for a project (see moduledoc above retire_node/2). +%%----------------------------------------------------------------------------- +set_retired(Project, Nref, Bool, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> set_retired_(Project, Nref, Bool, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. + +set_retired_(Home, Nref, Bool, RetAttr) -> + NodesTab = graphdb_ns:node_table(Home), + case mnesia:read(NodesTab, Nref, write) of + [] -> mnesia:abort(not_found); + [Node] -> + AVPs0 = Node#node.attribute_value_pairs, + AVPs1 = set_marker(AVPs0, RetAttr, Bool), + mnesia:write(NodesTab, + Node#node{attribute_value_pairs = AVPs1}, write) + end. + +%%----------------------------------------------------------------------------- +%% do_update_node_avps(Project, Nref, AVPs, State) -> {ok | {error, Reason}, State'} +%% No permanent-tier guard for a project. +%%----------------------------------------------------------------------------- +do_update_node_avps(Project, Nref, AVPs, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> update_node_avps_in_txn(Project, Nref, AVPs, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. +``` + +**IMPORTANT — this changes `set_retired_/3` and `update_node_avps_in_txn/3`'s +existing arity**, both of which are already exported / called elsewhere +(`update_node_avps_in_txn/3` is in the module's export list and is a +documented "Tier-1 in-txn write primitive composed by mutate/1"). Rename the +EXISTING environment-only 3-arg/3-arg forms to also take `Home`, and update +their two existing call sites (`set_retired/3`'s body, and +`do_update_node_avps/3`'s body) to pass the literal atom `environment`: + +```erlang +%%----------------------------------------------------------------------------- +%% set_retired(Nref, Bool, State) -> {ok | {error, Reason}, State'} (env-only) +%%----------------------------------------------------------------------------- +set_retired(Nref, _Bool, State) when Nref < ?NREF_START -> + {{error, permanent_node_immutable}, State}; +set_retired(Nref, Bool, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> set_retired_(environment, Nref, Bool, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. +``` + +```erlang +%%----------------------------------------------------------------------------- +%% do_update_node_avps(Nref, AVPs, State) -> {ok | {error, Reason}, State'} (env-only) +%%----------------------------------------------------------------------------- +do_update_node_avps(Nref, _AVPs, State) when Nref < ?NREF_START -> + {{error, permanent_node_immutable}, State}; +do_update_node_avps(Nref, AVPs, State0) -> + {RetAttr, State} = ensure_retired_nref(State0), + Reply = case graphdb_mgr:transaction( + fun() -> update_node_avps_in_txn(environment, Nref, AVPs, RetAttr) end) of + {ok, ok} -> ok; + {error, _}=E -> E + end, + {Reply, State}. +``` + +```erlang +%%----------------------------------------------------------------------------- +%% update_node_avps_in_txn(Home, Nref, AVPs, RetAttr) -> ok +%%----------------------------------------------------------------------------- +update_node_avps_in_txn(Home, Nref, AVPs, RetAttr) -> + NodesTab = graphdb_ns:node_table(Home), + case mnesia:read(NodesTab, Nref, write) of + [] -> + mnesia:abort(not_found); + [Node] -> + ok = guard_retired_marker(AVPs, RetAttr), + ok = guard_instance_only(Node#node.attribute_value_pairs, AVPs), + ok = guard_attribute_existence(AVPs), + New = apply_avp_updates(Node#node.attribute_value_pairs, AVPs), + mnesia:write(NodesTab, Node#node{attribute_value_pairs = New}, write) + end. +``` + +`guard_attribute_existence/1` is unchanged — it validates AVP `attribute` +nrefs against the environment attribute library regardless of which node's +AVPs are being edited (attribute nrefs are always environment). Do not +thread `Home` into it. + +Update the export list entry from `update_node_avps_in_txn/3` to +`update_node_avps_in_txn/4` (already reflected in Step 3's export block +above) and grep for any other caller of the old arity: + +```bash +grep -rn "update_node_avps_in_txn\|set_retired_(" apps/graphdb/src/ apps/graphdb/test/ +``` + +Fix every remaining `/3`-arity call site found (there should be none left +after this task and Task 11). + +- [ ] **Step 6: Run to verify tests pass** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_mgr_SUITE` +Expected: PASS on the five new tests (full suite may still fail until +Task 12 fixes `proj()`/`sess()` — note and continue if so). + +- [ ] **Step 7: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/test/graphdb_mgr_SUITE.erl +git commit -m "SP2 T10: get_node/retire_node/unretire_node/update_node_avps/delete_node gain Project-taking twins" +``` + +--- + +## Task 11: `graphdb_mgr` — `mutate/2` + +**Files:** +- Modify: `apps/graphdb/src/graphdb_mgr.erl` +- Modify: `apps/graphdb/test/graphdb_mgr_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_instance:add_relationship_in_txn/10`, + `remove_relationship_in_txn/5`, `update_relationship_avps_in_txn/6`, + `update_relationship_both_in_txn/7` (Tasks 5, 7); `update_node_avps_in_txn/4`, + `set_retired_/4` (Task 10). +- Produces: `mutate/2 :: (Project, [Mutation]) -> {ok, [term()]} | {error, + term()}`. `mutate/1`'s existing behaviour and grammar are UNCHANGED — + internally it now delegates to a `Home`-parameterised implementation with + `Home = environment`. + +- [ ] **Step 1: Write the failing test** + +Add to `graphdb_mgr_SUITE.erl`: + +```erlang +mutate_2_batches_within_one_project(_Config) -> + Project = proj(), + {ok, Root, _} = graphdb_instance:create_instance(Project, "Root", + widget_class(), root_instance(Project)), + {ok, A, _} = graphdb_instance:create_instance(Project, "A", widget_class(), + Root), + {ok, B, _} = graphdb_instance:create_instance(Project, "B", widget_class(), + Root), + {Char, Recip} = connects_to_attrs(), + {ok, [ok]} = graphdb_mgr:mutate(Project, + [{add_relationship, A, Char, B, Recip}]), + {ok, #node{}} = graphdb_mgr:get_node(Project, A). + +mutate_1_still_rejects_permanent_tier(_Config) -> + ?assertEqual({error, permanent_node_immutable}, + graphdb_mgr:mutate([{retire_node, ?NREF_ROOT}])). +``` + +(`connects_to_attrs/0` — reuse whatever existing helper the suite already +has for a reciprocal connection-attribute pair, or add one following the +pattern of the suite's existing `add_relationship` tests.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_mgr_SUITE` +Expected: undef `graphdb_mgr:mutate/2`. + +- [ ] **Step 3: Add `mutate/2` and thread `Home` through the batch pipeline** + +```erlang +%%----------------------------------------------------------------------------- +%% mutate(Project, [Mutation]) -> {ok, [term()]} | {error, term()} +%% +%% Project-scoped twin of mutate/1: applies the same mutation grammar, but +%% resolves Home = Project instead of environment, so add_relationship / +%% remove_relationship / update_relationship(_both) / update_node_avps / +%% retire_node / unretire_node all touch Project's own tables. A batch may +%% still mix environment and project references (an add_relationship whose +%% Char/Recip are environment attribute nrefs, as always) but spans at most +%% one project plus the environment (design §7). +%%----------------------------------------------------------------------------- +-spec mutate(map(), [tuple()]) -> {ok, [term()]} | {error, term()}. +mutate(Project, Mutations) -> + do_mutate(Project, Mutations). +``` + +Rename the existing `mutate/1` body to delegate: + +```erlang +-spec mutate([tuple()]) -> {ok, [term()]} | {error, term()}. +mutate(Mutations) -> + do_mutate(environment, Mutations). + +do_mutate(Home, Mutations) -> + case validate_mutations(Mutations) of + ok -> run_mutations(Home, Mutations); + {error, _} = Err -> Err + end. +``` + +`validate_mutations/1` and `validate_mutation/1` are UNCHANGED (static, +no table access — the permanent-tier guard in `tier_guard/1` stays +environment-tier-shaped deliberately: a `mutate/2` batch's `retire_node` +target could be either an environment nref, still subject to the tier +guard, or a project nref, which per Task 10 has no tier at all). + +Add a `tier_guard/2` overload used only from the `Home`-aware path, and have +`prepare/2`'s `retire_node`/`unretire_node`/`update_node_avps` arms carry +`Home` forward for `dispatch/3` to use — the guard itself stays +`Nref`-shaped since it is pure client-side validation, unchanged from +`validate_mutation/1`'s existing call. Do not add a `Home`-aware tier guard; +`validate_mutation/1` remains as-is (this is intentional: `retire_node` +inside a `mutate/2` batch on a genuinely environment-tier nref should still +be blocked by the exact same guard that blocks it in `mutate/1`, and a +project nref is never `< ?NREF_START` by construction since project +allocators start at 1 and the guard only ever fires for small nrefs that +also happen to be valid project nrefs — this is a known, accepted rough +edge: **a project's retire_node/unretire_node mutation for project nref 1 +through 999999 will incorrectly hit `tier_guard/1`'s `Nref < ?NREF_START` +check and be rejected as `permanent_node_immutable`.** Flag this explicitly +in code review — see the note below). + +**Stop and re-read**: this is a real bug the mechanical thread-through +would introduce silently. `tier_guard/1` in `validate_mutation/1` fires for +ANY `Nref < ?NREF_START` regardless of `Home` — which is wrong for a project +nref (no permanent tier exists for projects). Fix `validate_mutation/1`'s +three call sites (`retire_node`, `unretire_node`, `update_node_avps`) to +carry `Home` into the guard: + +```erlang +validate_mutation(Home, {retire_node, Nref}) when is_integer(Nref) -> + tier_guard(Home, Nref); +validate_mutation(Home, {unretire_node, Nref}) when is_integer(Nref) -> + tier_guard(Home, Nref); +validate_mutation(Home, {update_node_avps, Nref, AVPs}) when is_integer(Nref) -> + case validate_avp_updates(AVPs) of + ok -> tier_guard(Home, Nref); + {error, _} = Err -> Err + end; +%% ... every other clause of validate_mutation/1 gains a leading Home +%% parameter it ignores (it's a pure shape check, unaffected by Home) ... +validate_mutation(_Home, M) -> + {error, {bad_mutation, M}}. + +tier_guard(Home, Nref) when Home =/= environment -> ok; %% projects: no permanent tier +tier_guard(environment, Nref) when Nref >= ?NREF_START -> ok; +tier_guard(environment, _Nref) -> {error, permanent_node_immutable}. +``` + +Rename `validate_mutations/1` → `validate_mutations/2` accordingly (threads +`Home` through its fold), and update `do_mutate/2`: + +```erlang +do_mutate(Home, Mutations) -> + case validate_mutations(Home, Mutations) of + ok -> run_mutations(Home, Mutations); + {error, _} = Err -> Err + end. + +validate_mutations(_Home, []) -> + ok; +validate_mutations(Home, [M | Rest]) -> + case validate_mutation(Home, M) of + ok -> validate_mutations(Home, Rest); + {error, _} = Err -> Err + end. +``` + +Now `run_mutations/2`, `prepare/2`, and `dispatch/4`: + +```erlang +run_mutations(_Home, []) -> + {ok, []}; +run_mutations(Home, Mutations) -> + {ok, #{target_kind := TkAttr, retired := RetAttr}} = + graphdb_attr:seeded_nrefs(), + Prepared = [prepare(Home, M) || M <- Mutations], + graphdb_mgr:transaction(fun() -> + [dispatch(Home, P, TkAttr, RetAttr) || P <- Prepared] + end). + +prepare(Home, {add_relationship, S, C, T, R}) -> + {add_relationship, alloc_rel_id_pair(Home), S, C, T, R, default, {[], []}}; +prepare(Home, {add_relationship, S, C, T, R, Template}) -> + {add_relationship, alloc_rel_id_pair(Home), S, C, T, R, Template, {[], []}}; +prepare(Home, {add_relationship, S, C, T, R, Template, AVPSpec}) -> + {add_relationship, alloc_rel_id_pair(Home), S, C, T, R, Template, AVPSpec}; +prepare(_Home, {retire_node, _Nref} = M) -> + M; +prepare(_Home, {unretire_node, _Nref} = M) -> + M; +prepare(_Home, {update_node_avps, _Nref, _AVPs} = M) -> + M; +prepare(_Home, {remove_relationship, _S, _C, _T} = M) -> + M; +prepare(_Home, {remove_relationship, _S, _C, _T, _Template} = M) -> + M; +prepare(_Home, {update_relationship, _S, _C, _T, _U} = M) -> + M; +prepare(_Home, {update_relationship, _S, _C, _T, _Template, _U} = M) -> + M; +prepare(_Home, {update_relationship_both, _S, _C, _T, _Pair} = M) -> + M; +prepare(_Home, {update_relationship_both, _S, _C, _T, _Template, _Pair} = M) -> + M. + +%% Duplicated 2-clause Home-dispatch helper (same YAGNI precedent as +%% is_retired/2's per-module duplication) -- graphdb_instance has its own +%% copy inline in do_add_relationship/8 (Task 5). +alloc_rel_id_pair(environment) -> rel_id_server:get_id_pair(); +alloc_rel_id_pair(Project) -> graphdb_project:next_rel_id_pair(Project). + +dispatch(Home, {add_relationship, IdPair, S, C, T, R, TemplateSpec, AVPSpec}, + TkAttr, RetAttr) -> + graphdb_instance:add_relationship_in_txn(Home, IdPair, S, C, T, R, + TemplateSpec, AVPSpec, TkAttr, RetAttr); +dispatch(Home, {retire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Home, Nref, true, RetAttr); +dispatch(Home, {unretire_node, Nref}, _TkAttr, RetAttr) -> + set_retired_(Home, Nref, false, RetAttr); +dispatch(Home, {update_node_avps, Nref, AVPs}, _TkAttr, RetAttr) -> + update_node_avps_in_txn(Home, Nref, AVPs, RetAttr); +dispatch(Home, {remove_relationship, S, C, T}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(Home, S, C, T, any); +dispatch(Home, {remove_relationship, S, C, T, Template}, _TkAttr, _RetAttr) -> + graphdb_instance:remove_relationship_in_txn(Home, S, C, T, Template); +dispatch(Home, {update_relationship, S, C, T, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(Home, S, C, T, any, U); +dispatch(Home, {update_relationship, S, C, T, Template, U}, _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_avps_in_txn(Home, S, C, T, Template, U); +dispatch(Home, {update_relationship_both, S, C, T, {Fwd, Rev}}, _TkAttr, + _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(Home, S, C, T, any, Fwd, + Rev); +dispatch(Home, {update_relationship_both, S, C, T, Template, {Fwd, Rev}}, + _TkAttr, _RetAttr) -> + graphdb_instance:update_relationship_both_in_txn(Home, S, C, T, Template, + Fwd, Rev). +``` + +`dispatch/3`'s call sites (both inside `run_mutations/2`) already updated +above to `dispatch/4` (leading `Home`). + +`update_node_avps_in_txn/3` (test-only export) — remove from the +`-ifdef(TEST)` export block if present; it does not exist as a `/3` anymore +after Task 10 renamed it to `/4`. Grep to confirm: + +```bash +grep -n "update_node_avps_in_txn/3\|check_category_guard/1" apps/graphdb/src/graphdb_mgr.erl +``` + +- [ ] **Step 4: Run to verify tests pass** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_mgr_SUITE` +Run: `./rebar3 eunit --app=graphdb` (the `-ifdef(TEST)` exports changed +arity; confirm any EUnit test in `graphdb_mgr` that called +`validate_mutation/1` or `tier_guard/1` directly is updated to the new +`/2` arity — grep `apps/graphdb/test/graphdb_mgr_tests.erl` if it exists). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_mgr.erl apps/graphdb/test/graphdb_mgr_SUITE.erl +git commit -m "SP2 T11: mutate/2 -- batch entry point takes a Project; fixes tier_guard for project nrefs" +``` + +--- + +## Task 12: `graphdb_query` — session gains a `Project`, reads resolve `Home` per nref + +**Files:** +- Modify: `apps/graphdb/src/graphdb_query.erl` +- Modify: `apps/graphdb/test/graphdb_query_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_ns:node_table/1`, `rel_table/1` (Task 1). +- Produces: `new_session/1 :: (Project) -> Session` (session map gains a + `project` key); `session_read_node/2` and `session_read_arcs/4` behaviour + changes internally (same public signature) to resolve `Home` per nref via + a new `resolve_home/2` instead of assuming the environment table. + +**Preserve `graphdb_query.erl`'s existing space-indentation style +throughout this task** — do not introduce tabs into this file. + +**Routing rule for this task (read before writing code):** every +`session_read_node`/`session_read_arcs` call in this module is a +**bare-nref, no-characterization-context** read (`#q_get_node{}`, +`#q_describe{}`, `#q_find_path{}`'s endpoints, and every arc-discovered +nref during BFS/`#q_instances_of{}` traversal) — the query language's +records carry no `target_kind`/`characterization` alongside the nref, unlike +`graphdb_instance`'s connection-arc primitives. So this task cannot reuse +`graphdb_ns:target_namespace/2` directly (it needs a `TargetKind` this +module never has in hand). Instead: **when the session is bound to a +`Project`, try the project's table first, falling back to environment.** +This is deliberately intent-following, not exhaustive-and-arbitrary: a query +session opened against a project is evidence the caller means that +project's nrefs first; environment nrefs (bootstrap scaffold, attributes, +classes) are the fallback. If a key genuinely exists in BOTH tables — e.g. a +tiny project has grown an instance whose nref equals a low environment +scaffold nref — that is a real ambiguity the project's copy resolves in +the project's favor, and it is logged (not silently swallowed) so the +collision is visible to whoever operates the system. Flag this design +choice explicitly to the user in code review; it is the one place in SP2 +where "try both, pick a winner" was chosen over the home-relative +determinism used everywhere else, because the query language's entry points +give no characterization context to determine Home outright. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/graphdb/test/graphdb_query_SUITE.erl` (2 spaces indentation, +matching this file's own style — confirm by reading a few existing test +bodies in the file before writing): + +```erlang +new_session_1_binds_a_project(_Config) -> + Project = proj(), + Session = graphdb_query:new_session(Project), + ?assertEqual(Project, maps:get(project, Session)). + +q_get_node_reads_a_project_instance(_Config) -> + Project = proj(), + {ok, Nref, _Report} = graphdb_instance:create_instance(Project, "Widget", + widget_class(), root_instance(Project)), + Session = graphdb_query:new_session(Project), + {ok, #{nref := Nref, kind := instance}, _Session1} = + graphdb_query:execute_query(#q_get_node{nref = Nref}, Session). + +q_get_node_still_reads_environment_when_project_bound(_Config) -> + Project = proj(), + Session = graphdb_query:new_session(Project), + {ok, #{nref := ?NREF_ROOT, kind := category}, _Session1} = + graphdb_query:execute_query(#q_get_node{nref = ?NREF_ROOT}, Session). + +resolve_home_prefers_project_and_logs_on_collision(_Config) -> + Project = proj(), + %% Project instance nref 1 collides in KEY (not identity) with the + %% environment's Root (nref 1) -- resolve_home must return the + %% project's copy, not silently return environment's. + {ok, 1, _Report} = graphdb_instance:create_instance(Project, "First", + widget_class(), root_instance(Project)), + Session = graphdb_query:new_session(Project), + {ok, #{nref := 1, kind := instance}, _Session1} = + graphdb_query:execute_query(#q_get_node{nref = 1}, Session). +``` + +(`widget_class/0`, `root_instance/1`, `proj/0` — reuse this suite's existing +helper conventions, matching whatever the current `sess()`-based tests +already use for setup, per Task 15's `proj()` rename.) + +- [ ] **Step 2: Run to verify these fail** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_query_SUITE` +Expected: undef `graphdb_query:new_session/1`. + +- [ ] **Step 3: `new_session/1`** + +```erlang +new_session(Project) -> + #{snapshot_at => os:timestamp(), + cache => #{}, + project => Project}. +``` + +(`new_session/0` and `refresh/1` are unchanged — `refresh/1` already +preserves unknown keys since it only sets `snapshot_at`/`cache` via `:=` +on an existing map, so a `project`-bearing session survives a refresh +unchanged. Confirm this by reading `refresh/1`'s current body — it uses +`Session#{snapshot_at := ..., cache := ...}`, which is key-preserving for +`project`.) + +- [ ] **Step 4: `resolve_home/2`, `session_read_node/2`, `session_read_arcs/4`, `read_arcs/4`** + +```erlang +%%--------------------------------------------------------------------- +%% resolve_home(Session, Nref) -> environment | Project +%% +%% Determines which store an Nref belongs to when no relationship context +%% is available (see the moduledoc note on this task in the plan / the +%% module's own comment block once written). Tries the session's bound +%% Project first (if any); a genuine ambiguity (the key exists in BOTH +%% tables) is logged, and the project's copy wins on the theory that a +%% session opened against a project is evidence of caller intent. +%%--------------------------------------------------------------------- +resolve_home(#{project := Project}, Nref) when Project =/= undefined -> + case mnesia:dirty_read(graphdb_ns:node_table(Project), Nref) of + [_] -> + case mnesia:dirty_read(nodes, Nref) of + [_] -> + logger:warning( + "graphdb_query: nref ~p exists in both project ~p " + "and the environment -- resolving to the project", + [Nref, maps:get(anchor, Project)]); + [] -> + ok + end, + Project; + [] -> + environment + end; +resolve_home(_Session, _Nref) -> + environment. + +%%--------------------------------------------------------------------- +%% session_read_node(Session, Nref) -> {Node | not_found, Session1} +%%--------------------------------------------------------------------- +session_read_node(#{cache := Cache} = Session, Nref) -> + case maps:get({node, Nref}, Cache, miss) of + miss -> + Home = resolve_home(Session, Nref), + case mnesia:dirty_read(graphdb_ns:node_table(Home), Nref) of + [Node] -> + Cache1 = Cache#{{node, Nref} => Node}, + {Node, Session#{cache := Cache1}}; + [] -> + {not_found, Session} + end; + Node -> + {Node, Session} + end. + +%%--------------------------------------------------------------------- +%% session_read_arcs(Session, Nref, Direction, KindFilter) +%% -> {[#relationship{}], Session1} +%%--------------------------------------------------------------------- +session_read_arcs(#{cache := Cache} = Session, Nref, Dir, Kinds) -> + Key = {arcs, Nref, Dir, Kinds}, + case maps:get(Key, Cache, miss) of + miss -> + Home = resolve_home(Session, Nref), + Arcs = read_arcs(Home, Nref, Dir, Kinds), + Cache1 = Cache#{Key => Arcs}, + {Arcs, Session#{cache := Cache1}}; + Cached -> + {Cached, Session} + end. + +read_arcs(Home, Nref, outgoing, Kinds) -> + Raw = mnesia:dirty_index_read(graphdb_ns:rel_table(Home), Nref, + #relationship.source_nref), + filter_kinds(Raw, Kinds); +read_arcs(Home, Nref, incoming, Kinds) -> + Raw = mnesia:dirty_index_read(graphdb_ns:rel_table(Home), Nref, + #relationship.target_nref), + filter_kinds(Raw, Kinds); +read_arcs(Home, Nref, both, Kinds) -> + read_arcs(Home, Nref, outgoing, Kinds) ++ read_arcs(Home, Nref, incoming, Kinds). +``` + +`filter_kinds/2` is unchanged. + +Note `resolve_home/2`'s first clause guards `Project =/= undefined` — this +matters because `new_session/0` (no project) does NOT set a `project` key +at all, so the first clause's map pattern `#{project := Project}` simply +won't match for an env-only session and falls through to the catch-all +`environment` clause. The `=/= undefined` guard is defensive for a future +caller that explicitly sets `project => undefined`; keep it for robustness +but it is not load-bearing against `new_session/0` today. + +- [ ] **Step 5: Run to verify tests pass** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_query_SUITE` +Expected: PASS (once Task 15's `proj()` helper lands — coordinate ordering +with that task, or stub `proj()` locally here first as noted in Task 10). + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_query.erl apps/graphdb/test/graphdb_query_SUITE.erl +git commit -m "SP2 T12: graphdb_query session binds a Project; reads resolve Home per nref" +``` + +--- + +## Task 13: `graphdb_rules` — scope-tag documentation and test-placeholder polish + +**Files:** +- Modify: `apps/graphdb/src/graphdb_rules.erl` +- Modify: `apps/graphdb/test/graphdb_rules_SUITE.erl` + +**Interfaces:** none — this is a comment/prose and test-fixture-realism +task. No function signature changes; `{project, _}` already pattern-matches +any second element, including a real `Project` map, so this task changes +nothing at runtime. + +- [ ] **Step 1: Update the moduledoc comments** + +`graphdb_rules.erl` has 8 comment lines matching `{project, _}` (confirmed +via `grep -n "{project," apps/graphdb/src/graphdb_rules.erl`, lines 176, +206, 224, 233, 246, 266, 287, 296 as of this plan's writing — re-grep before +editing, since Tasks 1-12 do not touch this file and line numbers should be +stable, but confirm). For each, change the prose from `{project, _}` to +`{project, Project}` (`Project` = a `graphdb_project` handle, per SP2), e.g.: + +```erlang +%% Scope environment reads the shared ontology; {project, Project} -> not_found. +``` + +Do not touch the `handle_call` pattern clauses themselves (lines ~478-555, +all matching `{..., {project, _}, ...}`) — `_` already matches a `Project` +map correctly; there is no behavioural reason to rename the bound variable +in a wildcard match, and doing so would be pure churn against a stub path. + +- [ ] **Step 2: Give the test suite's dummy scope tuples a realistic shape** + +In `apps/graphdb/test/graphdb_rules_SUITE.erl`, the stub-path tests +currently use placeholder second elements (`{project, 1}`, `{project, +p1}`). Replace with a synthetic-but-shaped `Project` map so the test reads +as "a project scope", not "an arbitrary term the stub ignores": + +```erlang +-define(DUMMY_PROJECT, #{anchor => 1, nodes => nodes_1, + rels => relationships_1, counters => counters_1}). +``` + +Then replace each of the 6 occurrences (`{project, 1}` → `{project, +?DUMMY_PROJECT}`, `{project, p1}` → `{project, ?DUMMY_PROJECT}`) at the +lines found by `grep -n "{project," apps/graphdb/test/graphdb_rules_SUITE.erl`. + +- [ ] **Step 3: Run to verify no regression** + +Run: `./rebar3 ct --app=graphdb --suite=graphdb_rules_SUITE` +Expected: PASS, unchanged pass count (these are stub-path assertions; the +shape change does not alter any assertion's expected value). + +- [ ] **Step 4: Commit** + +```bash +git add apps/graphdb/src/graphdb_rules.erl apps/graphdb/test/graphdb_rules_SUITE.erl +git commit -m "SP2 T13: graphdb_rules scope-tag docs + test fixtures name Project, not a placeholder" +``` + +--- + +## Task 14: Test suite migration — `sess()` → `proj()`, delta assertions, `invalid_session` → `invalid_project` + +**Files:** +- Modify: `apps/graphdb/test/graphdb_instance_SUITE.erl` +- Modify: `apps/graphdb/test/graphdb_mgr_SUITE.erl` +- Modify: `apps/graphdb/test/graphdb_query_SUITE.erl` + +**Interfaces:** +- Consumes: `graphdb_project:register_project/1`, `open/1` (Task 2). + +This task is what makes Tasks 3-12's suites actually compile and run (they +were written assuming `proj()` already existed). If you executed the tasks +in order and stubbed `proj()` locally per the notes in Tasks 10 and 12, this +task consolidates those stubs into one canonical helper per suite and +removes the duplicates. + +- [ ] **Step 1: Replace each suite's `sess()` helper with `proj()`** + +In all three files, the existing helper (shown here for +`graphdb_instance_SUITE.erl`; the other two are structurally identical): + +```erlang +%%--------------------------------------------------------------------- +%% proj() -> Project +%% +%% SP2 test helper: returns a project handle, memoised per test-case +%% process. Registers a project under Projects (nref 5) on first use and +%% opens it; subsequent calls in the same process reuse it. +%%--------------------------------------------------------------------- +proj() -> + case get(sp2_project) of + undefined -> + {ok, P} = graphdb_project:register_project("SP2 test project"), + {ok, Project} = graphdb_project:open(P), + put(sp2_project, Project), + Project; + Project -> + Project + end. +``` + +(`graphdb_query_SUITE.erl` uses space indentation — write this helper with +spaces there, matching that file's style, not tabs.) + +Grep-replace every call site: `grep -rln "sess()" apps/graphdb/test/*.erl` +and change `sess()` → `proj()` at each. Do NOT touch +`graphdb_project_SUITE.erl` (it never had a `sess()` helper — Task 2 already +wrote its tests directly against `register_project/1`/`open/1`). + +- [ ] **Step 2: `invalid_session` → `invalid_project`** + +In `graphdb_instance_SUITE.erl`, at the 5 code sites found by +`grep -n "invalid_session" apps/graphdb/test/graphdb_instance_SUITE.erl` +(lines 572, 2471, 2478, 2483, 2488 as of this plan's writing — re-grep to +confirm before editing), change `{error, invalid_session}` → +`{error, invalid_project}`, and update the one comment at line 567 +similarly. + +- [ ] **Step 3: `graphdb_instance_SUITE` delta-assertion table moves** + +Search for table-size delta assertions measuring `nodes`/`relationships` +directly: + +```bash +grep -n "mnesia:table_info(nodes\|mnesia:table_info(relationships\|ets:info(nodes\|ets:info(relationships" apps/graphdb/test/graphdb_instance_SUITE.erl +``` + +For each match, the pre/post counts must read the ACTIVE test's project +tables (`graphdb_ns:node_table(proj())` / `graphdb_ns:rel_table(proj())`), +not the literal `nodes`/`relationships` atoms — those now only ever hold +environment data, so a project-instance-creation delta assertion against +them would always see zero change and silently stop testing anything. + +Also apply the pre-warm fix SP1 needed for the same table-creation-inside- +measurement-window hazard (per the design's §10 and the SP1 precedent +recorded in `TASKS.md`): call `proj()` once in `init_per_testcase/2` (or at +the top of each affected test case, before the "before" measurement) so +`register_project/1`'s table-creation write doesn't land inside the +measured window. + +- [ ] **Step 4: Nref-value assertion review** + +```bash +grep -n "?assertEqual(1000000\|?assertEqual(100000[1-9]\|nref = 1000000" apps/graphdb/test/graphdb_instance_SUITE.erl apps/graphdb/test/graphdb_mgr_SUITE.erl apps/graphdb/test/graphdb_query_SUITE.erl +``` + +Any test asserting an exact instance nref in the old runtime tier +(`>= 1000000`) now sees project nrefs starting at 1 instead. Update each to +assert the new small integer (typically `1`, `2`, `3`, ... for the first +few instances created by that test case's `proj()`), reasoning from the +test's own creation order rather than guessing. + +- [ ] **Step 5: Run the full graphdb suite** + +Run: `./rebar3 eunit --app=graphdb` +Run: `./rebar3 ct --app=graphdb` (or `make test-ct-parallel`) +Expected: full PASS, all 13 CT suites + EUnit, zero failures, zero +warnings. This is the first point in the plan where the entire `graphdb` +app is expected to be green end-to-end. + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/test/graphdb_instance_SUITE.erl apps/graphdb/test/graphdb_mgr_SUITE.erl apps/graphdb/test/graphdb_query_SUITE.erl +git commit -m "SP2 T14: sess()->proj(), invalid_session->invalid_project, delta assertions and nref values moved to project tables" +``` + +--- + +## Task 15: Documentation — `graphdb/CLAUDE.md`, `docs/Architecture.md`, `TASKS.md` + +**Files:** +- Modify: `apps/graphdb/CLAUDE.md` +- Modify: `docs/Architecture.md` (only if it currently describes the SP1 + single-store state in a way SP2 contradicts — read its current Mnesia + schema / multi-database section before editing; per the top-level + CLAUDE.md's own rule, only touch it for a schema/supervision/API-contract + change, all three of which apply here) +- Modify: `TASKS.md` + +**Interfaces:** none — documentation only. + +- [ ] **Step 1: `apps/graphdb/CLAUDE.md`** + +Update the "Reference & namespace model (SP1)" section (or rename it to +cover SP1+SP2 together) to reflect: +- `graphdb_ns:namespace_of/2`, `target_namespace/2` (arity-2, home-relative). +- `graphdb_project:register_project/1` now also creates the project's three + physical tables; `open/1` replaces `open_session/1`; `require_project/1` + replaces `require_session/1`. +- The project write path's first argument is `Project`, not `Session` + (rename throughout the existing bullet list: + `create_instance`/`add_relationship`/`remove_relationship`/ + `update_relationship`(`_both`)/`add_class_membership`). + Add: instance reads (`get_instance`/`children`/`compositional_ancestors`/ + `resolve_value`) now also take `Project` — no longer namespace-agnostic. +- `graphdb_mgr`: `get_node/2`, `retire_node/2`, `unretire_node/2`, + `update_node_avps/3`, `delete_node/2`, `mutate/2` — new Project-taking + twins; the `/1` (and `/2` for `update_node_avps`) forms stay + environment-only. +- `graphdb_query`: `new_session/1` binds a `Project`; reads resolve `Home` + per nref. +- Update the "Cross-database nref resolution" paragraph to describe + home-relative routing (mirroring the amended parent design's §3), not + the old "target_kind AVP" single-field description. + +- [ ] **Step 2: `docs/Architecture.md`** + +If it documents the Mnesia schema as "one `nodes`/`relationships` pair," +add the per-project table set (`nodes_`/`relationships_`/ +`counters_`) as a peer storage unit, matching this plan's File Structure +table. Keep it at architectural altitude — table naming convention and +ownership, not per-function signatures. + +- [ ] **Step 3: `TASKS.md`** + +In the `## Multi-project sessions` section: +- Move the "Physical project store (SP2)" bullet from "DESIGNED, not yet + implemented" to "IMPLEMENTED", pointing at this plan's file and + summarizing the shipped surface (mirror the style of the SP1 bullet + immediately above it in the same file). +- Add a note recording the scope addition beyond the original spec: the + `retire_node`/`unretire_node`/`update_node_avps`/`delete_node` + Project-taking twins (Task 10), since these were not in the original + design's API table and their absence would have been a live nref-collision + hazard post-SP2. +- Update the "SP2+ — turning project scope on" intro paragraph, since SP2 is + no longer future work. + +- [ ] **Step 4: Commit** + +```bash +git add apps/graphdb/CLAUDE.md docs/Architecture.md TASKS.md +git commit -m "SP2 T15: documentation reflects the shipped physical project store" +``` + +--- + +## Post-plan verification checklist (run once, after Task 15) + +- [ ] `./rebar3 compile` — clean, zero warnings, whole umbrella. +- [ ] `./rebar3 eunit` — full pass, whole umbrella. +- [ ] `./rebar3 ct` (or `make test-ct-parallel`) — full pass, all suites. +- [ ] `grep -rn "open_session\|session_project\|require_session\|with_session\|invalid_session" apps/graphdb/src apps/graphdb/test` — zero matches (confirms the Session→Project rename is complete, not partial). +- [ ] `grep -rn "graphdb_ns:namespace_of(" apps/graphdb/src apps/graphdb/test | grep -v "/2)"` — zero matches (confirms no stray arity-1 call survived). +- [ ] Manually create two projects in a shell (`make shell` → + `application:start(nref), application:start(database).` then + `{ok, P1} = graphdb_project:register_project("A").`, + `{ok, P2} = graphdb_project:register_project("B").`, + `{ok, Proj1} = graphdb_project:open(P1).`, + `{ok, Proj2} = graphdb_project:open(P2).`, + `graphdb_instance:create_instance(Proj1, "X", SomeClass, RootOfProj1).`, + `graphdb_instance:create_instance(Proj2, "Y", SomeClass, RootOfProj2).`) + and confirm both land at nref 1 in their own tables + (`mnesia:dirty_read(nodes_P1, 1)` vs `mnesia:dirty_read(nodes_P2, 1)` + return different, correct nodes) — this is the design's stated goal + (§2) made concrete. diff --git a/rebar.config b/rebar.config index 96e0a09..4b96745 100644 --- a/rebar.config +++ b/rebar.config @@ -2,6 +2,38 @@ {deps, []}. +%% Only the one check that catches undef-at-runtime bugs like SP2 review +%% wave A Fixes 1 and 2: a function exported only under -ifdef(TEST) but +%% called cross-module from production code compiles clean and passes the +%% test suite -- TEST is defined in the test profile -- but crashes `undef` +%% in a default-profile release. undefined_function_calls flags every such +%% call site by the calling function (verified: reverting Fixes 1/2 makes +%% this fail with exactly the 7 call sites those fixes touch). +%% +%% undefined_functions is deliberately OFF too: it reports the same class +%% of problem but keyed by the (often OTP/stdlib) module the undefined +%% function was expected in rather than the caller, so it can't be +%% narrowed to one caller via xref_ignores below -- undefined_function_calls +%% alone is both necessary and sufficient for this gate's goal. +%% +%% exports_not_used/locals_not_used/deprecated_* are deliberately OFF: +%% this codebase's public worker APIs are exercised almost entirely from +%% CT/EUnit suites xref does not analyze, so exports_not_used alone flags +%% ~150 false positives across every graphdb_* module -- enabling it would +%% not be a real gate, just noise that trains reviewers to ignore xref +%% output. +{xref_checks, [undefined_function_calls]}. + +%% rel_id_server:seed_from_mnesia/0 calls mnesia:dirty_foldl/3, which does +%% not exist (real function is mnesia:foldl/3). Pre-existing, unrelated to +%% Fixes 1/2, out of scope for this wave -- see wave-a report. Silently +%% caught by seed_from_mnesia/0's own `catch _:_ -> 1`, so today this is a +%% silent-fallback bug (rel_id_server always reseeds at 1 on restart +%% instead of the true max), not a crash. Ignored here by exact MFA so the +%% gate stays strict for everything else; do not widen this list without +%% the same scrutiny. +{xref_ignores, [{rel_id_server, seed_from_mnesia, 0}]}. + {relx, [ {release, {seerstone, "0.1.0"}, [nref,