spiderly 19.10.0 → 19.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -205,6 +205,8 @@ new Column({ field: 'total', name: 'Total', filterType: 'numeric',
205
205
 
206
206
  It receives a `CellClickEvent` — `{ id, field, row, value, displayValue, element, originalEvent }`, where `value` is the raw cell value and `displayValue` is the formatted text shown. Only columns that set it become clickable (they get a `cursor`/hover affordance), and the click stops propagation so it does **not** also trigger `navigateOnRowClick`. Anchor overlays with `element` — it's the clicked `<td>`, captured synchronously, so it stays valid inside an async handler (`originalEvent.currentTarget` is null once dispatch ends). Not applied to editable cells. (Reusing one popover across rows is the `show()` + `align()` case shown in the snippet above — bare `show()` swaps content but won't move an already-open panel.)
207
207
 
208
+ Style the popover's content at your component's SCSS **top level**, never nested under `:host` — PrimeNG appends the open popover to `document.body`, where `:host`-scoped rules (compiled to `[_nghost] … [_ngcontent]` descendant selectors) silently stop matching; top-level rules keep the `[_ngcontent]` scoping that travels with the moved nodes.
209
+
208
210
  ### Custom Toolbar Actions
209
211
 
210
212
  Project an `<ng-template spiderlyDataTableActions>` to add your own buttons (or any markup) to the table's toolbar, alongside the built-in Clear Filters / Export to Excel / Reload / Delete Selected buttons. Import `SpiderlyDataTableActionsDirective` in the consuming component.
@@ -361,6 +363,8 @@ menu: SpiderlyMenuItem[] = [
361
363
  </spiderly-layout>
362
364
  ```
363
365
 
366
+ The side menu's desktop collapse state persists automatically (localStorage key `spiderly-layout:menu-desktop-inactive`), so the sidebar stays collapsed/expanded across reloads.
367
+
364
368
  ## Validation
365
369
 
366
370
  ### ValidatorAbstractService
@@ -25,6 +25,23 @@ namespace Foo.Business.Entities
25
25
 
26
26
  Hand-written DTOs use `[SpiderlyDTO]`. Generated DTOs (`{Entity}DTO`, `{Entity}SaveBodyDTO`, `{Entity}MainUIFormDTO`) need no marker. The `spiderly add-new-entity` CLI emits `[SpiderlyEntity]` automatically.
27
27
 
28
+ ### Generated DTO nullability
29
+
30
+ `[Required]` decides it for **reference types only**:
31
+
32
+ | Entity property | Generated DTO property |
33
+ | ------------------------- | --------------------------------------------------------- |
34
+ | `[Required] string Name` | `string Name { get; set; } = null!;` |
35
+ | `string Slug` | `string? Slug` (bare `string` for a nullable-oblivious app) |
36
+ | `[Required] int Quantity` | `int? Quantity` — value types stay nullable, see below |
37
+ | `int Stock` | `int? Stock` |
38
+ | any navigation | `long? CategoryId` + `string? CategoryDisplayName` |
39
+ | any collection | unchanged, with `= new()` |
40
+
41
+ **Value types stay nullable even under `[Required]`, deliberately.** A generated `{Entity}DTO` serves four roles at once: response shape, request shape, the Angular form model (an empty numeric input posts `null`), and the sparse placeholder carrier for `[ComplexManyToManyList]` grids, where an all-null row *is* the "no record" sentinel. The last two need `null` to be representable, and `int` cannot hold it. Reference types are safe to tighten because, with `RespectNullableAnnotations` off, a non-nullable `string` still accepts `null` at runtime — `= null!` is an assertion for the compiler and for humans, not a runtime constraint.
42
+
43
+ **The C# annotation is invisible to the OpenAPI spec unless the app opts in** with `spiderly.AddSwagger(options => options.SupportNonNullableReferenceTypes())`. Not on by default: it narrows the published contract (members become `required`), which regenerates every consumer's typed client — schedule it as its own deploy. `spiderly init` scaffolds it, since a new app has no consumers yet. `AddSwagger`'s callback runs after Spiderly's defaults and is the seam for any other Swashbuckle option too.
44
+
28
45
  ## Base Classes
29
46
 
30
47
  | Base Class | Use When | Generated |
@@ -67,7 +84,7 @@ public virtual Brand Brand { get; set; }
67
84
  ### When to use it
68
85
 
69
86
  - **Hand-written save/sync code** that builds the entity directly (`new Order { BrandId = id, ... }`) — skips the `FindAsync` + navigation-attach roundtrip that the naive pattern requires
70
- - **Hot read paths** with `ProjectToDTO` — the mapper emits `x.BrandId` instead of the `EF.Property<long>(x, "BrandId")` workaround for [EF Core #15826](https://github.com/dotnet/efcore/issues/15826), which otherwise still forces a JOIN in some queries
87
+ - **Hot read paths** in mapper projections — the mapper emits `x.BrandId` instead of the `EF.Property<long>(x, "BrandId")` workaround for [EF Core #15826](https://github.com/dotnet/efcore/issues/15826), which otherwise still forces a JOIN in some queries
71
88
 
72
89
  ### Rules
73
90
 
@@ -298,7 +315,6 @@ The complete list of every Spiderly attribute and its valid target is generated
298
315
  | `[ExcludeServiceMethodsFromGeneration]` | Property | Skip generated service methods (implement custom logic) |
299
316
  | `[GreaterThanOrEqualTo(N)]` | Property | Numeric minimum validation |
300
317
  | `[Email]` | Property | Email format validation |
301
- | `[ProjectToDTO(".Map(...)")]` | Class | Custom Mapster projection |
302
318
  | `[GenerateCommaSeparatedDisplayName]` | Property | Add comma-separated display names to DTO |
303
319
  | `[ComplexManyToManyList]` | Property | Editable list UI for complex M2M junction (small sets only) |
304
320
  | `[ComplexManyToManyReadonlyTable]` | Property | Read-only table for complex M2M junction |
@@ -27,7 +27,6 @@
27
27
  | `[M2MWithMany]` | Property | Marks a property in a many-to-many (M2M) relationship. |
28
28
  | `[MaxFileSize]` | Property | Specifies the maximum allowed file size (in bytes) for a blob property. When not applied, the file upload defaults to 20 MB (20,000,000 bytes). Use this attribute alongside any StorageAttribute subclass for file uploads. |
29
29
  | `[Output]` | All | Specifies the output configuration for the Source Generator. Note: This is a temporary solution and may be replaced in future versions. |
30
- | `[ProjectToDTO]` | Class | Specifies custom mapping configuration when projecting an entity to its DTO. |
31
30
  | `[ReadOnly]` | Property | Marks a property as server-owned: the client may read it, but can never write it (mirrors OpenAPI readOnly). The property stays in the read {Entity}DTO — so it's still returned by GET endpoints and shown in the list table — but the generator closes the write path for it: no inbound FluentValidation rule is emitted, and the generated DTO→entity Mapster config .Ignore()s it, so a crafted payload can't tamper with it. It also gets no editable control in the generated details form. Use for values only backend code assigns — usage counters, denormalized aggregates, computed timestamps. Combine with [Required] to document the non-null DB column without generating an unsatisfiable inbound rule (the form never sends it). This differs from [UIDoNotGenerate] (hidden from the form but still writable and validated) and [ExcludeFromDTO] (dropped from every DTO, unreadable). |
32
31
  | `[S3PrivateStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PrivateStorageService. The column stores an opaque S3 key; access is expected to be mediated via signed URLs or a backend proxy rather than direct CDN retrieval. Intended for files that contain personal or compliance-sensitive data (warranty receipts, ID documents, customer-uploaded invoices). |
33
32
  | `[S3PublicStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PublicStorageService. The bucket is configured for public access and the column stores a fully-qualified CDN URL; objects are uploaded with Cache-Control: public, max-age=31536000, immutable. |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mapper-customization
3
- description: Customize Spiderly-generated Mapster mappers. Use when overriding DTO-to-entity or entity-to-DTO mapping, adding computed fields to DTOs, customizing query projections, or using the ProjectToDTO attribute.
3
+ description: Customize Spiderly-generated Mapster mappers. Use when overriding DTO-to-entity or entity-to-DTO mapping, adding computed fields to DTOs, customizing query projections, or implementing Customize* partial mapper hooks.
4
4
  ---
5
5
 
6
6
  # Mapper Customization
@@ -35,48 +35,71 @@ public static TypeAdapterConfig CartToDTOConfig()
35
35
  }
36
36
  ```
37
37
 
38
- ## `[ProjectToDTO]` — Inline Custom Mappings
38
+ ## `Customize*` Partial Hooks — Add Mappings in Real C#
39
39
 
40
- Add custom mappings to the projection method without overriding it. Applied to the **entity class** (not properties). `AllowMultiple = true`.
40
+ Every generated config method declares a matching `static partial void Customize{MethodName}(TypeAdapterConfig config)`
41
+ hook and calls it before returning. Implement it in your hand-written `Mapper` partial class to
42
+ **add** custom mappings on top of the generated ones — compiler-checked, IntelliSense-assisted,
43
+ and able to express null guards (which a projection through an optional navigation requires):
41
44
 
42
45
  ```csharp
43
- [ProjectToDTO(".Map(dest => dest.TransactionPrice, src => src.Transaction.Price)")]
44
- public class Achievement : BusinessObject<long>
46
+ [SpiderlyDataMapper]
47
+ public static partial class Mapper
45
48
  {
46
- // ...
49
+ static partial void CustomizeOrderItemProjectToConfig(TypeAdapterConfig config)
50
+ {
51
+ // ProductVariant is an optional nav (nullable FK) — the guard is mandatory:
52
+ // an unguarded src.ProductVariant.ProductId LEFT JOINs to NULL and crashes the
53
+ // EF shaper with "Nullable object must have a value" at materialization.
54
+ config.ForType<OrderItem, OrderItemDTO>()
55
+ .Map(dest => dest.ProductId, src => src.ProductVariant != null ? (int?)src.ProductVariant.ProductId : null);
56
+ }
47
57
  }
48
58
  ```
49
59
 
50
- The string is appended directly to the generated `.NewConfig<Entity, EntityDTO>()` chain. Use this for simple field mappings that the generator doesn't produce automatically.
60
+ Use `config.ForType<...>()` (get-or-extend), never `NewConfig` (replace) — the generated
61
+ M2O/display-name mappings are already on the config when the hook runs. An unimplemented hook
62
+ compiles away entirely; implementing a hook whose config method you also fully overrode (see below)
63
+ is a compile error (`CS0759`), so a dead hook can't sit around silently.
51
64
 
52
- ### `[ProjectToDTO]` only fills the value — the field must exist on the DTO
65
+ **Precedence (verified against Mapster 7.4.0):** `.Map` is *first-registration-wins* — a hook
66
+ `.Map` targeting a member the generator already mapped is a **silent no-op**, not an override.
67
+ `.Ignore` *does* win over an earlier `.Map`. So from a hook you can **add** new member mappings and
68
+ **suppress** generated ones, but you cannot **replace** a generated mapping's expression — that is
69
+ exactly what the full-method override (below) remains for.
53
70
 
54
- `[ProjectToDTO]` adds a Mapster *mapping*; it does **not** create the property. If `dest.ProductId`
55
- is not a property on the DTO, the field **never appears in the generated Angular type**
56
- (`entities.generated.ts`) — `[ProjectToDTO]` fills a value that has nowhere to land. (A common
57
- false alarm: the value maps fine at runtime, so it looks like the frontend "didn't regenerate" —
58
- but a normal `dotnet build` *does* regenerate the Angular files; the property was just never on
59
- the DTO for the generator to emit.)
71
+ ### Convention flattening is OFF — unmapped extension props stay unmapped
60
72
 
61
- To add a computed/projected field end-to-end, declare the property on a `partial class {Entity}DTO`
62
- extension — it merges into the generated DTO automatically, no attribute needed — **then** map its value:
73
+ Generated configs strip Mapster's flatten-by-name strategy (`NewStrictConfig()` in the generated
74
+ mapper). Without this, a DTO extension prop named e.g. `ShippingTierIsBulky` would *silently*
75
+ project through `src.ShippingTier.IsBulky` — and when that navigation is optional, the LEFT JOIN's
76
+ NULL crashes the shaper on a non-nullable member. With flattening off, a prop you never mapped
77
+ simply stays at its default; wire it deliberately via a `Customize*` hook.
78
+
79
+ ### The hook only fills the value — the field must exist on the DTO
80
+
81
+ A mapping does **not** create the property. If `dest.ProductId` is not a property on the DTO, the
82
+ field **never appears in the generated Angular type** (`entities.generated.ts`). Declare the
83
+ property on a `partial class {Entity}DTO` extension — it merges into the generated DTO
84
+ automatically, no attribute needed — **then** map its value in the hook:
63
85
 
64
86
  ```csharp
65
- // 1. The property — a partial extension of the generated OrderItemDTO. No [SpiderlyDTO]
66
- // needed: a partial that extends a generated DTO is merged in by name.
87
+ // The property — a partial extension of the generated OrderItemDTO. No [SpiderlyDTO]
88
+ // needed: a partial that extends a generated DTO is merged in by name.
67
89
  public partial class OrderItemDTO
68
90
  {
69
91
  public int? ProductId { get; set; }
70
92
  }
71
-
72
- // 2. The value — [ProjectToDTO] on the entity fills it during projection.
73
- [ProjectToDTO(".Map(dest => dest.ProductId, src => src.ProductVariant.ProductId)")]
74
- public class OrderItem : BusinessObject<long> { /* ... */ }
75
93
  ```
76
94
 
77
95
  Then `dotnet build` the backend — the source generators run on build and the field appears
78
96
  in `entities.generated.ts`. There is no separate "regenerate" command; the build is it.
79
97
 
98
+ > **Removed:** the string-based `[ProjectToDTO(".Map(...)")]` entity attribute. Its string DSL
99
+ > could not express a null guard (the parser choked on anything beyond a bare dotted path), which
100
+ > made unsafe projections through optional navs the *only* writable form. Move any existing usage
101
+ > into the corresponding `Customize{Entity}ProjectToConfig` hook.
102
+
80
103
  ## Partial Method Override — Full Control
81
104
 
82
105
  For complex mapping logic, override the entire generated method. The generator **skips generation** for any method that already exists in the user's partial `Mapper` class (detected by method name match).
@@ -124,10 +147,9 @@ public static partial class Mapper
124
147
 
125
148
  | Scenario | Approach |
126
149
  |---|---|
127
- | Add a simple computed field to projection | `[ProjectToDTO(".Map(...)")]` on entity class |
128
- | Add multiple computed fields to projection | Stack multiple `[ProjectToDTO]` attributes |
129
- | Complex mapping with conditionals or method calls | Override the full method in `Mapper.cs` |
130
- | Change DTO → Entity mapping (e.g., ignore a field) | Override `{Entity}DTOToEntityConfig()` |
131
- | Change Excel export projection | Override `{Entity}ExcelProjectToConfig()` |
150
+ | Add computed/custom fields to any config (projection, DTO, save, Excel) | Implement the matching `Customize{Entity}{Method}` partial hook |
151
+ | Mapping through an optional navigation | `Customize*` hook with an explicit null guard |
152
+ | Suppress a generated mapping | `Customize*` hook with `.Ignore(dest => dest.X)` |
153
+ | Change or replace generated mappings | Override the full method in `Mapper.cs` |
132
154
 
133
155
  Most projects never need custom mappers — the generated mappings handle M2O, M2M display names, and standard field-to-field mapping automatically.
@@ -31,7 +31,12 @@ Follow Spiderly's fail-loudly convention: every refused-to-start condition exits
31
31
 
32
32
  > Working tree must be clean before upgrading. Commit or stash your changes first — the upgrade will make many edits, and a clean tree is your `git restore .` safety net.
33
33
 
34
- 4. **Detect current version.** Read all `Spiderly.*` `PackageReference` `Version=` values across every csproj, plus the `"spiderly"` value in `Frontend/package.json`. They should agree. If they drift, list the values and ask the user which to treat as "current".
34
+ 4. **Detect current version.** Versions live in one of two places — check for **Central Package Management first**:
35
+
36
+ - **CPM (`Directory.Packages.props` exists next to or above the csprojs):** versions are `<PackageVersion Include="Spiderly.X" Version="..." />` there, and the csprojs carry **versionless** `<PackageReference Include="Spiderly.X" />`. Reading `Version=` off the csprojs finds nothing — don't conclude "not a Spiderly app".
37
+ - **Classic:** `Version=` on each csproj's `PackageReference`.
38
+
39
+ Read whichever applies, plus the `"spiderly"` value in `Frontend/package.json`. They should agree. If they drift, list the values and ask the user which to treat as "current". Record which layout you found — Step 6 bumps the same place.
35
40
 
36
41
  5. **Multi-solution monorepo.** If multiple Backend dirs (each with their own Spiderly refs) exist, ask the user which one to upgrade. One app per invocation.
37
42
 
@@ -39,7 +44,7 @@ Follow Spiderly's fail-loudly convention: every refused-to-start condition exits
39
44
 
40
45
  - If arg is `latest` or omitted: WebFetch `https://api.nuget.org/v3-flatcontainer/spiderly.shared/index.json`, parse the `versions` array, pick the highest stable (no `-` suffix), confirm with the user.
41
46
  - Otherwise validate the arg as `X.Y.Z` (or `X.Y.Z-preview.N` if the user is explicitly opting into a preview — warn that preview release notes are usually thin).
42
- - Reject downgrades: if target < current, exit `Downgrades are not supported. To downgrade, edit Spiderly.* PackageReference Version= attributes and the "spiderly" npm dep manually, then run dotnet restore and npm install.`
47
+ - Reject downgrades: if target < current, exit `Downgrades are not supported. To downgrade, edit the Spiderly.* versions (Directory.Packages.props under CPM, otherwise the csproj PackageReference Version= attributes) and the "spiderly" npm dep manually, then run dotnet restore and npm install.`
43
48
  - Reject no-op: if target == current, exit `Already on {version}.`
44
49
 
45
50
  ## Step 3 — Fetch what changed
@@ -94,7 +99,7 @@ Code edits (N items affect you):
94
99
  Manual steps after upgrade:
95
100
  - <step the agent can't do for you>
96
101
 
97
- Version bumps: Spiderly.* 19.5.0 → 21.2.0 (4 csproj + Frontend/package.json[ + .claude/settings.json legacy-plugin removal])
102
+ Version bumps: Spiderly.* 19.5.0 → 21.2.0 (Directory.Packages.props | 4 csproj + Frontend/package.json[ + .claude/settings.json legacy-plugin removal])
98
103
 
99
104
  After approval: apply → bump → migrate guidance + config → restore + install (parallel) → build → agent-sync. Up to 2 build-fix retries on failure.
100
105
  ```
@@ -108,7 +113,19 @@ Present the plan and ask the user explicitly. Don't proceed without a clear yes.
108
113
  In this order. A failure at any step is a hard stop until the recovery procedure in Step 7.
109
114
 
110
115
  1. **Code edits.** Apply each plan item via Edit. Read each target file first.
111
- 2. **Version bumps.** Iterate the csproj list cached in Step 1: rewrite every `<PackageReference Include="Spiderly.X" Version="OLD" />` to the new version. Rewrite `"spiderly": "OLD"` in `Frontend/package.json`. Don't re-glob.
116
+ 2. **Version bumps.** Bump the layout detected in Step 1.4:
117
+
118
+ - **CPM:** rewrite each `<PackageVersion Include="Spiderly.X" Version="OLD" />` in `Directory.Packages.props`. The csprojs need no edit — that is the point of CPM, and adding a `Version=` there is an error (`NU1008`).
119
+ - **Classic:** iterate the csproj list cached in Step 1 and rewrite every `<PackageReference Include="Spiderly.X" Version="OLD" />`.
120
+
121
+ Then rewrite `"spiderly": "OLD"` in `Frontend/package.json`. Don't re-glob.
122
+
123
+ **If the app commits `packages.lock.json` files** (`RestorePackagesWithLockFile`), the restore in item 5 updates them — but only for the current OS's resolution graph. Apps whose CI restores in `--locked-mode` on linux must regenerate the locks under the **linux SDK image**, or a macOS-only regeneration ships a lock that CI rejects. Add it to the manual steps:
124
+
125
+ ```bash
126
+ docker run --rm -v "$PWD:/src" -v <cache-volume>:/root/.nuget/packages -w /src \
127
+ mcr.microsoft.com/dotnet/sdk:<version> dotnet restore <Solution>.sln --force
128
+ ```
112
129
  3. **Migrate agent guidance off the legacy plugin.** Spiderly's AI-agent guidance now ships *inside* the `spiderly` npm package (projected by `spiderly agent-sync` in item 7 below), replacing the old `spiderly@spiderly` Claude Code plugin. If `.claude/settings.json` contains `extraKnownMarketplaces.spiderly` and/or `enabledPlugins."spiderly@spiderly"`, remove just those keys (leave any other settings intact; delete the file only if it becomes an empty `{}`). No settings file or no spiderly entries → skip.
113
130
  4. **Migrate `spiderly.json` → `.spiderly/config.json`.** Recent Spiderly moved the generator/api config out of a root `spiderly.json` into `.spiderly/config.json`. If the app has a `spiderly.json` registered via `<AdditionalFiles Include="spiderly.json" />`, `git mv` it to `.spiderly/config.json` and rewrite that csproj line to `<AdditionalFiles Include=".spiderly/config.json" />`. No `spiderly.json` → skip.
114
131
  5. **Restore packages in parallel.** Same message, two Bash calls: `dotnet restore` in Backend and `npm install` in Frontend. They share no locks. If either fails, surface the actual error and exit.
@@ -3151,7 +3151,27 @@ class InitTopBarData extends BaseEntity {
3151
3151
  static { this.typeName = 'InitTopBarData'; }
3152
3152
  }
3153
3153
 
3154
+ /** Parses a JSON entry from web storage; missing or corrupted entries read as null. */
3155
+ function readStoredJson(storage, key) {
3156
+ try {
3157
+ return JSON.parse(storage.getItem(key) ?? 'null');
3158
+ }
3159
+ catch {
3160
+ return null;
3161
+ }
3162
+ }
3163
+ /** Writes a value to web storage as JSON; the counterpart of readStoredJson. */
3164
+ function writeStoredJson(storage, key, value) {
3165
+ storage.setItem(key, JSON.stringify(value));
3166
+ }
3167
+
3154
3168
  class LayoutServiceBase {
3169
+ /**
3170
+ * Only the desktop collapse persists across reloads (a durable preference,
3171
+ * like the data table's column layout); the rest of LayoutState is
3172
+ * deliberately transient.
3173
+ */
3174
+ static { this.menuDesktopInactiveKey = 'spiderly-layout:menu-desktop-inactive'; }
3155
3175
  constructor(apiService, config, authService) {
3156
3176
  this.apiService = apiService;
3157
3177
  this.config = config;
@@ -3163,7 +3183,8 @@ class LayoutServiceBase {
3163
3183
  colorScheme: 'light',
3164
3184
  };
3165
3185
  this.state = {
3166
- staticMenuDesktopInactive: false,
3186
+ staticMenuDesktopInactive: readStoredJson(localStorage, LayoutServiceBase.menuDesktopInactiveKey) ===
3187
+ true,
3167
3188
  overlayMenuActive: false,
3168
3189
  profileSidebarVisible: false,
3169
3190
  profileDropdownSidebarVisible: false,
@@ -3200,6 +3221,7 @@ class LayoutServiceBase {
3200
3221
  if (this.isDesktop()) {
3201
3222
  this.state.staticMenuDesktopInactive =
3202
3223
  !this.state.staticMenuDesktopInactive;
3224
+ writeStoredJson(localStorage, LayoutServiceBase.menuDesktopInactiveKey, this.state.staticMenuDesktopInactive);
3203
3225
  }
3204
3226
  else {
3205
3227
  this.state.staticMenuMobileActive = !this.state.staticMenuMobileActive;
@@ -4179,23 +4201,14 @@ class SpiderlyDataTableComponent {
4179
4201
  localStorage.removeItem(this.columnsStateKey);
4180
4202
  }
4181
4203
  else {
4182
- localStorage.setItem(this.columnsStateKey, JSON.stringify(this.columnVisibilityOverrides));
4204
+ writeStoredJson(localStorage, this.columnsStateKey, this.columnVisibilityOverrides);
4183
4205
  }
4184
4206
  }
4185
4207
  restoreColumnVisibility() {
4186
4208
  if (!this.columnsStateKey)
4187
4209
  return;
4188
4210
  this.columnVisibilityOverrides =
4189
- SpiderlyDataTableComponent.readStoredJson(localStorage, this.columnsStateKey) ?? {};
4190
- }
4191
- /** Parses a JSON entry from web storage; missing or corrupted entries read as null. */
4192
- static readStoredJson(storage, key) {
4193
- try {
4194
- return JSON.parse(storage.getItem(key) ?? 'null');
4195
- }
4196
- catch {
4197
- return null;
4198
- }
4211
+ readStoredJson(localStorage, this.columnsStateKey) ?? {};
4199
4212
  }
4200
4213
  /**
4201
4214
  * PrimeNG's persisted table state (filters/sort/pagination), read directly from storage
@@ -4204,7 +4217,7 @@ class SpiderlyDataTableComponent {
4204
4217
  persistedTableState() {
4205
4218
  if (!this.resolvedStateKey)
4206
4219
  return null;
4207
- return SpiderlyDataTableComponent.readStoredJson(this.stateStorage === 'local' ? localStorage : sessionStorage, this.resolvedStateKey);
4220
+ return readStoredJson(this.stateStorage === 'local' ? localStorage : sessionStorage, this.resolvedStateKey);
4208
4221
  }
4209
4222
  /**
4210
4223
  * Guards the "hidden contributes nothing" invariant against state this component didn't
@@ -5421,6 +5434,8 @@ const unauthorizedInterceptor = (req, next) => {
5421
5434
  if (!config.production) {
5422
5435
  console.error(err);
5423
5436
  }
5437
+ // TODO: type this as an ApiError interface (TS mirror of ApiErrorDTO, next to errors/api-error-codes.ts)
5438
+ // so message/errorCode/traceId reads of the cross-language contract are compile-checked, not conventional.
5424
5439
  let errorResponse = err.error;
5425
5440
  if (request.responseType !== 'json' && typeof err.error === 'string') {
5426
5441
  try {
@@ -5430,31 +5445,36 @@ const unauthorizedInterceptor = (req, next) => {
5430
5445
  errorResponse = null;
5431
5446
  }
5432
5447
  }
5448
+ // ApiErrorDTO.traceId is present only on reportable errors, so this is a no-op everywhere else —
5449
+ // the server decides which responses carry a support reference, never this status-code chain.
5450
+ const withReference = (detail) => errorResponse?.traceId
5451
+ ? `${detail} ${translocoService.translate('ErrorReference', { traceId: errorResponse.traceId })}`
5452
+ : detail;
5433
5453
  if (err.status === 0) {
5434
5454
  // Server unreachable; defer so the message isn't lost during a shutdown/refresh race.
5435
5455
  setTimeout(() => {
5436
- messageService.warningMessage(translocoService.translate('ServerLostConnectionDetails'), translocoService.translate('ServerLostConnectionTitle'));
5456
+ messageService.warningMessage(withReference(translocoService.translate('ServerLostConnectionDetails')), translocoService.translate('ServerLostConnectionTitle'));
5437
5457
  }, 100);
5438
5458
  }
5439
5459
  else if (err.status === 400) {
5440
- messageService.warningMessage(errorResponse?.message ?? translocoService.translate('BadRequestDetails'), translocoService.translate('Warning'));
5460
+ messageService.warningMessage(withReference(errorResponse?.message ?? translocoService.translate('BadRequestDetails')), translocoService.translate('Warning'));
5441
5461
  }
5442
5462
  else if (err.status === 401) {
5443
5463
  if (errorResponse?.errorCode === ApiErrorCodes.InvalidToken) {
5444
5464
  authService.clearSession(); // expired/invalid session — drop it; guards send the user to login
5445
5465
  }
5446
5466
  else {
5447
- messageService.warningMessage(errorResponse?.message ?? translocoService.translate('LoginRequired'), translocoService.translate('Warning'));
5467
+ messageService.warningMessage(withReference(errorResponse?.message ?? translocoService.translate('LoginRequired')), translocoService.translate('Warning'));
5448
5468
  }
5449
5469
  }
5450
5470
  else if (err.status === 403) {
5451
- messageService.warningMessage(translocoService.translate('PermissionErrorDetails'), translocoService.translate('PermissionErrorTitle'));
5471
+ messageService.warningMessage(withReference(translocoService.translate('PermissionErrorDetails')), translocoService.translate('PermissionErrorTitle'));
5452
5472
  }
5453
5473
  else if (err.status === 404) {
5454
- messageService.warningMessage(translocoService.translate('NotFoundDetails'), translocoService.translate('NotFoundTitle'));
5474
+ messageService.warningMessage(withReference(translocoService.translate('NotFoundDetails')), translocoService.translate('NotFoundTitle'));
5455
5475
  }
5456
5476
  else {
5457
- messageService.errorMessage(translocoService.translate('UnexpectedErrorDetails'), translocoService.translate('UnexpectedErrorTitle'));
5477
+ messageService.errorMessage(withReference(translocoService.translate('UnexpectedErrorDetails')), translocoService.translate('UnexpectedErrorTitle'));
5458
5478
  }
5459
5479
  };
5460
5480
  return next(req).pipe(catchError((err) => {