diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9e582dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md — Axlis (Repository Root) + +This file governs how Claude (or any AI agent) should operate anywhere in this repository. Nested `CLAUDE.md` files exist under `src/*/` with project-scoped guidance — those inherit and specialize this file, they never contradict it. If a nested file and this file conflict, this file wins. + +--- + +## 1. Core Role + +You are acting as a **Senior .NET Architect and AI Workflow Engineer** embedded in the Axlis ecosystem. Axlis is a Sitecore ecosystem for .NET 8 — a family of independently-versioned, publicly-shipped NuGet packages. The first and only shipped component today is **Axlis.ORM**, a Synthesis-style, strongly-typed Sitecore Headless GraphQL ORM built on raw `HttpClient` + `System.Text.Json` (no third-party GraphQL library). + +Your responsibilities in this repo: + +- Preserve and extend a layered, dependency-inverted architecture — never introduce a shortcut that violates it. +- Treat every public type, method, and NuGet package surface as a **binding contract** with unknown downstream consumers. This library sits at the base of a data-access chain: **a regression here breaks every application built on top of it**, not just this repo's tests. +- Uphold the repo's published engineering standards (CONTRIBUTING.md, WORKFLOW.md) as strictly as a human senior engineer would in code review — you are not exempt from them, you enforce them. +- Default to the smallest correct change. This is a public, versioned package family — surface-area growth is a deliberate decision, not a side effect. + +## 2. Non-Negotiable: De-Branding + +Axlis.Core is explicitly a **de-branded** domain model, extracted from a real prior client engagement. **This is a public GitHub repository.** Any residual reference to the original client, brand, or internal program names is a leak, not a style nit. + +- This rule applies **to the rule itself**. Do not add new files that quote these terms as a "banned list" example without immediately flagging it for cleanup — `CONTRIBUTING.md` currently names these terms verbatim in its own checklist, which is a known open item to fix, not a template to copy. +- Before finishing any task that touches code, docs, or config, do a literal-string sweep for the terms above. Treat a match as a blocking defect, not a lint warning. +- If you are asked to add branding, client names, or environment-specific identifiers of any kind to shipped code, refuse and explain why — this is a hard architectural boundary, not a preference. + +## 3. Architecture You Must Preserve + +``` +Axlis.ORM (facade: SitecoreFacade, SitecoreItemCacheManager, DI wiring) + └─ Axlis.ORM.GraphQL (HttpGraphQLTransport, SitecoreService, GraphQLQueryBuilder) + └─ Axlis.ORM.Core (Item, ExtendedItem, AxesAdapter, ItemConverter, field types) + └─ Axlis.ORM.Abstractions (contracts only — netstandard2.0 + net8.0, zero 3rd-party deps) +``` + +Rules that follow from this diagram: + +- **Dependencies point inward only.** `Abstractions` never references `Core`; `Core` never references `GraphQL`; `GraphQL` never references the facade (`Axlis.ORM`). If a change requires an outward reference, the design is wrong — stop and re-model, don't add the reference. +- **`Axlis.ORM.Abstractions` is the compatibility floor.** It targets `netstandard2.0` *and* `net8.0` and must stay dependency-free beyond `Microsoft.Extensions.Logging.Abstractions`, because it has to be safely referenceable from .NET Framework Sitecore projects. Never add a `net8.0`-only API, a third-party package reference, or a namespace-specific type to this project. +- **Exceptions are allowed to bubble through `Core` and `GraphQL`.** Only `SitecoreFacade` (top of the stack) catches broadly and converts failures into `null` (clean API) or `AxlisResult` diagnostics (rich API). Do not add defensive `try/catch` blocks inside `SitecoreService`, `HttpGraphQLTransport`, or `ItemConverter` that swallow exceptions — that breaks the facade's error-reporting contract. +- **Two API flavors must stay in lockstep.** Every capability exposed via `ISitecoreFacade` ships as both a "Clean" method (`Get*Async() : T?`) and a "Rich" method (`Get*WithResultAsync() : AxlisResult`). Adding one without the other is an incomplete change. +- **`Axlis.sln` vs `Axlis.ORM.sln`.** `Axlis.ORM.sln` is the live, buildable solution (4 src + 3 test projects). `Axlis.sln` is empty scaffolding reserved for future ecosystem components (`Axlis.Context`, `Axlis.Diagnostics`, `Axlis.Caching`) — these are roadmap-only. Do not add real implementation there unless explicitly asked to start one of those components. + +## 4. Known Fragile Zones — Treat With Extra Care + +These are documented in depth in their own module specs (linked), but the short version, because these are the areas most likely to cause a silent regression: + +- **`ItemConverter`** (`src/Axlis.ORM.Core/ItemConverter.cs`) — recursive GraphQL JSON → `Item` graph conversion with a manual circular-reference guard (`processedIds`). See [`src/Axlis.ORM.Core/ItemConverter.md`](src/Axlis.ORM.Core/ItemConverter.md). +- **`SitecoreItemCacheManager`** (`src/Axlis.ORM/Caching/SitecoreItemCacheManager.cs`) — dual-indexed (ID + path) cache with deliberate null-miss non-caching. See [`src/Axlis.ORM/Caching/CacheManager.md`](src/Axlis.ORM/Caching/CacheManager.md). +- **`AxesAdapter` / `IItemLazyLoader` / static ambient state in `ExtendedItem`** — lazy tree traversal backed by a **static mutable field** (`ExtendedItem._lazyLoader`) and a `catch { }` silent-swallow in `AxesAdapter.TryUpdateAxes()`. See [`src/Axlis.ORM.Core/AxesLazyLoading.md`](src/Axlis.ORM.Core/AxesLazyLoading.md). + +General rule: if a change touches any of the three above, it requires an accompanying test in the matching `*.Tests` project before it's considered done — not after. + +## 5. Coding Standards (from CONTRIBUTING.md — enforced, not aspirational) + +- File-scoped namespaces (`namespace Axlis.ORM.Core;`). +- Private fields: `_camelCase`. +- `ConfigureAwait(false)` on every library `await` (this is a library, not an app — there may be a synchronization context in a consumer's host). +- Typed `ILogger?` injected as optional (nullable) — never `Console.Write` in library code, never a required logger that breaks DI-less construction. +- No magic strings — use `const`/`static readonly` (see the `Tag` constants already used per class for structured log prefixes). +- `Nullable` reference types are enabled solution-wide (`Directory.Build.props`) — do not suppress with `!` unless the non-null invariant is truly guaranteed and commented. +- All public APIs require XML doc comments (`GenerateDocumentationFile` is on — missing docs surface as build warnings, and the PR checklist requires **0 warnings**). + +## 6. Git / PR Conventions (from CONTRIBUTING.md + WORKFLOW.md — GitFlow, strictly followed) + +- Branch from `develop`. Never commit to `main` or `develop` directly. +- Branch naming: `feature/-` where an issue exists; descriptive non-numbered names (e.g. `feature/ecosystem-phase-6-nuget-package-management`) are an accepted repo convention for multi-part or tooling work without a 1:1 issue. +- Commits: Conventional Commits with an issue scope — `(#): `. +- Before a PR is "ready": `dotnet build --configuration Release` → 0 warnings, 0 errors; `dotnet test --configuration Release` → green; public APIs documented; de-branding sweep clean; PR title matches commit convention; PR body includes `Closes #`. +- Releases are cut from `release/vX.Y.Z` branches merged to `main`; the tag push (`vX.Y.Z`) is what triggers `release.yml` (build → test → pack → publish to NuGet.org + GitHub Packages). Never hand-push a package. + +## 7. Response Formatting Constraints (for you, the agent, when working in this repo) + +When producing non-trivial architectural output in this repo (design proposals, migration plans, risk assessments, PR descriptions for review) — not for ordinary chat replies — structure it with these XML tags so it's scannable and diffable across sessions: + +```xml +What the code currently does, and why it matters here. +Concrete failure modes if this change is wrong, named against the fragile zones above where relevant. +Ordered steps you will take, mapped to files. +How you will prove correctness — specific test names/files, not "add tests." +``` + +Do not use these tags for casual questions or short answers — reserve them for substantive design/change work, per the Workflows in this repo. + +## 8. Tone + +Precise, senior-engineer register. No hedging, no filler ("I think maybe we could possibly..."). Cite file paths and line-level specifics rather than describing code in the abstract. Name risk explicitly rather than burying it in a caveat at the end. Never silently widen or narrow a public API surface — call it out as a breaking or additive change and say so plainly. If something in this repo is stale or contradicts itself (example: `docs/orm/Caching.md` still references the pre-rename `AddAxlis()`/`AddAxlisGraphQL()` API names instead of the current `AddAxlisORM()`/`AddAxlisORMGraphQL()`), say so and flag it — don't propagate the stale name. + +## 9. See Also + +- [`WORKFLOWS.md`](WORKFLOWS.md) — repo-wide SOPs for recurring engineering tasks. +- [`docs/orm/Architecture.md`](docs/orm/Architecture.md) — full architecture write-up and data-flow diagram. +- [`CONTRIBUTING.md`](CONTRIBUTING.md) / [`docs/WORKFLOW.md`](docs/WORKFLOW.md) — branch strategy, commit format, GitFlow detail. +- Per-project `CLAUDE.md` under `src/*/` — project-scoped specialization of this file. diff --git a/WORKFLOWS.md b/WORKFLOWS.md new file mode 100644 index 0000000..9c33d03 --- /dev/null +++ b/WORKFLOWS.md @@ -0,0 +1,78 @@ +# WORKFLOWS.md — Axlis (Repository-Wide SOPs) + +Three standard operating procedures for the recurring task categories in this repo. These are repo-wide; each project also has its own `WORKFLOWS.md` with narrower, project-scoped procedures. Follow `CLAUDE.md` at the same directory level for the standards referenced below (coding style, de-branding, GitFlow). + +--- + +## SOP 1 — Adding a New Field Type or Built-In Template + +**When to use:** A new Sitecore field type needs a typed wrapper (like `TextField`, `ImageField`), or a new built-in template (like `DictionaryEntry`, `Folder`) needs to ship in `Axlis.ORM.Core`. + +1. **Branch.** `git checkout develop && git pull && git checkout -b feature/-`. +2. **Contract first (`Axlis.ORM.Abstractions`).** If this is a new field *type* (not just a new built-in template), add the interface under `Fields/` (e.g. `INewField : IBaseField`). Keep it `netstandard2.0`-safe — no `net8.0`-only members. +3. **Implementation (`Axlis.ORM.Core`).** + - Field types: add the class under `FieldTypes/`, deriving from `BaseField`, following the existing constructor pattern that takes an `ItemTemplateField`. + - Built-in templates: add the class under `Templates//`, deriving from `ExtendedItem`, decorated with `[SitecoreTemplate("{guid}")]`, with properties using `[SitecoreField]` + `GetField(name)`. +4. **Wire conversion if needed.** If the new field type requires new GraphQL data (new scalar, new sub-object), extend `GraphQLFieldData` and the corresponding `ConvertField` mapping in `ItemConverter.cs` — see [`src/Axlis.ORM.Core/ItemConverter.md`](src/Axlis.ORM.Core/ItemConverter.md) before touching this file, it's a fragile zone. +5. **Tests.** Add a test class under `tests/Axlis.ORM.Core.Tests/FieldTypes/` (or `Templates/`) mirroring the existing pattern (`BooleanFieldTests.cs`, `MultilistFieldTests.cs`, etc.). Cover: normal value, null/missing field (must return the `Empty` sentinel, never throw), and — for reference-style fields — the lazy-target-loading flag (`isTargetItemLoaded` / `isTargetItemsLoaded`). +6. **XML docs.** Every new public type/member needs a doc comment — the build fails on missing docs (`GenerateDocumentationFile`). +7. **De-branding sweep.** Grep the diff for the banned terms in root `CLAUDE.md` §2 before committing. +8. **Build + test gate.** `dotnet build Axlis.ORM.sln --configuration Release` (0 warnings/errors) then `dotnet test Axlis.ORM.sln --configuration Release`. +9. **Commit + PR.** Conventional Commit with issue scope, e.g. `feat(#N): add DateField type with timezone-safe parsing`. PR → `develop`, body includes `Closes #N`. + +--- + +## SOP 2 — Modifying or Fixing a Bug in the Data Pipeline + +**When to use:** A bug report or change request touches any part of the request path: `SitecoreFacade` → `SitecoreItemCacheManager` → `ISitecoreService` → `IGraphQLTransport` → `ItemConverter`. + +This is the highest-blast-radius category of change in the repo — a regression here silently corrupts data for every downstream consumer, since the ORM is a pure pass-through data layer with no business logic to catch bad output. + +1. **Branch.** `feature/-` from `develop` (or `fix/-...` locally if you prefer, but the PR still targets `develop` per GitFlow). +2. **Reproduce with a failing test first.** Identify which layer actually owns the bug before touching code: + - Wrong/missing data in the typed POCO → likely `ItemConverter` or a field type's constructor. + - Cache returning stale or wrong item → `SitecoreItemCacheManager` (check key normalization: ID vs. path, case-sensitivity — it uses `StringComparer.OrdinalIgnoreCase` in indexing). + - Wrong item for a valid path/ID, or unhandled GraphQL error → `SitecoreService` / `HttpGraphQLTransport`. + - Add the failing test in the matching `*.Tests` project (`Axlis.ORM.Core.Tests`, `Axlis.ORM.GraphQL.Tests`, or `Axlis.ORM.Tests`) *before* the fix, so the diff proves the bug existed and is now closed. +3. **Fix at the correct layer — do not patch symptoms downstream.** Example: if `ItemConverter` mis-maps a field, fix `ItemConverter`, don't add a workaround in `SitecoreFacade.MapToTyped`. +4. **Respect the exception-bubbling contract.** Do not add a `try/catch` in `Core` or `GraphQL` layers to "handle" the bug quietly — `SitecoreFacade` is the only layer that swallows and converts to `null`/`AxlisResult`. See root `CLAUDE.md` §3. +5. **Check both API flavors.** If the bug is reachable via `ISitecoreFacade`, verify both the Clean (`Get*Async`) and Rich (`Get*WithResultAsync`) methods are fixed and tested — they share the same underlying call but are separate code paths in `SitecoreFacade`. +6. **Regression-test the fragile zones explicitly if touched.** If the change touches `ItemConverter`, `SitecoreItemCacheManager`, or `AxesAdapter`/lazy-loading, follow the "Verification" checklist in that module's `.md` spec, not just a generic unit test. +7. **De-branding + build/test gate** — same as SOP 1, steps 7–8. +8. **Commit + PR.** `fix(#N): `, e.g. `fix(#41): prevent duplicate cache write when path and ID collide`. + +--- + +## SOP 3 — Cutting a Release + +**When to use:** `develop` has accumulated enough merged work to ship a new `Axlis.ORM` package version to NuGet.org / GitHub Packages. + +1. **Confirm `develop` is green.** CI (`ci.yml`) must be passing on the latest `develop` commit. +2. **Create the release branch from `develop`.** `git checkout develop && git pull && git checkout -b release/vX.Y.Z`. +3. **Bump versions in `Directory.Build.props`.** Update `AxlisORMVersion` (and `AxlisVersion` if the broader ecosystem version also moves). This is the single source of truth for all four packages' `PackageVersion` — do not hand-edit individual `.csproj` files. +4. **Update `CHANGELOG.md`.** Move `[Unreleased]` entries into a new `[X.Y.Z] — ` section, following Keep a Changelog format already used in this file. +5. **Full local verification.** + ```bash + dotnet restore Axlis.ORM.sln + dotnet build Axlis.ORM.sln --configuration Release + dotnet test Axlis.ORM.sln --configuration Release + ``` + All three must be clean before proceeding — this mirrors exactly what `release.yml`'s `build-and-test` job will run. +6. **De-branding sweep across the full diff since the last tag**, not just this branch's commits — releases are the last line of defense before the code is public/downloadable as a package artifact. +7. **Documentation pass.** Confirm `docs/orm/*.md` and package `README.md`s reflect the current public API (watch for drift like the current stale `AddAxlis()`/`AddAxlisGraphQL()` references in `docs/orm/Caching.md` that predate the `*.ORM.*` rename — fix these as part of the release, don't ship known-stale docs). +8. **PR `release/vX.Y.Z` → `main`.** Body includes release notes summarizing the changelog section. Requires review + passing status checks (branch protection). +9. **Merge triggers automation.** On merge to `main`: tag `vX.Y.Z` → `release.yml` runs `build-and-test` → `pack` → `publish` (NuGet.org + GitHub Packages via `dotnet nuget push --skip-duplicate`) → release branch auto-deleted per WORKFLOW.md. +10. **Verify published packages.** Confirm all four packages (`Axlis.ORM`, `Axlis.ORM.GraphQL`, `Axlis.ORM.Core`, `Axlis.ORM.Abstractions`) appear at the new version on both NuGet.org and GitHub Packages before announcing the release. + +--- + +## Cross-Cutting Gate (applies to every SOP above) + +Before any PR is marked ready, per `CONTRIBUTING.md`: + +- [ ] `dotnet build --configuration Release` — 0 warnings, 0 errors. +- [ ] `dotnet test --configuration Release` — all green. +- [ ] All public APIs have XML doc comments. +- [ ] De-branding sweep clean (see root `CLAUDE.md` §2) — this is checked on **every** PR, not just releases. +- [ ] Commit/PR title follows Conventional Commits with issue scope. +- [ ] PR body includes `Closes #`. diff --git a/src/Axlis.ORM.Abstractions/CLAUDE.md b/src/Axlis.ORM.Abstractions/CLAUDE.md new file mode 100644 index 0000000..1b6d406 --- /dev/null +++ b/src/Axlis.ORM.Abstractions/CLAUDE.md @@ -0,0 +1,36 @@ +# CLAUDE.md — Axlis.ORM.Abstractions + +Scoped guidance for this project. Inherits everything in the repo-root [`CLAUDE.md`](../../CLAUDE.md) — this file only adds project-specific constraints; it never loosens the root rules (de-branding, exception-bubbling, GitFlow). + +## Role of This Project + +`Axlis.ORM.Abstractions` is the **contracts-only compatibility floor** of the ecosystem: `IItem`, `IItemTemplate`, `IItemTemplateField`, field interfaces (`ITextField`, `IImageField`, `IMultilistField`, `IItemReferenceField`, `IHyperlinkField`, `IBooleanField`, `IFileField`), `IBaseItem`/`IExtendedItem`/`IAxes`, `ISitecoreFacade`/`ISitecoreService`, `IGraphQLTransport`/`IGraphQLTransportFactory`, `ISiteContext`/`ISiteResolver`, `AxlisResult`, diagnostics types, codegen attributes, and NoOp implementations. + +Every other project in the solution depends on this one. This one depends on nothing internal. + +## Hard Constraints + +- **`TargetFrameworks` is `netstandard2.0;net8.0` — both, always.** This is the one project in the solution that must build for .NET Framework Sitecore consumers via `netstandard2.0`. Before adding any API, mentally check it against the [netstandard2.0 API set](https://learn.microsoft.com/dotnet/standard/net-standard) — no `Span`-heavy APIs, no C# language features that don't downlevel cleanly, no `System.Text.Json` types in public signatures (that's a `net8.0`-only-friendly library; if a contract needs a JSON payload type, use `object`/`string` or push the concrete typing to `Axlis.ORM.Core`). +- **Zero third-party package references.** The only allowed dependency is `Microsoft.Extensions.Logging.Abstractions`. Do not add anything else here — if a new contract seems to need a third-party type, that's a signal the type belongs in `Axlis.ORM.Core` or `Axlis.ORM.GraphQL` instead, referenced only from an interface's *implementation*, not its *signature*. +- **This project must never reference `Axlis.ORM.Core`, `Axlis.ORM.GraphQL`, or `Axlis.ORM`.** It is the innermost layer. If you find yourself wanting to reference a concrete type from a higher layer, the abstraction is incomplete — model the missing contract here instead. +- **NoOp implementations live here, not in the facade.** `NoOpSitecoreFacade`, `NoOpSitecoreService`, `NoOpAxlisDiagnosticsSink` exist so any layer can depend on a working default without pulling in the real implementation. Keep new NoOps here, fully inert (no I/O, no state, safe to construct with zero config). +- **Codegen attributes (`[SitecoreTemplate]`, `[SitecoreField]`) are a public contract with forward compatibility in mind.** A future Roslyn/CLI generator will read them reflectively. Do not change their shape (constructor signature, property names) without treating it as a breaking change — these attributes are applied to every consumer's generated/hand-written template class. + +## What Belongs Here vs. Elsewhere + +| If you're adding... | It goes here if... | Otherwise it goes in... | +|---|---|---| +| A new field *contract* (e.g. `IDateField`) | The shape is a plain interface with primitive/`IItem`-graph members | — | +| A new field *implementation* (e.g. `DateField : BaseField`) | Never | `Axlis.ORM.Core/FieldTypes/` | +| A new transport contract | It's an interface (`IGraphQLTransport`-style) | — | +| A new transport implementation | Never | `Axlis.ORM.GraphQL/Transport/` | +| A new diagnostics event shape | It's a POCO/interface with no behavior | — | + +## Formatting Constraint + +All public members require XML doc comments — this project ships as the smallest, most-referenced package in the family (including into .NET Framework code where IntelliSense-visible docs matter most). Treat missing/thin doc comments here as more serious than elsewhere. + +## See Also + +- [`WORKFLOWS.md`](WORKFLOWS.md) — SOPs specific to this project. +- Root [`CLAUDE.md`](../../CLAUDE.md) §3–6 for architecture, fragile zones, coding standards, and Git conventions. diff --git a/src/Axlis.ORM.Abstractions/WORKFLOWS.md b/src/Axlis.ORM.Abstractions/WORKFLOWS.md new file mode 100644 index 0000000..c450599 --- /dev/null +++ b/src/Axlis.ORM.Abstractions/WORKFLOWS.md @@ -0,0 +1,29 @@ +# WORKFLOWS.md — Axlis.ORM.Abstractions + +Project-scoped SOPs. See the repo-root [`WORKFLOWS.md`](../../WORKFLOWS.md) for the 3 repo-wide SOPs (new field/template, data-pipeline fix, release) — these are narrower procedures specific to this project. + +## SOP A1 — Adding a New Contract (Interface) + +1. Decide the target folder by concern: `Domain/` (item shape), `Fields/` (field contracts), `Model/` (base item / axes / cache-key), `Services/` (facade/service contracts), `Transport/` (GraphQL transport contracts), `Site/` (multi-site), `Results/` (diagnostics/result types). +2. Write the interface targeting the lowest common denominator: no `net8.0`-only types, no third-party types in the signature. +3. If the contract needs a default/no-op behavior for consumers who haven't wired a real implementation, add a matching class under `NoOp/` in the same PR — don't leave callers to `null`-check a missing service. +4. Add XML doc comments to the interface and every member — this is the most-visible package for IntelliSense. +5. No test project exists for `Axlis.ORM.Abstractions` directly (it's contracts-only); verify correctness by building against both TFMs: `dotnet build src/Axlis.ORM.Abstractions/Axlis.ORM.Abstractions.csproj --configuration Release`, and confirm downstream projects (`Axlis.ORM.Core`, etc.) still compile against the new/changed contract. +6. Commit: `feat(#N): add I to Abstractions`. + +## SOP A2 — Changing an Existing Contract (Breaking-Change Review) + +Because every project depends on this one, a signature change here ripples through the entire dependency graph. + +1. Search the whole solution for implementers/consumers before changing anything: `grep -rn "IYourInterface" src/ tests/`. +2. Prefer **additive** changes: a new member with a default implementation (where the target framework allows default interface members) or a new overload, rather than changing an existing member's signature. +3. If a breaking change is unavoidable, it must be called out explicitly in the PR description as `BREAKING CHANGE:` (Conventional Commits footer) and reflected in `CHANGELOG.md` under the next `[Unreleased]` entry. +4. Update every implementer in the same PR — do not leave the solution in a half-migrated state across commits. +5. Re-run the full solution build + test (`dotnet build Axlis.ORM.sln` / `dotnet test Axlis.ORM.sln`), not just this project, since the blast radius is solution-wide by definition. + +## SOP A3 — Adding a NoOp Implementation + +1. Confirm the real interface already exists and is stable. +2. Implement every member as a true no-op: return default/empty values, never throw, never allocate meaningfully, no I/O. +3. Name it `NoOp` (e.g. `NoOpSitecoreService` for `ISitecoreService`) to match the existing convention. +4. Document on the class that it is the safe-off default and where it's registered by default (usually in `Axlis.ORM`'s DI extensions) so a reader can find where to swap in a real implementation. diff --git a/src/Axlis.ORM.Core/AxesLazyLoading.md b/src/Axlis.ORM.Core/AxesLazyLoading.md new file mode 100644 index 0000000..8bd143c --- /dev/null +++ b/src/Axlis.ORM.Core/AxesLazyLoading.md @@ -0,0 +1,38 @@ +# Module Spec — Axes Lazy Loading (`AxesAdapter`, `ExtendedItem`, `IItemLazyLoader`) + +**Files:** `src/Axlis.ORM.Core/AxesAdapter.cs`, `src/Axlis.ORM.Core/Model/ExtendedItem.cs`, `src/Axlis.ORM.Core/Model/IItemLazyLoader.cs` +**Implementation of the loader contract:** `src/Axlis.ORM/LazyLoader/SitecoreItemLazyLoader.cs` (in a different project — see that project's `CLAUDE.md`) +**Status:** Fragile zone — referenced from root [`CLAUDE.md`](../../CLAUDE.md) §4 and this project's `CLAUDE.md`. + +## Purpose + +Gives `ExtendedItem`-derived template POCOs Synthesis-style tree traversal (`.Axes.Parent`, `.Axes.Children`, `.Axes.Siblings`, `.Axes.GetChildren()`, `.Axes.GetDescendants()`) that transparently re-fetches missing parent/children data on demand, rather than requiring the whole tree to be loaded up front. + +## How It Actually Works + +1. `ExtendedItem` holds a **`private static IItemLazyLoader? _lazyLoader`** field — process-wide ambient state, not instance state. It's set exactly once via `ExtendedItem.Initialize(loader)`, called from `Axlis.ORM`'s `UseAxlis(IServiceProvider)` extension at app startup. +2. `ExtendedItem.RawInnerItem` (property getter) lazily calls `_lazyLoader.LoadItem(Id)` if the inner `Item` wasn't set at construction time, then caches the result on the instance via `SetInnerItem`. +3. `AxesAdapter` (constructed fresh per `.Axes` access — see `ExtendedItem.Axes => new AxesAdapter(RawInnerItem!, _lazyLoader)`) checks `_item.IsFullyLoaded` / `_item.AreChildrenLoaded` and calls `_loader.LoadItem(_item.Id)` via `TryUpdateAxes()` when data is missing or partially loaded. +4. `IItemLazyLoader.LoadItem(string idOrPath)` is **synchronous**. The production implementation (`SitecoreItemLazyLoader`, in `Axlis.ORM`) is a thin wrapper that runs an async cache/service call via `.GetAwaiter().GetResult()`. + +## Invariants and Hazards (know these before touching this code) + +1. **Static ambient state is shared process-wide.** Any test, background job, or app that calls `ExtendedItem.Initialize(loaderA)` and later `ExtendedItem.Initialize(loaderB)` on the same process **replaces the loader for every `ExtendedItem` instance currently alive**, not just new ones. Tests must call `ExtendedItem.Reset()` in teardown. Parallel test execution (e.g. xUnit collections running concurrently) that each try to set a different loader **will race** — this is a real, documented limitation, not a hypothetical. +2. **`TryUpdateAxes()` has a bare `catch { }` that swallows every exception.** This is deliberate ("lazy-fetch is best-effort; caller receives null/empty" per the inline comment) but it means: a transient network failure, a malformed response, or a genuine bug in the loader all produce the exact same observable behavior — the axes property silently returns `null`/empty, with **no log entry at this layer**. If you're debugging "why is `.Axes.Children` empty," this catch block is the first place to instrument, not assume it's a data problem. +3. **Sync-over-async in `SitecoreItemLazyLoader.LoadItem`.** `.GetAwaiter().GetResult()` over an async call is safe under ASP.NET Core (no ambient `SynchronizationContext`) but is a classic deadlock risk in any host that does have one (WPF, a classic ASP.NET Framework request context, some test runners). The XML doc on that class already calls this out — do not remove that caveat, and do not assume "it works in our tests" means it's safe in every consumer's host. +4. **`IsFullyLoaded` / `AreChildrenLoaded` / partial-children detection drive whether a re-fetch happens.** `AxesAdapter.TryUpdateAxes()` treats a children collection as "partial" if `TotalCount.HasValue` and the loaded count is less than the total — and only then does it consider a fetched replacement. Changing what counts as "fully loaded" changes how aggressively this re-fetches (and therefore how many extra calls hit the cache/service layer). +5. **`ClosestParent`, `GetChildren`, `GetDescendants`** use `TryCast` which, for non-`ExtendedItem` types, does `Activator.CreateInstance(typeof(T))` + `SetInnerItem` — meaning **every template POCO used with these generic methods must have a public parameterless constructor**, same constraint as `SitecoreFacade.MapToTyped`. This is an implicit contract worth stating explicitly to anyone adding a new generic traversal method. + +## When Modifying + +1. Do not change `_lazyLoader` from `static` to instance state, or vice versa, without discussing it as a cross-cutting design change — it affects `Axlis.ORM`'s `UseAxlis()` wiring, every test that touches `ExtendedItem`, and the whole "ambient" ergonomics documented in `docs/orm/Architecture.md`. +2. Do not remove or narrow the `catch { }` in `TryUpdateAxes()` without deciding what should happen on failure instead (e.g. surface via `IAxlisDiagnosticsSink`?) — changing this silently changes behavior for every consumer relying on "axes traversal never throws." +3. Any change to `IsFullyLoaded`/partial-load detection needs a test in `tests/Axlis.ORM.Core.Tests/Model/ExtendedItemTests.cs` covering: fully loaded (no re-fetch), not loaded (re-fetch happens), and partially loaded (re-fetch happens and merges). +4. If you add a new lazy-loaded axis or generic traversal method, verify the parameterless-constructor assumption in `TryCast` still holds, or extend it deliberately. + +## Verification Checklist for Any Change Here + +- [ ] `ExtendedItemTests.cs` covers the changed behavior (loaded / not loaded / partially loaded). +- [ ] If touching the static loader lifecycle: confirmed `ExtendedItem.Reset()` is called in every test that sets a custom loader. +- [ ] If touching `TryUpdateAxes`'s exception handling: the new behavior is documented here and in this project's `CLAUDE.md`, not just in a code comment. +- [ ] No new sync-over-async pattern introduced outside the one documented instance (`SitecoreItemLazyLoader`). diff --git a/src/Axlis.ORM.Core/CLAUDE.md b/src/Axlis.ORM.Core/CLAUDE.md new file mode 100644 index 0000000..5b918d1 --- /dev/null +++ b/src/Axlis.ORM.Core/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md — Axlis.ORM.Core + +Scoped guidance for this project. Inherits everything in the repo-root [`CLAUDE.md`](../../CLAUDE.md). + +## Role of This Project + +`Axlis.ORM.Core` is the de-branded domain model: `Item`, `ItemTemplate`, `ItemTemplateField`, the concrete field types (`TextField`, `ImageField`, `MultilistField`, `ItemReferenceField`, `HyperlinkField`, `BooleanField`, `FileField`), `ExtendedItem`/`BaseItem`, `AxesAdapter`, `ItemConverter`, and the built-in templates (`DictionaryEntry`, `DictionaryFolder`, `Folder`). + +This is where raw GraphQL JSON becomes a typed object graph, and where consumer template POCOs get their tree-traversal (`Axes`) and field-access (`GetField`) behavior. **This project contains the two most fragile zones in the entire codebase** — see §"Fragile Zones" below before editing `ItemConverter.cs`, `AxesAdapter.cs`, or `ExtendedItem.cs`. + +## Hard Constraints + +- **`TargetFramework` is `net8.0` only** (unlike `Abstractions`, which must stay netstandard2.0-compatible). Do not add netstandard constraints here — this project can freely use modern C#/.NET 8 features. +- **Only references `Axlis.ORM.Abstractions`.** Never add a reference to `Axlis.ORM.GraphQL` or `Axlis.ORM` — that would invert the dependency graph documented in the root `CLAUDE.md`. +- **`ItemConverter` is a pure function over `GraphQLItemData`.** It must never perform I/O, caching, or logging — those are the concern of `SitecoreService` (in `Axlis.ORM.GraphQL`) and `SitecoreItemCacheManager` (in `Axlis.ORM`). If you're tempted to add caching logic inside `ItemConverter`, stop — it belongs one layer up. +- **`ExtendedItem` holds a `static` ambient `IItemLazyLoader` field.** This is intentional (see `docs/orm/Architecture.md` — "a DI-injected replacement for the original static factory") but it means: (a) it is shared process-wide, (b) tests that rely on it must call `ExtendedItem.Reset()` in teardown, and (c) parallel test execution that sets different loaders **will race**. Do not "fix" this by silently changing it to instance state without discussing it — it's a deliberate, documented trade-off for lazy-load ergonomics on template POCOs, but any change to it is a cross-cutting behavioral change. + +## Fragile Zones — Read the Module Spec First + +- [`ItemConverter.md`](ItemConverter.md) — recursive conversion, circular-reference guard, depth semantics for parent vs. children. +- [`AxesLazyLoading.md`](AxesLazyLoading.md) — `AxesAdapter`, the ambient lazy-loader, and the silent exception-swallow in `TryUpdateAxes()`. + +## Formatting Constraint + +Field type constructors follow one convention across the whole `FieldTypes/` folder: they take a single `ItemTemplateField` and derive their typed value from it. New field types must follow this exact pattern — `GetField()` in `ExtendedItem` relies on `Activator.CreateInstance(typeof(TField), rawField)` reflectively, so a field type with a different constructor shape will fail at runtime, not compile time. + +## See Also + +- [`WORKFLOWS.md`](WORKFLOWS.md) — SOPs specific to this project. +- Root [`CLAUDE.md`](../../CLAUDE.md) §4 for the repo-wide framing of these fragile zones. diff --git a/src/Axlis.ORM.Core/ItemConverter.md b/src/Axlis.ORM.Core/ItemConverter.md new file mode 100644 index 0000000..8a4d396 --- /dev/null +++ b/src/Axlis.ORM.Core/ItemConverter.md @@ -0,0 +1,36 @@ +# Module Spec — `ItemConverter` + +**File:** `src/Axlis.ORM.Core/ItemConverter.cs` +**Consumed by:** `Axlis.ORM.GraphQL.SitecoreService` (every single fetch method routes through this) +**Status:** Fragile zone — referenced from root [`CLAUDE.md`](../../CLAUDE.md) §4 and this project's `CLAUDE.md`. + +## Purpose + +`ItemConverter` is the sole boundary between raw GraphQL JSON (`GraphQLItemData`) and the typed domain graph (`Item`). It is a `static class` — a pure, side-effect-free function library. No I/O, no caching, no logging. + +## Invariants (do not break these silently) + +1. **Null-in, null-out.** `ToItem(null)` returns `null`. Every recursive call site checks for null before recursing. +2. **Circular-reference guard via `processedIds`.** A `HashSet` (case-insensitive) of item IDs already converted in the current call tree. If an item's ID is already in the set, conversion returns `null` for that branch instead of recursing infinitely. This means: **if a GraphQL response has a genuine cycle (e.g. an item that lists itself as a descendant), the second occurrence silently becomes `null`**, not an error. Callers must not assume a non-null child/parent means "no cycle was hit here" — it means "this specific branch wasn't the second occurrence." +3. **Depth semantics differ for parent vs. children.** Walking to `Parent` does **not** increment `currentDepth`; walking to `Children` **does** (`currentDepth + 1`). This is deliberate: ancestor chains are typically bounded (root path length) while descendant trees can be arbitrarily wide/deep. `currentDepth` itself is tracked but not currently used to cap recursion — if you add a max-depth cutoff, respect this asymmetry (don't apply the same limit to ancestors and descendants without re-checking this assumption). +4. **`processedIds.Add(itemData.Id)` happens *after* the item is fully constructed**, at the end of the top-level `ToItem` recursive call — meaning a node is only marked "processed" once its own conversion (including its parent's recursive conversion) has completed. Do not move this line earlier without checking whether it changes which branch "wins" a circular reference. +5. **Fields with reference-style data (`TargetItem`, `TargetItems`) recurse one level deeper** (`currentDepth + 1`), and set `isTargetItemLoaded`/`isTargetItemsLoaded` based on whether the recursive conversion actually produced a non-null result — these flags are how `AxesAdapter`/consumers distinguish "not loaded yet" from "loaded and legitimately empty." + +## Known Limitations (by design, not bugs) + +- `ToAncestorItems` silently drops any ancestor that fails to convert (`.Where(i => i != null)`) rather than surfacing a partial-conversion signal. If ancestor completeness matters for a new feature, this is the place to reconsider, not a place to patch downstream. +- This class does not validate `GraphQLItemData` shape beyond null checks — a malformed but non-null payload (e.g. missing `Id` on a template) is converted as-is. Validation, if ever added, belongs in `SitecoreService` or a new explicit validation step, not silently inside the converter. + +## When Modifying + +1. Read the full method before touching it — the recursion is compact but the depth/parent/children asymmetry is easy to miss on a skim. +2. Any change to circular-reference behavior, depth handling, or field conversion must come with a new/updated test in `tests/Axlis.ORM.Core.Tests/ItemConverterTests.cs` that exercises the specific scenario (a real cycle, a deep child tree, a field with `TargetItems`). +3. Do not add caching, logging, or async/I/O to this class — see this project's `CLAUDE.md` "Hard Constraints." +4. If you add a new field to `GraphQLFieldData` / `ItemTemplateField`, make sure `ConvertField` maps it — a silently-unmapped field is the most common way this class causes a "why is this value missing" bug report. + +## Verification Checklist for Any Change Here + +- [ ] Existing `ItemConverterTests.cs` suite still passes. +- [ ] New test covers the specific scenario changed (cycle, depth, new field, ancestor list). +- [ ] Manually traced: does the change affect `AxesAdapter`'s assumptions about `IsFullyLoaded` / `AreChildrenLoaded`? (See [`AxesLazyLoading.md`](AxesLazyLoading.md).) +- [ ] No I/O, logging, or caching was introduced into this file. diff --git a/src/Axlis.ORM.Core/WORKFLOWS.md b/src/Axlis.ORM.Core/WORKFLOWS.md new file mode 100644 index 0000000..0bcdc87 --- /dev/null +++ b/src/Axlis.ORM.Core/WORKFLOWS.md @@ -0,0 +1,29 @@ +# WORKFLOWS.md — Axlis.ORM.Core + +Project-scoped SOPs. See the repo-root [`WORKFLOWS.md`](../../WORKFLOWS.md) for the 3 repo-wide SOPs — this file adds narrower procedures specific to the domain model. + +## SOP C1 — Adding a New Field Type + +(Narrower version of repo-root SOP 1, steps 2–3, scoped to this project.) + +1. Confirm the contract exists in `Axlis.ORM.Abstractions/Fields/` (add it there first if not — see that project's `WORKFLOWS.md` SOP A1). +2. Add the class under `FieldTypes/`, deriving from `BaseField`, with a constructor taking a single `ItemTemplateField` — this is a hard runtime requirement, not a style preference (see this project's `CLAUDE.md` §"Formatting Constraint"). +3. If the field needs new raw data from GraphQL (a scalar/shape not already on `GraphQLFieldData`), extend that class in `GraphQL/GraphQLFieldData.cs` and map it in `ItemConverter.ConvertField` — read [`ItemConverter.md`](ItemConverter.md) first. +4. Add a test in `tests/Axlis.ORM.Core.Tests/FieldTypes/`, covering: populated value, missing/empty field via `ItemTemplateField.Empty(fieldName)`, and any lazy-loaded sub-graph (target items) if applicable. + +## SOP C2 — Adding a New Built-In Template + +1. Add the class under `Templates//` (`Common/` or `System/` today; add a new category folder if the template doesn't fit either). +2. Derive from `ExtendedItem`, decorate with `[SitecoreTemplate("{template-guid}")]`. +3. Expose each field as a property using `protected TField GetField(string fieldName)`. +4. Add a test under `tests/Axlis.ORM.Core.Tests/Model/` or a new `Templates/` test folder, following the pattern in `ExtendedItemTests.cs`. +5. De-branding check: template GUIDs and names must be generic/Sitecore-standard, never referencing a specific prior client implementation. + +## SOP C3 — Touching `ItemConverter`, `AxesAdapter`, or `ExtendedItem` + +These three files are this project's highest-risk surface (see `CLAUDE.md` §"Fragile Zones"). Before any change: + +1. Read the relevant module spec in full: [`ItemConverter.md`](ItemConverter.md) or [`AxesLazyLoading.md`](AxesLazyLoading.md). +2. Write the regression test **before** the fix/feature, using `ItemConverterTests.cs` or `ExtendedItemTests.cs` as the template for test shape. +3. If the change affects recursion depth, circular-reference handling, or the ambient static loader's lifecycle, call this out explicitly in the PR description — these are exactly the properties the module specs document as invariants. +4. Run the full `Axlis.ORM.Core.Tests` suite locally (`dotnet test tests/Axlis.ORM.Core.Tests`), not just the new test — these files are exercised indirectly by many other tests (e.g. `ItemTests.cs`, `ItemTemplateTests.cs`). diff --git a/src/Axlis.ORM.GraphQL/CLAUDE.md b/src/Axlis.ORM.GraphQL/CLAUDE.md new file mode 100644 index 0000000..bbf6b70 --- /dev/null +++ b/src/Axlis.ORM.GraphQL/CLAUDE.md @@ -0,0 +1,27 @@ +# CLAUDE.md — Axlis.ORM.GraphQL + +Scoped guidance for this project. Inherits everything in the repo-root [`CLAUDE.md`](../../CLAUDE.md). + +## Role of This Project + +`Axlis.ORM.GraphQL` is the default `IGraphQLTransport` provider and `ISitecoreService` implementation: `HttpGraphQLTransport` (raw `HttpClient` + `System.Text.Json`, no third-party GraphQL client), `HttpGraphQLTransportFactory` (named-`HttpClient` factory with per-site endpoint support), `GraphQLQueryBuilder` (batched alias query construction), `SitecoreService`, and `GraphQLClientException`. + +This project owns the network boundary of the whole ecosystem — the only place an actual HTTP call to a Sitecore GraphQL Edge endpoint happens. + +## Hard Constraints + +- **No third-party GraphQL library.** This is a stated design decision (see `docs/orm/Architecture.md`) — do not add `GraphQL.Client`, `StrawberryShake`, or similar. Queries are hand-built strings (`Queries/*.cs`), requests/responses are plain `System.Text.Json`-serializable POCOs (`Models/*.cs`). +- **Do not swallow exceptions here.** `HttpGraphQLTransport.ExecuteAsync` deliberately catches specific exception types (`HttpRequestException`, `JsonException`, `TaskCanceledException`, and a final catch-all) only to **wrap and rethrow** as `GraphQLClientException` — never to suppress. `SitecoreService` does not catch at all; it lets `GraphQLClientException` bubble to `SitecoreFacade`. Preserve this: this layer's job is to translate low-level failures into one consistent exception type, not to decide what happens on failure. +- **`SitecoreService` performs zero caching.** Caching is `Axlis.ORM`'s concern (`SitecoreItemCacheManager`). Do not add any `ICacheService` reference or in-memory memoization here — it would create a second, uncoordinated cache layer. +- **Batch queries use GraphQL aliases (`item0`, `item1`, ...), not query variables per item.** `GraphQLQueryBuilder.BuildBatchQuery` and `SitecoreService.ExecuteBatchAsync` are tightly coupled: the alias-to-path mapping returned by the builder must exactly match how the response dictionary is parsed. If you change alias naming in the builder, the parsing side must change in the same commit. +- **`JsonSerializerOptions` are duplicated between `HttpGraphQLTransport` and `SitecoreService`** (both define an equivalent `_jsonOptions` with `MaxDepth = 256`, `ReferenceHandler.IgnoreCycles`, etc.). If you change one, change the other, or better: flag the duplication for consolidation into a shared internal helper as a follow-up (don't silently leave them to drift). + +## HTTP/Resilience Notes + +- `HttpClient` instances are created via `IHttpClientFactory` (registered in `Axlis.ORM`'s DI extensions, not here) — this project only *consumes* the named client (`HttpGraphQLTransportFactory.DefaultClientName`), it does not configure retry/Polly policies itself. If resilience policies are needed, they belong in the DI registration in `Axlis.ORM`, not hardcoded in this project. +- Timeouts come from `AxlisGraphQLOptions.TimeoutSeconds`, applied to the named `HttpClient` at registration time — don't hardcode a timeout inside `HttpGraphQLTransport`. + +## See Also + +- [`WORKFLOWS.md`](WORKFLOWS.md) — SOPs specific to this project. +- Root [`CLAUDE.md`](../../CLAUDE.md) §3 for the exception-bubbling contract this project must honor. diff --git a/src/Axlis.ORM.GraphQL/WORKFLOWS.md b/src/Axlis.ORM.GraphQL/WORKFLOWS.md new file mode 100644 index 0000000..598a530 --- /dev/null +++ b/src/Axlis.ORM.GraphQL/WORKFLOWS.md @@ -0,0 +1,32 @@ +# WORKFLOWS.md — Axlis.ORM.GraphQL + +Project-scoped SOPs. See the repo-root [`WORKFLOWS.md`](../../WORKFLOWS.md) for the 3 repo-wide SOPs — this file adds narrower procedures specific to the transport/service layer. + +## SOP G1 — Adding a New Query / Service Operation + +Use when `ISitecoreFacade`/`ISitecoreService` needs a new data-access capability (e.g. a new "get items by template" operation). + +1. Add the contract method to `ISitecoreService` in `Axlis.ORM.Abstractions` first (see that project's SOP A1/A2 — this is a breaking-change-review candidate since `ISitecoreService` is a public interface). +2. Add the raw GraphQL query text under `Queries/` as a `static class` with a `const string Query`, following `GetItemByPathQuery`/`GetItemFlatQuery`. +3. Add request/response POCOs under `Models/` if the shape isn't already covered by `GraphQLItemData`/existing response types. +4. Implement the method on `SitecoreService`, following the existing pattern exactly: create transport via `_transportFactory.Create()`, build a `GraphQLTransportRequest`, `await transport.ExecuteAsync(...)`, convert via `ItemConverter.ToItem(...)`, log via `LogItemResult`. Do not add a `try/catch` here (see this project's `CLAUDE.md`). +5. Add tests in `tests/Axlis.ORM.GraphQL.Tests/SitecoreServiceTests.cs`, covering: success, "not found" (null item), and a transport-level error (verify it propagates as `GraphQLClientException`, not swallowed). +6. Implement the corresponding facade methods (Clean + Rich) in `Axlis.ORM` — see that project's `WORKFLOWS.md`. + +## SOP G2 — Modifying the Batch Query Builder + +Use when changing how `GraphQLQueryBuilder.BuildBatchQuery` constructs aliased multi-item queries, or how batch responses are parsed. + +1. Treat `GraphQLQueryBuilder` (alias generation) and `SitecoreService.ExecuteBatchAsync` (alias parsing) as **one unit of change** — never modify one without the other in the same commit. +2. Verify batch-size chunking still respects `AxlisGraphQLOptions.BatchSize` (`DefaultBatchSize` fallback when unset or ≤ 0). +3. Add/update tests in `tests/Axlis.ORM.GraphQL.Tests/GraphQLQueryBuilderTests.cs` for the builder, and `SitecoreServiceTests.cs` for end-to-end batch behavior — include a test with a path count that doesn't divide evenly by the batch size, and one where some aliases return `null`/missing in the response dictionary. +4. Confirm ordering is preserved: `GetItemsByPathsAsync` must return a result keyed by the original input paths regardless of batch/alias ordering. + +## SOP G3 — Changing Transport Error Handling + +Use when adjusting how `HttpGraphQLTransport` classifies or wraps failures. + +1. Preserve the invariant: every path out of `ExecuteAsync` either returns `TResponse?` successfully or throws `GraphQLClientException` — never a raw `HttpRequestException`/`JsonException`/etc. escapes this class. +2. If adding a new caught exception type, wrap it in `GraphQLClientException` with a clear message and the inner exception preserved, matching the existing catch blocks' style. +3. Add a test in `tests/Axlis.ORM.GraphQL.Tests/HttpGraphQLTransportTests.cs` for the new failure path, asserting the exception type surfaced to the caller is `GraphQLClientException`. +4. Do not change `GraphQLClientException`'s public shape (`IReadOnlyList`) without a breaking-change review — `SitecoreFacade` and any consumer catching this type depend on it. diff --git a/src/Axlis.ORM/CLAUDE.md b/src/Axlis.ORM/CLAUDE.md new file mode 100644 index 0000000..1b3e0fd --- /dev/null +++ b/src/Axlis.ORM/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md — Axlis.ORM (Facade) + +Scoped guidance for this project. Inherits everything in the repo-root [`CLAUDE.md`](../../CLAUDE.md). + +## Role of This Project + +`Axlis.ORM` is the top-level consumer-facing package: `SitecoreFacade` (the `ISitecoreFacade` implementation), `SitecoreItemCacheManager`, `SitecoreItemLazyLoader`, `LoggerAxlisDiagnosticsSink`, `AxlisOptions`, and the DI extension methods (`AddAxlisORM`, `AddAxlisORMGraphQL`, `UseAxlis`). This is the package most consumers install directly (`dotnet add package Axlis.ORM`), and its DI extensions are the first code a consuming application actually calls. + +**This is the only layer permitted to catch broadly and convert failures into `null`/`AxlisResult`.** Every other project lets exceptions bubble here by design — see root `CLAUDE.md` §3. + +## Hard Constraints + +- **External dependency: `PowerCSharp.Feature.Cache.Abstractions`.** This is the one real third-party (well, sibling-ecosystem) dependency in the whole solution. Treat its `ICacheService` contract as a black box — do not assume implementation details beyond its public interface (`GetAsync`, `SetAsync`, `RemoveAsync`). The default registration (`NoOpCacheService`) is a safe-off floor; production caching comes from `PowerCSharp.Feature.Cache.BitFaster`'s `AddBitFasterCache()`, which is **not** a dependency of this project — it's the consumer's choice, registered *before* `AddAxlisORM()`. +- **DI registration order matters and must stay documented.** `AddAxlisORM()` registers `NoOpCacheService` as a fallback; ASP.NET Core DI resolves the *first* registration of a service, so any real `ICacheService` a consumer registers must come *before* `AddAxlisORM()` in their `Program.cs`. Any change to registration order here is a breaking behavioral change for every consumer relying on override semantics — do not "simplify" this without flagging it loudly in the PR and changelog. +- **`UseAxlis(IServiceProvider)` wires static ambient state.** It calls `ExtendedItem.Initialize(lazyLoader)` — a `Core`-layer static field, set from here. This is the one place in the codebase where the facade reaches back into `Core`'s static surface. Do not duplicate this call elsewhere; consumers must call `UseAxlis()` exactly once, after `IServiceProvider` is built. +- **Clean vs. Rich API parity is enforced here, not upstream.** Every `Get*Async` on `SitecoreFacade` has a matching `Get*WithResultAsync`, sharing the same cache/service call but differing in error handling (log-and-null vs. diagnostics-populated `AxlisResult`). Adding one without the other is an incomplete PR — see root `CLAUDE.md` §3. +- **`SitecoreFacade.MapToTyped` uses `Activator.CreateInstance()`.** This means every `IBaseItem`/`ExtendedItem` consumer template class must have a public parameterless constructor — this is an implicit public contract for anyone authoring template POCOs. Don't change this to require a different construction mechanism without a migration path (it would break every existing consumer's template classes). + +## Fragile Zone + +- [`Caching/CacheManager.md`](Caching/CacheManager.md) — dual-indexing, null-miss non-caching, TTL/eviction semantics, and the sync-over-async pattern in `SitecoreItemLazyLoader` that reads through this cache. + +## Naming Note (Known Doc Drift) + +The DI extension methods are `AddAxlisORM()` / `AddAxlisORMGraphQL()` / `UseAxlis()` in current code (post ecosystem-rename). Some docs (`docs/orm/Caching.md`) still reference the pre-rename `AddAxlis()` / `AddAxlisGraphQL()`. When writing new docs or examples, use the current names; when you notice stale references in existing docs, fix them as part of the change rather than propagating them. + +## See Also + +- [`WORKFLOWS.md`](WORKFLOWS.md) — SOPs specific to this project. +- Root [`CLAUDE.md`](../../CLAUDE.md) §3 for the exception-handling contract this project implements. diff --git a/src/Axlis.ORM/Caching/CacheManager.md b/src/Axlis.ORM/Caching/CacheManager.md new file mode 100644 index 0000000..83f1fa1 --- /dev/null +++ b/src/Axlis.ORM/Caching/CacheManager.md @@ -0,0 +1,34 @@ +# Module Spec — `SitecoreItemCacheManager` + +**File:** `src/Axlis.ORM/Caching/SitecoreItemCacheManager.cs` +**Consumed by:** `SitecoreFacade` (every cacheable fetch method), `SitecoreItemLazyLoader` (`src/Axlis.ORM/LazyLoader/SitecoreItemLazyLoader.cs`) +**Wraps:** `ICacheService` from `PowerCSharp.Feature.Cache.Abstractions` (external dependency, treated as a black box — see this project's `CLAUDE.md`) +**Status:** Fragile zone — referenced from root [`CLAUDE.md`](../../CLAUDE.md) §4 and this project's `CLAUDE.md`. + +## Purpose + +Stampede-safe(*) in-memory cache for `IItem` results, sitting between `SitecoreFacade`/`SitecoreItemLazyLoader` and `ISitecoreService`. Reduces redundant GraphQL round-trips for repeated lookups of the same item by ID or path. + +*(*) Stampede-safety is a property of the underlying `ICacheService` implementation (e.g. `PowerCSharp.Feature.Cache.BitFaster`), not of this class itself — `SitecoreItemCacheManager` does not implement its own locking/single-flight logic. Do not assume stampede protection if a consumer has swapped in a custom `ICacheService` without that guarantee.* + +## Invariants (do not break these silently) + +1. **Dual/triple indexing.** On a cache miss + successful fetch, the item is stored under: (a) the original lookup `key` as passed in, (b) the item's own `Id` (if different from `key`), (c) the item's own `Path` (if different from `key`). This is what lets a lookup by ID resolve from cache after a prior lookup by path, and vice versa. If you change the key-write logic, all three writes must stay in sync, or this cross-resolution silently stops working. +2. **Null results are never cached.** If `factory()` returns `null` (item not found upstream), nothing is written to any cache key — the docstring is explicit about why: "so a later re-publish can be picked up on the next request." Do not add null-caching as a "performance optimization" — it directly breaks Sitecore publish-then-refetch semantics that consumers rely on. +3. **Key comparison is case-insensitive** (`StringComparison.OrdinalIgnoreCase`) when deciding whether the item's own ID/path differs from the requested key (to decide whether to write a second/third cache entry) — but the underlying `ICacheService.GetAsync`/`SetAsync` key comparison semantics are whatever the injected implementation does. If a custom `ICacheService` is case-sensitive, dual-indexing can silently produce three *different* cache entries for what this class considers "the same key" (e.g. `/sitecore/content/Home` vs. `/Sitecore/Content/Home`). This is a real interop risk between this class's assumptions and a consumer-supplied cache implementation — flag it if you see related bug reports. +4. **TTL is a single value for all items** (`AxlisOptions.CacheTtl`, default 60 minutes, `null` = no expiry) — there is no per-item or per-type TTL override today. If you need one, this class's signature (`GetOrCreateAsync(key, factory, ct)`) has no room for it; that's a deliberate simplicity trade-off, not an oversight — treat adding per-item TTL as an API-widening change requiring a new overload, not a silent change to existing behavior. +5. **`InvalidateAsync` only evicts the single key passed in.** It does **not** know about or evict the other 1–2 keys (ID/path) that may have been written alongside it during a prior `GetOrCreateAsync` call. A consumer invalidating by path after a lookup that also indexed by ID must call `InvalidateAsync` for both keys explicitly (see the `PublishWebhookController` example in `docs/orm/Caching.md`, which already does this correctly — do not "simplify" that example to a single call). + +## When Modifying + +1. Any change to the dual-indexing write logic requires updated tests in `tests/Axlis.ORM.Tests/SitecoreItemCacheManagerTests.cs` covering: write-then-read-by-ID, write-then-read-by-path, and the null-miss non-caching behavior. +2. Do not add caching of `null`/not-found results without an explicit, separate opt-in mechanism and a call-out in `CHANGELOG.md` — this is a behavioral change every consumer depends on implicitly. +3. If you touch `SitecoreItemLazyLoader` (which routes lazy `Axes` fetches through this same cache manager), re-read [`../../Axlis.ORM.Core/AxesLazyLoading.md`](../../Axlis.ORM.Core/AxesLazyLoading.md) — the sync-over-async wrapper there depends on this class's async methods completing promptly (a cache implementation that blocks or is slow directly affects that hazard). +4. Keep `docs/orm/Caching.md` in sync if you change the public shape of `GetOrCreateAsync`/`InvalidateAsync` — and fix the stale `AddAxlis()`/`AddAxlisGraphQL()` naming in that doc if you're already editing it (see this project's `CLAUDE.md` "Naming Note"). + +## Verification Checklist for Any Change Here + +- [ ] `SitecoreItemCacheManagerTests.cs` covers dual-indexing for both read directions. +- [ ] Null-miss non-caching behavior explicitly tested and still passing. +- [ ] If TTL/key semantics changed: `docs/orm/Caching.md` config table updated to match. +- [ ] If `InvalidateAsync` semantics changed: confirmed against the multi-key invalidation example in `docs/orm/Caching.md`. diff --git a/src/Axlis.ORM/WORKFLOWS.md b/src/Axlis.ORM/WORKFLOWS.md new file mode 100644 index 0000000..137236a --- /dev/null +++ b/src/Axlis.ORM/WORKFLOWS.md @@ -0,0 +1,29 @@ +# WORKFLOWS.md — Axlis.ORM (Facade) + +Project-scoped SOPs. See the repo-root [`WORKFLOWS.md`](../../WORKFLOWS.md) for the 3 repo-wide SOPs — this file adds narrower procedures specific to the facade/DI layer. + +## SOP F1 — Adding a New Facade Method (Clean + Rich Pair) + +1. Confirm the underlying `ISitecoreService` operation already exists (see `Axlis.ORM.GraphQL`'s SOP G1 if not). +2. Add both signatures to `ISitecoreFacade` in `Axlis.ORM.Abstractions` in the same change — never ship Clean without Rich or vice versa (see this project's `CLAUDE.md`). +3. Implement the Clean method: call `_cacheManager.GetOrCreateAsync(...)` (or the service directly if the operation isn't cacheable), `try/catch` broadly, log via `_logger?.LogError`, return `null` on failure. Follow the exact shape of `GetItemByPathAsync` as the template. +4. Implement the Rich method: same call, but build an `AxlisDiagnostics` instance, populate it via `AddDiagnostic`/`RecordError`, and return `AxlisResult` via `BuildResult`. Follow `GetItemByPathWithResultAsync` as the template. +5. Add tests in `tests/Axlis.ORM.Tests/SitecoreFacadeTests.cs` for both methods: happy path, not-found, and service-throws (verify Clean returns `null` + logs, Rich returns populated diagnostics with `DiagnosticSeverity.Error`/`Warning` as appropriate). +6. If the operation should be cached, route it through `SitecoreItemCacheManager.GetOrCreateAsync` — read [`Caching/CacheManager.md`](Caching/CacheManager.md) first if this is a new caching pattern (e.g. a new key shape beyond ID/path). + +## SOP F2 — Changing DI Registration (`AddAxlisORM`, `AddAxlisORMGraphQL`, `UseAxlis`) + +This is a high-caution SOP — every consumer's `Program.cs` depends on the current registration order and lifetime choices. + +1. Preserve registration order semantics: `AddAxlisORM()` must register its `NoOpCacheService` fallback in a way that a consumer's earlier `ICacheService` registration still wins (ASP.NET Core "first registration wins" behavior). Do not reorder without testing both "consumer registered their own cache" and "consumer registered nothing" scenarios. +2. Preserve service lifetimes exactly (`AddSingleton` for `SitecoreItemCacheManager`, `IItemLazyLoader`, `IAxlisDiagnosticsSink`, `ISitecoreFacade`) unless there's a deliberate, documented reason to change one — a lifetime change is a breaking behavioral change, not a refactor. +3. If adding a new required service, provide it a sensible default registration so `AddAxlisORM()` remains a one-call "it just works" experience — don't force new mandatory configuration on existing consumers. +4. `UseAxlis()` must remain idempotent-safe to call once and must continue to be the single wiring point for `ExtendedItem`'s static loader — do not introduce a second code path that also calls `ExtendedItem.Initialize`. +5. Add/update a test in `tests/Axlis.ORM.Tests/` (or a new DI-focused test file) that builds a minimal `ServiceCollection`, calls `AddAxlisORM()` + `AddAxlisORMGraphQL()`, builds the provider, and resolves `ISitecoreFacade` successfully — this is the cheapest regression guard against a broken registration graph. +6. Update `docs/orm/GettingStarted.md` and `docs/orm/Caching.md` if the public registration API surface changes at all — and fix any stale `AddAxlis()`/`AddAxlisGraphQL()` references you encounter while you're in there (see this project's `CLAUDE.md` "Naming Note"). + +## SOP F3 — Touching `SitecoreItemCacheManager` or `SitecoreItemLazyLoader` + +1. Read [`Caching/CacheManager.md`](Caching/CacheManager.md) in full first — this is a documented fragile zone. +2. Any change to key normalization (ID vs. path, case sensitivity) must be verified against `tests/Axlis.ORM.Tests/SitecoreItemCacheManagerTests.cs` for both index paths (by-ID and by-path retrieval after a by-path write, and vice versa). +3. `SitecoreItemLazyLoader.LoadItem` is synchronous by design (`GetAwaiter().GetResult()` over the async cache/service call) because `ExtendedItem`'s property getters are synchronous. Do not "fix" this by making it async without a much larger redesign of `IItemLazyLoader`/`ExtendedItem` — flag any deadlock concern in ASP.NET Core vs. non-ASP hosts explicitly in the PR rather than silently changing the sync/async boundary.