diff --git a/.changeset/retire-degraded-analytics-shim.md b/.changeset/retire-degraded-analytics-shim.md new file mode 100644 index 0000000000..a103eacef8 --- /dev/null +++ b/.changeset/retire-degraded-analytics-shim.md @@ -0,0 +1,47 @@ +--- +"@objectstack/metadata-protocol": major +"@objectstack/objectql": major +--- + +fix(metadata-protocol,objectql)!: retire the degraded analytics shim — the `analytics` slot stays empty without service-analytics (#3891, #3878) + +The protocol assembly (`assembleMetadataProtocol`, used by both +`MetadataProtocolPlugin` and `ObjectQLPlugin`'s built-in mode) used to register +a lightweight `analytics` fallback so `POST /api/v1/analytics/query` kept +answering on installs without `@objectstack/service-analytics`. That fallback +is **removed**, and with it the facade methods that existed only to serve it: +`ObjectStackProtocolImplementation.analyticsQuery` / `getAnalyticsMeta` (the +class no longer implements `AnalyticsProtocol`). + +Why removal instead of repair (#3891): + +- **It dropped the caller's ExecutionContext at the door.** The dispatcher + passes `context.executionContext` (#2852), but the shim's `query` was + unary — aggregation reached `engine.aggregate` with no context, the security + middleware's empty-principal branch waved it through, and **no RLS or tenant + predicate was injected**. An authenticated caller got a 200 with rows RLS + would hide. +- **It ignored the contract filter.** `AnalyticsQuery`'s canonical filter field + is `where`; the shim read only a non-contract `filters` key, so a + spec-conformant filtered request silently returned a full-table aggregate. +- **Every security gate had to be built twice** (#3770 on the shim vs + #3867/#3875 on the real engine) — the "duplicates logic only, harmless" + assessment in ADR-0076 D10 did not survive contact with reality. + +`getDiscovery()` stops hardcoding analytics as an always-on kernel service — +the entry is now computed from the service registry like every other optional +service (`enabled: false, status: 'unavailable'` and **no advertised route** +when absent), which also removes the pre-#2462 discovery lie the shim was +originally invented to make true. + +**Migration.** Deployments that relied on the fallback (programmatic +`createStandaloneStack()` / `createObjectQLKernel()` embeds, hosts whose bundle +doesn't require `analytics`): install `@objectstack/service-analytics` and +mount `AnalyticsServicePlugin` — the real, context-aware engine. Without it, +`/api/v1/analytics/*` now answers **404 ROUTE_NOT_FOUND** (previously: 200 with +unscoped, unfiltered aggregates) and discovery reports +`analytics: { enabled: false, status: 'unavailable' }`. Callers of +`protocol.analyticsQuery(...)` / `protocol.getAnalyticsMeta(...)` must use the +`analytics` service (`kernel.getService('analytics')`) instead. `os serve` +default/full presets and managed environments already force the real engine and +are unaffected. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 4c1221e104..a46690ba92 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -184,7 +184,10 @@ to set a new name or clear a unique field. ## Analytics -Semantic BI queries using a cube-style API. Available when the analytics service is enabled. +Semantic BI queries using a cube-style API. Provided by `@objectstack/service-analytics` — +on deployments without it these endpoints answer `404 ROUTE_NOT_FOUND` and discovery +reports `analytics: { enabled: false, status: "unavailable" }`. (The former kernel-level +degraded fallback was retired — it served unscoped, unfiltered aggregates.) ### `POST /analytics/query` diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index 3bf944daa0..15f1128dff 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -34,16 +34,16 @@ The ObjectStack protocol defines **16 kernel services** registered via the `Core ┌─────────────────────────────────────────────────────────┐ │ Kernel Layer │ │ ┌──────────────────┐ ┌──────────────────────────────┐ │ -│ │ metadata (⚠️) │ │ data + analytics (✅) │ │ +│ │ metadata (⚠️) │ │ data (✅) │ │ │ │ In-memory only │ │ ObjectQL example kernel │ │ │ │ DB persistence │ │ Will be rebuilt as plugins │ │ │ │ pending │ │ │ │ │ └──────────────────┘ └──────────────────────────────┘ │ ├─────────────────────────────────────────────────────────┤ │ Plugin Layer │ -│ All other services: auth, automation, workflow, ui, │ -│ realtime, notification, ai, i18n, search, │ -│ file-storage, cache, queue, job │ +│ All other services: analytics, auth, automation, │ +│ workflow, ui, realtime, notification, ai, i18n, │ +│ search, file-storage, cache, queue, job │ │ │ │ Discovery API reports: enabled/unavailable/degraded │ │ per service so clients adapt their UI accordingly │ @@ -58,7 +58,7 @@ The ObjectStack protocol defines **16 kernel services** registered via the `Core |:--|:-------------|:------------|:--------|:-------|:---------| | 1 | **metadata** | `core` | 7 | ⚠️ Framework | Kernel (in-memory) | | 2 | **data** | `required` | 9 | ✅ Implemented | `@objectstack/objectql` | -| 3 | **analytics** | `optional` | 2 | ✅ Implemented | `@objectstack/objectql` | +| 3 | **analytics** | `optional` | 2 | ❌ Plugin Required | `@objectstack/service-analytics` | | 4 | **auth** | `core` | — | 🟡 In Development | `@objectstack/plugin-auth` | | 5 | **ui** | `optional` | 5 | ❌ Plugin Required | TBD plugin | | 6 | **workflow** | `optional` | 3 | ❌ Plugin Required | TBD plugin | @@ -173,23 +173,26 @@ The discovery endpoint returns a `services` map so clients know what is availabl --- -## 3. analytics Service ✅ Implemented +## 3. analytics Service — Plugin Required **Service Name**: `analytics` · **Criticality**: `optional` -**Implementation**: `@objectstack/objectql` (driver-level capability) -**Route Mount**: `/api/v1/analytics` +**Implementation**: `@objectstack/service-analytics` (the only implementation) +**Route Mount**: `/api/v1/analytics` — only served when the plugin registers the service -### Protocol Methods - -| Method | Signature | Status | -|:-------|:----------|:------:| -| `analyticsQuery` | `AnalyticsQueryRequest → AnalyticsResultResponse` | ✅ | -| `getAnalyticsMeta` | `GetAnalyticsMetaRequest → AnalyticsMetadataResponse` | ✅ | + +The kernel-level "degraded analytics fallback" (a lightweight ObjectQL adapter +registered by the protocol assembly) was **retired** (#3891): it dropped the +caller's `ExecutionContext` — aggregates ran without RLS/tenant scoping — and +silently ignored the contract `where` filter. Without +`@objectstack/service-analytics`, `/api/v1/analytics/*` now answers **404** and +discovery reports `analytics: { enabled: false, status: "unavailable" }`. + -### Features +### Features (via `@objectstack/service-analytics`) -- Cube semantic queries: measures / dimensions / filters → ObjectQL aggregation -- Auto-generated metadata from SchemaRegistry (numeric → sum/avg, date → time dimensions) +- Cube semantic queries: measures / dimensions / `where` filter → SQL or ObjectQL aggregation +- Context-aware read scoping: per-object RLS/tenant predicates resolved from the request's `ExecutionContext` (fail-closed) +- Cube metadata (`/analytics/meta`), SQL preview (`/analytics/sql`), dataset queries (`/analytics/dataset/query`) --- diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index cc6a3c53cc..626763222a 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -104,7 +104,7 @@ The segmentation is **spec/type-level and may start incrementally now** (define A single `ObjectStackProtocolImplementation` facade — and any `*-protocol` *implementation* package — is the wrong end-state. Verified against source: - The facade implements only **4 of the 11** contract domains (data, metadata, analytics, feed). The other 7 (realtime / notifications / workflow / ai / i18n / views / permissions) are **not implemented in it** — they belong to their own services or aren't implemented at all. *(Update #1959: the feed domain was retired per ADR-0052 §5 — `IFeedService`, `FeedProtocol`, and the facade's feed forwarding no longer exist, so the facade now implements 3 domains: data, metadata, analytics.)* -- **Analytics uses a deliberate fallback + `replaceService` pattern (NOT a collision/bug — corrected in rev.9)**: `registerService` throws on duplicate (`core/kernel.ts`), so `ObjectQLPlugin` registers a lightweight ~66-line analytics **fallback** (so `/analytics` doesn't 404 in minimal deployments), and `AnalyticsServicePlugin`, when installed, calls **`ctx.replaceService('analytics', …)`** to swap in the full ~1.8k-LOC engine (three strategies incl. native-SQL). With service-analytics present the real engine serves (no shadowing); without it the fallback serves. The fallback duplicates *logic* only (a minor maintenance cost) and is intentional and harmless. +- **Analytics uses a deliberate fallback + `replaceService` pattern (NOT a collision/bug — corrected in rev.9)**: `registerService` throws on duplicate (`core/kernel.ts`), so `ObjectQLPlugin` registers a lightweight ~66-line analytics **fallback** (so `/analytics` doesn't 404 in minimal deployments), and `AnalyticsServicePlugin`, when installed, calls **`ctx.replaceService('analytics', …)`** to swap in the full ~1.8k-LOC engine (three strategies incl. native-SQL). With service-analytics present the real engine serves (no shadowing); without it the fallback serves. The fallback duplicates *logic* only (a minor maintenance cost) and is intentional and harmless. *(Update #3891: "duplicates logic only / harmless" was refuted by evidence — the same security gate had to be built twice (#3770 for the fallback, #3867/#3875 for the real engine), and the fallback dropped the caller's ExecutionContext (aggregates ran without RLS/tenant predicates) and ignored the contract `where` filter. The fallback is **retired**: without service-analytics the `analytics` slot stays empty, `/analytics` answers the dispatcher's existing 404, and discovery reports `unavailable` — the D12 machinery makes the empty slot honest, which removes the fallback's original reason to exist (the discovery hardcode).)* - ~~**Feed is already delegated** to `IFeedService`; the facade only forwards.~~ *(Retired per ADR-0052 §5 / #1959 — `IFeedService` and the facade's feed forwarding were removed.)* - The domain service packages already exist: `service-analytics`, `service-messaging`, `service-realtime`. @@ -112,7 +112,7 @@ Target end-state: - **"protocol" names ONLY the contract** — the segmented interfaces in `@objectstack/spec/api` (D9). There is **no `*-protocol` implementation package**. - **`DataProtocol`** impl → engine-adjacent / transport (thin wire-normalizers). - **`MetadataProtocol`** impl → stays in **`@objectstack/metadata-protocol`** (name **retained** — already published; renaming churns downstream for ~0 benefit). The package's *content* converges to the metadata-management impl (it owns `sys_metadata`). The `protocol` in the name is a deliberate, low-cost naming exception from being published — the real contract lives in `@objectstack/spec/api`, not here. (Open Question #7, resolved.) -- **Analytics / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. The engine keeps only the minimal fallback it deliberately provides. *(Feed was dropped entirely per ADR-0052 §5 / #1959 — no service package, no facade forwarding.)* +- **Analytics / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. The engine keeps only the minimal fallback it deliberately provides. *(Feed was dropped entirely per ADR-0052 §5 / #1959 — no service package, no facade forwarding.)* *(Update #3891: consciously dropped — see the fallback bullet above. The facade's `analyticsQuery`/`getAnalyticsMeta` went with it, so the facade now implements 2 domains (data, metadata) and analytics has exactly one implementation, in service-analytics — this bullet's end-state for the domain.)* - **The transport/dispatcher routes each contract-slice to the owning service** (it already resolves services by name) — no central facade class. This **refines D1** (the `metadata-protocol` package was an intermediate, not the end-state) and **completes D9**. Executed at the cross-repo window with D7 / Step 2. @@ -143,7 +143,7 @@ Decision: each capability plugin registers its routes as a **normalized handler* This fixes the **whole class at once — without deleting any fallback** (no `/analytics` 404 regression): the analytics fallback and the dev stubs simply stop *lying*; they keep serving but are honestly labelled. It is the runtime enforcement of the D9-refinement principle (capabilities = what is actually installed, computed at runtime). -**Supersedes the rev.9 analytics conclusion**: the fix for the analytics fallback is to **mark it honestly (this D12)**, not "preserve-or-delete". +**Supersedes the rev.9 analytics conclusion**: the fix for the analytics fallback is to **mark it honestly (this D12)**, not "preserve-or-delete". *(Update #3891: superseded in turn for the analytics fallback specifically — honest labelling was necessary but not sufficient. The fallback's `degraded` label was accurate about capability, yet nothing in it disclosed that aggregates ran WITHOUT the caller's RLS/tenant scoping and that the contract `where` filter was ignored; an authorized caller still got a 200 with wrong (over-broad) numbers. A fallback may degrade features, never security semantics — so it was retired, and the "no `/analytics` 404 regression" goal above is deliberately abandoned for this slot: the 404 IS the honest signal. D12's marker/discovery machinery stays, and is what makes the now-empty slot report `unavailable`.)* **Execution**: framework (marker convention + `svcAvailable` respects it + discovery schema `stub` status) and console (read the honest status) land **together at the cross-repo window** — the console reads `discovery.services`, so this is a cross-repo contract change. diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts index 1363a4b5bf..bb71da2432 100644 --- a/packages/metadata-protocol/src/plugin.ts +++ b/packages/metadata-protocol/src/plugin.ts @@ -5,8 +5,9 @@ * * Owns what `ObjectQLPlugin` historically assembled inline: the * `ObjectStackProtocolImplementation` construction + `protocol` service - * registration, the metadata-storage platform objects, and the lightweight - * `analytics` fallback. Registering it NEXT TO `ObjectQLPlugin` (with + * registration and the metadata-storage platform objects. (The lightweight + * `analytics` fallback that used to ride here was retired in #3891 — see + * {@link assembleMetadataProtocol}.) Registering it NEXT TO `ObjectQLPlugin` (with * `registerProtocol: false` on the engine plugin) makes `@objectstack/objectql` * effectively protocol-free at boot-assembly level — the engine plugin keeps * only protocol CONSUMERS (DB hydration + authored hook/action rebind, both of @@ -22,7 +23,6 @@ */ import type { Plugin, PluginContext } from '@objectstack/core'; -import { SERVICE_SELF_INFO_KEY, type ServiceSelfInfo } from '@objectstack/spec/api'; import { SysMetadataObject, SysMetadataHistoryObject, @@ -73,12 +73,19 @@ export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOpti /** * The ONE protocol assembly (ADR-0076 Step 2 PR-C): metadata-storage platform - * objects + `ObjectStackProtocolImplementation` as the `protocol` service + - * the D12 `degraded` analytics fallback. Called by - * {@link createMetadataProtocolPlugin} (delegated mode) AND by + * objects + `ObjectStackProtocolImplementation` as the `protocol` service. + * Called by {@link createMetadataProtocolPlugin} (delegated mode) AND by * `ObjectQLPlugin`'s built-in convenience mode (`registerProtocol !== false`) * — single source, two mounts, identical result. * + * The `analytics` slot is deliberately NOT filled here (#3891 / #3878, + * superseding the ADR-0076 D10/D12 "preserve the fallback" stance): the + * degraded shim dropped the caller's ExecutionContext (no RLS/tenant + * predicates) and ignored the contract `where` filter, returning full-table + * aggregates with a 200. An empty slot degrades honestly instead — the + * dispatcher's `/analytics` domain answers 404 and discovery reports + * `unavailable` until `AnalyticsServicePlugin` registers the real engine. + * * @returns the protocol shim, so the engine-side caller can arm its * mutation-rebind subscription synchronously. */ @@ -119,42 +126,13 @@ export function assembleMetadataProtocol( ctx.registerService('protocol', protocolShim); ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)'); - // Lightweight `analytics` fallback mapped onto the protocol shim's - // `analyticsQuery` — kept with the protocol assembly so `/analytics` - // keeps answering on installs without service-analytics. Honest - // capabilities (ADR-0076 D12): self-identifies as `degraded`; - // AnalyticsServicePlugin replaces it (ctx.replaceService) with the - // real engine. - ctx.registerService('analytics', { - [SERVICE_SELF_INFO_KEY]: { - status: 'degraded', - handlerReady: true, - message: 'Lightweight ObjectQL analytics fallback — install @objectstack/service-analytics for the full engine', - } satisfies ServiceSelfInfo, - // HttpDispatcher passes the raw POST body (AnalyticsQuery - // shape); the shim's `analyticsQuery` expects the wrapped - // `{ cube, query }` envelope and returns its own `{ success, - // data }` — reshape in, unwrap out (one level), exactly as the - // historical inline adapter did. - query: async (body: any) => { - const envelope = body && typeof body === 'object' && 'query' in body && 'cube' in body - ? body - : { cube: body?.cube, query: body }; - const result = await protocolShim.analyticsQuery(envelope); - if (result && typeof result === 'object' && 'success' in result && 'data' in result) { - return (result as any).data; - } - return result; - }, - getMeta: async () => ({ - cubes: [], - message: 'Analytics meta endpoint not implemented by ObjectQL adapter', - }), - generateSql: async (_body: any) => ({ - sql: null, - message: 'Analytics SQL generation not implemented by ObjectQL adapter', - }), - }); + // NO `analytics` fallback rides here anymore (#3891 / #3878). The + // degraded shim this assembly used to register dropped the request's + // ExecutionContext (aggregates ran without RLS/tenant predicates) + // and ignored the contract filter field `where` — a 200 with wrong + // numbers. The slot now stays empty: `/analytics/*` answers 404 + // (ROUTE_NOT_FOUND) and discovery reports the service unavailable + // until @objectstack/service-analytics registers the real engine. return protocolShim; } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8f06558247..7f23e66831 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { - DataProtocol, MetadataProtocol, AnalyticsProtocol, PackageProtocol, + DataProtocol, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; import { IDataEngine } from '@objectstack/core'; import { readEnvWithDeprecation } from '@objectstack/types'; @@ -660,6 +660,11 @@ function mergeDroppedFieldEvents(events: DroppedFieldsEvent[]): DroppedFieldsEve * with no mounted handler 404s and misleads consumers). */ const SERVICE_CONFIG: Record = { + // Plugin-provided like every other optional service since the degraded + // ObjectQL fallback was retired (#3891): advertised iff the real engine + // is registered — never hardcoded 'available' (the pre-#2462 lie the + // fallback existed to paper over). + analytics: { route: '/api/v1/analytics', plugin: 'service-analytics' }, auth: { route: '/api/v1/auth', plugin: 'plugin-auth' }, automation: { route: '/api/v1/automation', plugin: 'plugin-automation' }, cache: { route: '/api/v1/cache', plugin: 'plugin-redis' }, @@ -1042,10 +1047,12 @@ export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void * Implements the per-domain contracts this class ACTUALLY provides (ADR-0076 * D10 — the facade never implemented the other domains; those live in their * owning services and are reached through the discovery `services` registry, - * never through this class). + * never through this class). Analytics left this list with the degraded shim + * retirement (#3891) — the domain's one implementation is + * `@objectstack/service-analytics`. */ export class ObjectStackProtocolImplementation implements - DataProtocol, MetadataProtocol, AnalyticsProtocol, PackageProtocol { + DataProtocol, MetadataProtocol, PackageProtocol { private engine: MetadataHostEngine; private getServicesRegistry?: () => Map; /** @@ -1388,26 +1395,17 @@ export class ObjectStackProtocolImplementation implements async getDiscovery() { // Get registered services from kernel if available const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); - - // Honest capabilities (ADR-0076 D12, #2462): the registered analytics - // service may be the lightweight ObjectQL fallback rather than the - // full service-analytics engine — report whatever it declares about - // itself instead of hardcoding 'available'. - const analyticsSelf = readServiceSelfInfo(registeredServices.get('analytics')); - // Build dynamic service info with proper typing + // Build dynamic service info with proper typing. Analytics is NOT in + // this kernel-provided block: since the degraded ObjectQL fallback was + // retired (#3891) it is an ordinary optional service, computed from the + // registry via SERVICE_CONFIG below — absent means `unavailable`, and + // no route is advertised (the pre-#2462 hardcode here is exactly what + // the fallback was invented to make true). const services: Record = { // --- Kernel-provided (objectql is an example kernel implementation) --- metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' }, data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' }, - analytics: { - enabled: true, - status: analyticsSelf?.status ?? ('available' as const), - route: '/api/v1/analytics', - provider: 'objectql', - ...(analyticsSelf?.handlerReady !== undefined ? { handlerReady: analyticsSelf.handlerReady } : {}), - ...(analyticsSelf?.message ? { message: analyticsSelf.message } : {}), - }, }; // Check which services are actually registered @@ -1445,6 +1443,7 @@ export class ObjectStackProtocolImplementation implements // Build routes from services — a flat convenience map for client routing const serviceToRouteKey: Record = { + analytics: 'analytics', auth: 'auth', automation: 'automation', ui: 'ui', @@ -1456,9 +1455,7 @@ export class ObjectStackProtocolImplementation implements 'file-storage': 'storage', }; - const optionalRoutes: Partial = { - analytics: '/api/v1/analytics', - }; + const optionalRoutes: Partial = {}; // Add routes for available plugin services. Services without an HTTP // surface (config.route undefined) advertise no route (D12, #2462). @@ -3646,183 +3643,16 @@ export class ObjectStackProtocolImplementation implements } as BatchUpdateResponse; } - async analyticsQuery(request: any): Promise { - // Map AnalyticsQuery (cube-style) to engine aggregation. - // cube name maps to object name; measures → aggregations; dimensions → groupBy. - const { query, cube } = request; - const object = cube; - // [#3770] A cube name IS an object name here (`getAnalyticsMeta` derives - // every cube from `registry.listItems('object')`), so this read surface - // needs the same existence gate as the CRUD ones — otherwise it stays a - // way to aggregate over an arbitrary physical table. - this.assertObjectRegistered(object); - - // Build groupBy from dimensions - const groupBy = query.dimensions || []; - - // Build aggregations from measures - // Measures can be simple field names like "count" or "field_name.sum" - // Or cube-defined measure names. We support: field.function or just function(field). - const aggregations: Array<{ field: string; method: string; alias: string }> = []; - if (query.measures) { - for (const measure of query.measures) { - // Support formats: "count", "amount.sum", "revenue.avg" - if (measure === 'count' || measure === 'count_all') { - aggregations.push({ field: '*', method: 'count', alias: 'count' }); - } else if (measure.includes('.')) { - const [field, method] = measure.split('.'); - aggregations.push({ field, method, alias: `${field}_${method}` }); - } else { - // Treat as count of the field - aggregations.push({ field: measure, method: 'sum', alias: measure }); - } - } - } - - // Build filter from analytics filters - let filter: any = undefined; - if (query.filters && query.filters.length > 0) { - const conditions: any[] = query.filters.map((f: any) => { - const op = this.mapAnalyticsOperator(f.operator); - if (f.values && f.values.length === 1) { - return { [f.member]: { [op]: f.values[0] } }; - } else if (f.values && f.values.length > 1) { - return { [f.member]: { $in: f.values } }; - } - return { [f.member]: { [op]: true } }; - }); - filter = conditions.length === 1 ? conditions[0] : { $and: conditions }; - } - - // Execute via engine.aggregate (which delegates to driver.find with groupBy/aggregations) - const rows = await this.engine.aggregate(object, { - where: filter, - groupBy: groupBy.length > 0 ? groupBy : undefined, - aggregations: aggregations.length > 0 - ? aggregations.map(a => ({ function: a.method as any, field: a.field, alias: a.alias })) - : [{ function: 'count' as any, alias: 'count' }], - }); - - // Build field metadata - const fields = [ - ...groupBy.map((d: string) => ({ name: d, type: 'string' })), - ...aggregations.map(a => ({ name: a.alias, type: 'number' })), - ]; - - return { - success: true, - data: { - rows, - fields, - }, - }; - } - - async getAnalyticsMeta(request: any): Promise { - // Auto-generate cube metadata from registered objects in SchemaRegistry. - // Each object becomes a cube; number fields → measures; other fields → dimensions. - const objects = this.engine.registry.listItems('object'); - const cubeFilter = request?.cube; - - const cubes: any[] = []; - for (const obj of objects) { - const schema = obj as any; - if (cubeFilter && schema.name !== cubeFilter) continue; - - const measures: Record = {}; - const dimensions: Record = {}; - const fields = schema.fields || {}; - - // Always add a count measure - measures['count'] = { - name: 'count', - label: 'Count', - type: 'count', - sql: '*', - }; - - for (const [fieldName, fieldDef] of Object.entries(fields)) { - const fd = fieldDef as any; - const fieldType = fd.type || 'text'; - - if (['number', 'currency', 'percent'].includes(fieldType)) { - // Numeric fields become both measures and dimensions - measures[`${fieldName}_sum`] = { - name: `${fieldName}_sum`, - label: `${fd.label || fieldName} (Sum)`, - type: 'sum', - sql: fieldName, - }; - measures[`${fieldName}_avg`] = { - name: `${fieldName}_avg`, - label: `${fd.label || fieldName} (Avg)`, - type: 'avg', - sql: fieldName, - }; - dimensions[fieldName] = { - name: fieldName, - label: fd.label || fieldName, - type: 'number', - sql: fieldName, - }; - } else if (['date', 'datetime'].includes(fieldType)) { - dimensions[fieldName] = { - name: fieldName, - label: fd.label || fieldName, - type: 'time', - sql: fieldName, - granularities: ['day', 'week', 'month', 'quarter', 'year'], - }; - } else if (['boolean'].includes(fieldType)) { - dimensions[fieldName] = { - name: fieldName, - label: fd.label || fieldName, - type: 'boolean', - sql: fieldName, - }; - } else { - // text, select, lookup, etc. → dimension - dimensions[fieldName] = { - name: fieldName, - label: fd.label || fieldName, - type: 'string', - sql: fieldName, - }; - } - } - - cubes.push({ - name: schema.name, - title: schema.label || schema.name, - description: schema.description, - sql: schema.name, - measures, - dimensions, - public: true, - }); - } - - return { - success: true, - data: { cubes }, - }; - } - - private mapAnalyticsOperator(op: string): string { - const map: Record = { - equals: '$eq', - notEquals: '$ne', - contains: '$contains', - notContains: '$notContains', - gt: '$gt', - gte: '$gte', - lt: '$lt', - lte: '$lte', - set: '$ne', - notSet: '$eq', - }; - return map[op] || '$eq'; - } + // `analyticsQuery` / `getAnalyticsMeta` were retired with the degraded + // `analytics` service shim (#3891 / #3878). They aggregated through + // `engine.aggregate` WITHOUT the caller's ExecutionContext — no RLS or + // tenant predicate was ever injected — and read a non-contract `filters` + // field while silently ignoring the canonical `AnalyticsQuery.where`, so a + // spec-conformant filtered request returned an unscoped full-table + // aggregate. The analytics domain has exactly one implementation now: + // `@objectstack/service-analytics` (context-aware, fail-closed + // `getReadScope`). Deployments without it get an honest 404 from the + // dispatcher's `/analytics` domain instead of wrong numbers. async triggerAutomation(_request: any): Promise { throw new Error('triggerAutomation requires plugin-automation service. Install and register a plugin that provides the "automation" service.'); diff --git a/packages/objectql/src/plugin.step2.test.ts b/packages/objectql/src/plugin.step2.test.ts index aebca61466..838631f953 100644 --- a/packages/objectql/src/plugin.step2.test.ts +++ b/packages/objectql/src/plugin.step2.test.ts @@ -12,7 +12,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; -import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; import { createMetadataProtocolPlugin, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { ObjectQLPlugin } from './plugin'; @@ -34,12 +33,11 @@ describe('ADR-0076 Step 2 — delegated protocol assembly', () => { // getService('protocol') + the loadMetaFromDb type guard. expect(typeof (protocol as any).loadMetaFromDb).toBe('function'); - // The lightweight analytics fallback rides with the protocol assembly and - // keeps its D12 honest-capabilities self-descriptor. - const analytics: any = kernel.getService('analytics'); - expect(analytics).toBeDefined(); - expect(analytics[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded'); - expect(typeof analytics.query).toBe('function'); + // The degraded analytics fallback was retired (#3891): it dropped the + // caller's ExecutionContext (unscoped aggregates) and ignored the contract + // `where` filter. The slot stays EMPTY until service-analytics fills it — + // /analytics 404s honestly instead of answering with wrong numbers. + expect(() => kernel.getService('analytics')).toThrow(); }); it('registerProtocol:false WITHOUT the plugin boots protocol-free (consumers degrade)', async () => { @@ -58,10 +56,10 @@ describe('ADR-0076 Step 2 — delegated protocol assembly', () => { await expect(kernel.bootstrap()).rejects.toThrow(/registerProtocol: false/); }); - it('default assembly is unchanged (backward compatibility)', async () => { + it('default assembly registers protocol but leaves the analytics slot empty (#3891)', async () => { await kernel.use(new ObjectQLPlugin()); await kernel.bootstrap(); expect(kernel.getService('protocol')).toBeInstanceOf(ObjectStackProtocolImplementation); - expect((kernel.getService('analytics') as any)[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded'); + expect(() => kernel.getService('analytics')).toThrow(); }); }); diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 51791bbf50..da139598e0 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -120,10 +120,10 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.workflow.message).toBe('partial impl'); }); - it('should report the analytics fallback honestly when it self-identifies', async () => { + it('should report a registered analytics service with its self-declared status', async () => { const mockServices = new Map(); mockServices.set('analytics', { - __serviceInfo: { status: 'degraded', handlerReady: true, message: 'fallback' }, + __serviceInfo: { status: 'degraded', handlerReady: true, message: 'partial engine' }, }); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); @@ -131,21 +131,48 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.analytics.enabled).toBe(true); expect(discovery.services.analytics.status).toBe('degraded'); - expect(discovery.services.analytics.message).toBe('fallback'); + expect(discovery.services.analytics.message).toBe('partial engine'); }); it('should always show core services as available', async () => { protocol = new ObjectStackProtocolImplementation(engine); - + const discovery = await protocol.getDiscovery(); - + // Core services should always be available expect(discovery.services.metadata.enabled).toBe(true); expect(discovery.services.metadata.status).toBe('available'); expect(discovery.services.data.enabled).toBe(true); expect(discovery.services.data.status).toBe('available'); + }); + + // #3891 — the degraded ObjectQL fallback is retired. Analytics is an + // ordinary optional service now: absent ⇒ unavailable, and the route must + // NOT be advertised (an advertised route with no handler 404s — the exact + // discovery lie the fallback was invented to make true). + it('should report analytics unavailable with no advertised route when not registered', async () => { + protocol = new ObjectStackProtocolImplementation(engine); + + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.analytics.enabled).toBe(false); + expect(discovery.services.analytics.status).toBe('unavailable'); + expect(discovery.services.analytics.message).toContain('service-analytics'); + expect(discovery.services.analytics.route).toBeUndefined(); + expect(discovery.routes.analytics).toBeUndefined(); + }); + + it('should advertise analytics route and availability when the real engine registers', async () => { + const mockServices = new Map(); + mockServices.set('analytics', { /* real engine — no self-info means available */ }); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + expect(discovery.services.analytics.enabled).toBe(true); expect(discovery.services.analytics.status).toBe('available'); + expect(discovery.services.analytics.route).toBe('/api/v1/analytics'); + expect(discovery.routes.analytics).toBe('/api/v1/analytics'); }); it('should map file-storage service to storage route', async () => { @@ -166,7 +193,8 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => mockServices.set('auth', {}); mockServices.set('automation', {}); mockServices.set('ai', {}); - + mockServices.set('analytics', {}); + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); diff --git a/packages/objectql/src/protocol-unregistered-object.test.ts b/packages/objectql/src/protocol-unregistered-object.test.ts index fb8f980074..d45277afa9 100644 --- a/packages/objectql/src/protocol-unregistered-object.test.ts +++ b/packages/objectql/src/protocol-unregistered-object.test.ts @@ -187,7 +187,10 @@ describe('#3770 — data-plane object-existence gate (real ObjectQL engine)', () ['updateManyData', () => protocol.updateManyData({ object: UNREGISTERED, records: [{ id: 'r1', data: {} }] } as any)], ['deleteManyData', () => protocol.deleteManyData({ object: UNREGISTERED, ids: ['r1'] } as any)], ['batchData', () => protocol.batchData({ object: UNREGISTERED, request: { operation: 'create', records: [{ data: {} }] } as any })], - ['analyticsQuery', () => protocol.analyticsQuery({ cube: UNREGISTERED, query: { measures: ['count'] } })], + // `analyticsQuery` is gone from this list because the method itself + // was retired with the degraded analytics shim (#3891) — the + // analytics-side twin of this gate lives in service-analytics' + // `ensureCube` (#3867/#3875). ]; for (const [name, call] of calls) { await expect(call(), `${name} must reject`).rejects.toMatchObject(OBJECT_NOT_FOUND); diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index a1a1b3f37d..31e5a81fb8 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -2,10 +2,12 @@ /** * `/analytics` domain — extracted dispatcher body (ADR-0076 D11 step ③, - * PR-2). Bridges to whatever provides the `analytics` service slot: the - * service-analytics engine when installed, or the ObjectQLPlugin degraded - * fallback otherwise (deliberate fallback + `replaceService`, see ADR-0076 - * D10/D12) — which is exactly why route registration stays dispatcher-owned. + * PR-2). Bridges to whatever provides the `analytics` service slot — in + * practice the service-analytics engine, the slot's ONE implementation since + * the degraded ObjectQL fallback was retired (#3891: it dropped the caller's + * ExecutionContext and the contract `where` filter). Route registration stays + * dispatcher-owned so the URL contract is stable regardless of what occupies + * the slot; an empty slot answers the `handled: false` 404 below. */ import { CoreServiceName } from '@objectstack/spec/system'; @@ -49,8 +51,8 @@ export async function handleAnalyticsRequest( // GET /analytics/meta[?cube=] if (subPath === 'meta' && m === 'GET') { // [#3584] Optional single-cube filter. `AnalyticsService.getMeta` - // already accepts `cubeName?`; degraded fallbacks that ignore the - // argument keep returning the full listing, which is still correct. + // already accepts `cubeName?`; an implementation that ignores the + // argument keeps returning the full listing, which is still correct. const cube = typeof query?.cube === 'string' && query.cube !== '' ? query.cube : undefined; const result = await analyticsService.getMeta(cube); return { handled: true, response: deps.success(result) };