Skip to content

fix(data-objectstack): map dataset-query failures by the server's error code, not its status (#5663) - #5723

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5663-analytics-error-branch-mapping
Aug 22, 2026
Merged

fix(data-objectstack): map dataset-query failures by the server's error code, not its status (#5663)#5723
os-zhuang merged 2 commits into
mainfrom
claude/issue-5663-analytics-error-branch-mapping

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5663

POST /api/v1/analytics/dataset/query answers 404 for two unrelated conditions. ObjectStackAdapter.queryDataset tested the status and could not see the difference:

if(res.status===501||res.status===404){thrownewAnalyticsNotInstalledError('POST /analytics/dataset/query',detail||undefined);}
conditionstatusADR-0112 codeproducer
route never mounted404ROUTE_NOT_FOUND@objectstack/runtimedispatcher-plugin.ts (wrapped envelope)
route mounted, no analytics service501NOT_IMPLEMENTED@objectstack/restregisterAnalyticsEndpoints
route answering, dataset unknown404NOT_FOUNDsame route, the body.datasetName lookup miss
session not authenticated401UNAUTHENTICATEDenforceAuthANONYMOUS_DENY_BODY (@objectstack/core)

Rows 1 and 3 share a status and nothing else. Every unknown dataset therefore produced the capability-missing banner: on the reported prod tenant, four HotCRM Executive Overview widgets told the operator to install @objectstack/service-analytics and mount AnalyticsServicePlugin, while the analytics service was installed and answering the whole time. The real condition was an installed app.objectstack.hotcrm at 1.3.0 whose datasets ship in 2.2.2 — a package upgrade, the opposite corner of the system from the remedy the banner named.

What changed

queryDataset now branches on the code the framework declares for each condition, and consults the status only as a residual when the answer carries no code at all:

  • NOT_IMPLEMENTED / ROUTE_NOT_FOUNDAnalyticsNotInstalledError, copy unchanged. One remedy, one message.
  • NOT_FOUND → new AnalyticsDatasetNotFoundError (ANALYTICS_DATASET_NOT_FOUND), which names the dataset and points at the installed app's version.
  • UNAUTHENTICATED → new AnalyticsUnauthenticatedError (ANALYTICS_UNAUTHENTICATED), which says the request was refused before it ran and therefore says nothing about the capability. Triage ruled this a third branch rather than a shade of either other one; an expired session reported as a missing capability is the same defect wearing a different mask.
  • No code at all → a bare 404/501 is still the capability-missing branch (nothing ObjectStack wrote it), a bare 401 is the unauthenticated branch, everything else keeps the generic Dataset query failed: … with its server detail.

The status is not a re-entry point here: the residual is reached only after every code branch has declined. A 404 is safe there and unsafe as a primary test for exactly the same reason — the route's own NOT_FOUND always ships a code, so a code-less 404 cannot be the unknown-dataset case.

The headline can no longer contradict its own parenthetical

The reported banner printed the server's real message in parentheses — (server said: Dataset "opportunity_metrics" not found.) — under a headline claiming a missing capability. A diagnostic that quotes its subject and then overrides its meaning is worse than one that says nothing, because it reads as authoritative.

That is now structurally impossible rather than merely corrected. The headline is a pure function of code; the parenthetical is a verbatim quote of message; both are read off the same response by the same reader, and message never feeds classification. If they ever disagree the producer has a bug — whereas before, the consumer did: the headline came from a status two conditions share while the quote came from the one that had actually happened. A parameterised test walks every branch asserting each message carries its own headline and none of the others'.

Reading two envelopes on purpose

One url, two possible producers, so readAnalyticsErrorEnvelope reads two shapes: the flat{ code, message } the route writes itself, and the wrapped{ success: false, error: { code, message } } the dispatcher writes when the route is not mounted. Both are live and sanctioned by ADR-0112's 2026-07-30 amendment.

This is not the tolerant body.error?.code ?? body.error chain @objectstack/core's anonymous-deny.ts warns consumers off. The two families are told apart structurally (typeof body.error === 'object'), never by trying one key and falling through to the other, so a producer that regresses its envelope reads as "no code" — the honest answer, which lands in the residual — instead of being quietly absorbed. And which family answered is itself part of the signal: only the wrapped ROUTE_NOT_FOUND can mean the route is absent, and only the route's own flat envelope can mean the dataset is.

Scope notes

  • Additive only.AnalyticsNotInstalledError keeps its code, its copy and its constructor arity (it gains an optional third serverCode argument plus a serverCode field). Consumers matching ANALYTICS_NOT_INSTALLED — including metadata-admin's DatasetPreview, whose test constructs the capability-missing string as a fixture — are untouched, so nothing under packages/app-shell/** needed editing.
  • A 404 with an unrecognised code is no longer relabelled. The analytics cube gate's CUBE_NOT_FOUND (404) used to land in the capability-missing branch by the same status collision; it now keeps its server detail. Pinned.
  • Out of scope here, filed separately:classifyAnalyticsFailure (same file, the /analytics/query face) has the same shape — status === 404 is tested before the code and short-circuits it, so a CUBE_NOT_FOUND is warned as a missing capability and silently degraded to a client-side aggregate. That is a different route face with a different envelope family, and correcting it moves degradation behaviour rather than copy, so it needs its own measurement: classifyAnalyticsFailure reads the status before the code, so /analytics/query's CUBE_NOT_FOUND is warned as a missing capability and silently degraded #5721.
  • No i18n keys added.@object-ui/data-objectstack is a transport adapter with no @object-ui/i18n dependency and no t() call sites; both live renderers (plugin-dashboard's DatasetWidget, app-shell's DatasetPreview) print error.message verbatim. Localising this copy means moving the mapping into a renderer, which is a different change in a different package. Adding pack keys nothing reads would be dead keys on arrival. Called out for the maintainer in the issue report.

Verification

All at 5c2376387, the branch head.

  • pnpm exec vitest run --maxWorkers=2 packages/data-objectstack/Test Files 42 passed (42) · Tests 576 passed (576) (545 before; 31 new)
  • pnpm --filter @object-ui/data-objectstack type-checktsc --noEmit, exit 0
  • pnpm exec eslint . in packages/data-objectstack✖ 368 problems (0 errors, 368 warnings), all pre-existing no-explicit-any
  • check:control-bytes✅ OK (scanned 4768 tracked text file(s); skipped 85 binary)
  • check:i18n-keys✅ Every in-scope call-site key resolves against the en pack (2924 keys)…
  • check:i18n-drift✅ No en value changed in this range.
  • check:phantom-deps, check:self-import, check:esm-specifiers → exit 0
  • check-changeset-presence.mjs✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
  • check-changeset-no-major.mjs✅ No changeset declares a major bump.

Ablation. Restoring the pre-fix res.status === 501 || res.status === 404 mapping (mutation confirmed on disk by counting both the injected marker, 1, and the deleted branch text, 0) turns 9 of the 31 new tests red: all four branch pins, the incident replay, the dataset-unknown and not-signed-in halves of the headline/parenthetical sweep, the bare-401 residual, and the unrecognised-404 pin. The route-absent headline pin correctly stays green — the ablation does not change that branch's verdict, only its serverCode provenance. No rebuild was needed: the test imports ./index by relative source path, so dist staleness cannot make the ablation falsely green. The restore leg ran under an EXIT INT TERM trap and was verified byte-identical to the commit (git diff --stat HEAD empty), then re-measured green at 576/576.

Cross-package type check. Against the rebuilt dist/index.d.ts: a consumer calling the pre-existing two-argument new AnalyticsNotInstalledError(surface, detail) still compiles (exit 0), and the reverse leg — passing datasetName: 404 — fails with TS2322: Type 'number' is not assignable to type 'string', confirming the declarations being read are the rebuilt ones and not a cache.

Declared narrowing. Repo-wide pnpm test / pnpm type-check / pnpm lint (all turbo run …) are CI's runs and are not duplicated here. The lint narrowing is a measurement, not a skip: the population comes from eslint's own config resolution via eslint . in the package — the exact command turbo run lint invokes for it — the file count (48) is read from --format json, and eslint.config.js enables no type-aware linting (tseslint.configs.recommended, no parserOptions.project / projectService), so every file's verdict is a function of its own text plus the shared config and this diff cannot move a verdict in any file it does not edit. The diff touches two files, both inside this one package.


Generated by Claude Code

os-project-managerand others added 2 commits August 22, 2026 21:10
…or code, not its status (#5663)
`POST /api/v1/analytics/dataset/query` answers 404 for two unrelated
conditions — the runtime dispatcher's `ROUTE_NOT_FOUND` when the route was
never mounted, and the route's own `NOT_FOUND` when `body.datasetName`
matches no saved dataset. `queryDataset` tested `res.status === 501 ||
res.status === 404` and called all of it "the analytics capability is not
installed", so every unknown dataset told the operator to install a server
plugin. Measured live on a prod tenant, that banner ran on four HotCRM
widgets while analytics was installed and answering; the real condition was
an installed app at 1.3.0 whose datasets ship in 2.2.2.
Branch on the ADR-0112 `code` instead, giving three conditions three
answers: `NOT_IMPLEMENTED`/`ROUTE_NOT_FOUND` keep the capability-missing
copy, `NOT_FOUND` gets the new `AnalyticsDatasetNotFoundError`, and `401
UNAUTHENTICATED` gets the new `AnalyticsUnauthenticatedError` rather than
being read as either. The headline is now a pure function of `code` and the
parenthetical a verbatim quote of `message` off the same response, so the
banner can no longer quote its subject and contradict it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
…e, not @link (#5663)
`readAnalyticsErrorEnvelope` is module-private, so a `{@link}` to it from an
exported class's docstring has no target in the emitted declarations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3915.1 KB3990.2 KB
Main entry chunk (gzip)152.2 KB350 KB
Entry fileindex-CepRrrSc.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)9.63KB3.74KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)508.30KB114.17KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.61KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review August 22, 2026 22:45
@os-zhuang
os-zhuang added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit c7cd2b6Aug 22, 2026
23 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5663-analytics-error-branch-mapping branch August 22, 2026 22:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Analytics widgets report "capability not installed" for a 401/dataset-not-found answer — misdiagnosis sends operators to the wrong fix

2 participants

@os-zhuang@os-project-manager