diff --git a/ROADMAP.md b/ROADMAP.md index 780e37c659..32571177f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -223,6 +223,9 @@ Strengthen discovery capabilities for frontend intelligent adaptation. | Localization info (locale, timezone) | ✅ | `api/discovery.zod.ts` | | Custom metadata extensions | ✅ | `api/discovery.zod.ts` | | Capabilities declaration (comments, automation, search, cron, files, analytics) | ✅ | `api/discovery.zod.ts` → `capabilities` with hierarchical descriptors | +| Well-known capabilities (feed, comments, automation, cron, search, export, chunkedUpload) | ✅ | `api/discovery.zod.ts` → `WellKnownCapabilitiesSchema`, `protocol.zod.ts` → `GetDiscoveryResponseSchema.capabilities` | +| Dynamic capabilities population from registered services | ✅ | `objectql/protocol.ts` → `getDiscovery()` builds capabilities from service registry | +| Client SDK `capabilities` getter | ✅ | `client/index.ts` → `ObjectStackClient.capabilities` | | Per-service version info | ✅ | `api/discovery.zod.ts` → `ServiceInfoSchema.version` | | Rate limit & quota disclosure | ✅ | `api/discovery.zod.ts` → `ServiceInfoSchema.rateLimit` | | OpenAPI/GraphQL schema discovery endpoint | ✅ | `api/discovery.zod.ts` → `DiscoverySchema.schemaDiscovery` | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 6013780815..d895cfcd73 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -786,4 +786,44 @@ describe('ObjectStackClient.automation', () => { expect.objectContaining({ method: 'POST' }), ); }); + + // ========================================== + // capabilities getter + // ========================================== + + it('should return undefined capabilities before connect', () => { + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); + expect(client.capabilities).toBeUndefined(); + }); + + it('should expose capabilities after connect', async () => { + const caps = { + feed: true, + comments: true, + automation: false, + cron: false, + search: true, + export: false, + chunkedUpload: false, + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + version: 'v1', + apiName: 'ObjectStack API', + capabilities: caps, + }), + }); + + const client = new ObjectStackClient({ + baseUrl: 'http://localhost:3000', + fetch: fetchMock, + }); + + await client.connect(); + expect(client.capabilities).toBeDefined(); + expect(client.capabilities!.feed).toBe(true); + expect(client.capabilities!.automation).toBe(false); + expect(client.capabilities!.search).toBe(true); + }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 92a05486d8..7369b0583f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -86,6 +86,7 @@ import { GetChangelogResponse, SubscribeResponse, UnsubscribeResponse, + WellKnownCapabilities, } from '@objectstack/spec/api'; import { Logger, createLogger } from '@objectstack/core'; @@ -255,6 +256,15 @@ export class ObjectStackClient { } } + /** + * Well-known capability flags discovered from the server. + * Returns undefined if the client has not yet connected or the server + * did not include capabilities in its discovery response. + */ + get capabilities(): WellKnownCapabilities | undefined { + return this.discoveryInfo?.capabilities; + } + /** * Metadata Operations */ @@ -1747,4 +1757,5 @@ export type { GetChangelogResponse, SubscribeResponse, UnsubscribeResponse, + WellKnownCapabilities, } from '@objectstack/spec/api'; diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index d8dcdbffcc..a2970608fa 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -137,17 +137,77 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.analytics.route).toBe('/api/v1/analytics'); }); - it('should not return capabilities field (removed — use services instead)', async () => { + it('should return capabilities field populated from registered services', async () => { const mockServices = new Map(); mockServices.set('workflow', {}); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - // capabilities field should no longer exist in the response - const keys = Object.keys(discovery); - expect(keys).not.toContain('capabilities'); - // Use services to check availability instead + // capabilities field should now exist in the response + expect(discovery.capabilities).toBeDefined(); + // workflow is registered but doesn't map to a well-known capability directly expect(discovery.services.workflow.enabled).toBe(true); + // All well-known capabilities should be false since workflow doesn't map to any + expect(discovery.capabilities!.feed).toBe(false); + expect(discovery.capabilities!.comments).toBe(false); + expect(discovery.capabilities!.automation).toBe(false); + expect(discovery.capabilities!.cron).toBe(false); + expect(discovery.capabilities!.search).toBe(false); + expect(discovery.capabilities!.export).toBe(false); + expect(discovery.capabilities!.chunkedUpload).toBe(false); + }); + + it('should set all capabilities to false when no services are registered', async () => { + protocol = new ObjectStackProtocolImplementation(engine); + const discovery = await protocol.getDiscovery(); + + expect(discovery.capabilities).toBeDefined(); + expect(discovery.capabilities!.feed).toBe(false); + expect(discovery.capabilities!.comments).toBe(false); + expect(discovery.capabilities!.automation).toBe(false); + expect(discovery.capabilities!.cron).toBe(false); + expect(discovery.capabilities!.search).toBe(false); + expect(discovery.capabilities!.export).toBe(false); + expect(discovery.capabilities!.chunkedUpload).toBe(false); + }); + + it('should dynamically set capabilities based on registered services', async () => { + const mockServices = new Map(); + mockServices.set('feed', {}); + mockServices.set('automation', {}); + mockServices.set('search', {}); + mockServices.set('file-storage', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + expect(discovery.capabilities!.feed).toBe(true); + expect(discovery.capabilities!.comments).toBe(true); + expect(discovery.capabilities!.automation).toBe(true); + expect(discovery.capabilities!.cron).toBe(false); + expect(discovery.capabilities!.search).toBe(true); + expect(discovery.capabilities!.export).toBe(true); + expect(discovery.capabilities!.chunkedUpload).toBe(true); + }); + + it('should enable cron capability when job service is registered', async () => { + const mockServices = new Map(); + mockServices.set('job', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + expect(discovery.capabilities!.cron).toBe(true); + }); + + it('should enable export capability when queue service is registered', async () => { + const mockServices = new Map(); + mockServices.set('queue', {}); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + expect(discovery.capabilities!.export).toBe(true); }); }); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 93695f2d35..889c16adfe 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -8,7 +8,7 @@ import type { UpdateManyDataRequest, DeleteManyDataRequest } from '@objectstack/spec/api'; -import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes } from '@objectstack/spec/api'; +import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import type { IFeedService } from '@objectstack/spec/contracts'; // We import SchemaRegistry directly since this class lives in the same package @@ -150,11 +150,23 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { ...optionalRoutes, }; + // Build well-known capabilities from registered services + const capabilities: WellKnownCapabilities = { + feed: registeredServices.has('feed'), + comments: registeredServices.has('feed'), + automation: registeredServices.has('automation'), + cron: registeredServices.has('job'), + search: registeredServices.has('search'), + export: registeredServices.has('automation') || registeredServices.has('queue'), + chunkedUpload: registeredServices.has('file-storage'), + }; + return { version: '1.0', apiName: 'ObjectStack API', routes, services, + capabilities, }; } diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 912c668ee1..ab8f9410a5 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -3,9 +3,11 @@ import { DiscoverySchema, ApiRoutesSchema, ServiceInfoSchema, + WellKnownCapabilitiesSchema, type DiscoveryResponse, type ApiRoutes, type ServiceInfo, + type WellKnownCapabilities, } from './discovery.zod'; describe('ApiRoutesSchema', () => { @@ -683,3 +685,64 @@ describe('DiscoverySchema (schemaDiscovery field)', () => { expect(discovery.schemaDiscovery?.graphql).toBeUndefined(); }); }); + +// ========================================== +// WellKnownCapabilitiesSchema +// ========================================== + +describe('WellKnownCapabilitiesSchema', () => { + it('should accept all capabilities enabled', () => { + const caps: WellKnownCapabilities = { + feed: true, + comments: true, + automation: true, + cron: true, + search: true, + export: true, + chunkedUpload: true, + }; + expect(() => WellKnownCapabilitiesSchema.parse(caps)).not.toThrow(); + }); + + it('should accept all capabilities disabled', () => { + const caps = WellKnownCapabilitiesSchema.parse({ + feed: false, + comments: false, + automation: false, + cron: false, + search: false, + export: false, + chunkedUpload: false, + }); + expect(caps.feed).toBe(false); + expect(caps.chunkedUpload).toBe(false); + }); + + it('should reject missing required fields', () => { + expect(() => WellKnownCapabilitiesSchema.parse({ feed: true })).toThrow(); + expect(() => WellKnownCapabilitiesSchema.parse({})).toThrow(); + }); + + it('should reject non-boolean values', () => { + expect(() => WellKnownCapabilitiesSchema.parse({ + feed: 'yes', + comments: true, + automation: true, + cron: true, + search: true, + export: true, + chunkedUpload: true, + })).toThrow(); + }); + + it('should have .describe() annotations on all fields', () => { + const shape = WellKnownCapabilitiesSchema.shape; + expect(shape.feed.description).toBeDefined(); + expect(shape.comments.description).toBeDefined(); + expect(shape.automation.description).toBeDefined(); + expect(shape.cron.description).toBeDefined(); + expect(shape.search.description).toBeDefined(); + expect(shape.export.description).toBeDefined(); + expect(shape.chunkedUpload.description).toBeDefined(); + }); +}); diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 6d0ebb9b72..a233341e5c 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -146,6 +146,30 @@ export const DiscoverySchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata key-value pairs for extensibility'), }); +/** + * Well-Known Capabilities Schema + * Flat boolean flags for quick feature detection by clients (ObjectUI). + * Each flag indicates whether the backend supports a specific capability. + * Clients can use these to show/hide UI elements without probing individual endpoints. + */ +export const WellKnownCapabilitiesSchema = z.object({ + /** Whether the backend supports Feed / Chatter API */ + feed: z.boolean().describe('Whether the backend supports Feed / Chatter API'), + /** Whether the backend supports comments (a subset of Feed) */ + comments: z.boolean().describe('Whether the backend supports comments (a subset of Feed)'), + /** Whether the backend supports Automation CRUD (flows, triggers) */ + automation: z.boolean().describe('Whether the backend supports Automation CRUD (flows, triggers)'), + /** Whether the backend supports cron scheduling */ + cron: z.boolean().describe('Whether the backend supports cron scheduling'), + /** Whether the backend supports full-text search */ + search: z.boolean().describe('Whether the backend supports full-text search'), + /** Whether the backend supports async export */ + export: z.boolean().describe('Whether the backend supports async export'), + /** Whether the backend supports chunked (multipart) uploads */ + chunkedUpload: z.boolean().describe('Whether the backend supports chunked (multipart) uploads'), +}).describe('Well-known capability flags for frontend intelligent adaptation'); + +export type WellKnownCapabilities = z.infer; export type DiscoveryResponse = z.infer; export type ApiRoutes = z.infer; export type ServiceInfo = z.infer; diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index f1130976f2..86a2a8246f 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -319,3 +319,51 @@ describe('ObjectStack Protocol', () => { }); }); + +// ========================================== +// GetDiscoveryResponseSchema — capabilities +// ========================================== +import { GetDiscoveryResponseSchema } from './protocol.zod'; + +describe('GetDiscoveryResponseSchema (capabilities)', () => { + it('should accept response with well-known capabilities', () => { + const result = GetDiscoveryResponseSchema.safeParse({ + version: 'v1', + apiName: 'ObjectStack API', + capabilities: { + feed: true, + comments: true, + automation: false, + cron: false, + search: true, + export: false, + chunkedUpload: true, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.capabilities?.feed).toBe(true); + expect(result.data.capabilities?.automation).toBe(false); + } + }); + + it('should accept response without capabilities (optional)', () => { + const result = GetDiscoveryResponseSchema.safeParse({ + version: 'v1', + apiName: 'ObjectStack API', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.capabilities).toBeUndefined(); + } + }); + + it('should reject capabilities with missing fields', () => { + const result = GetDiscoveryResponseSchema.safeParse({ + version: 'v1', + apiName: 'ObjectStack API', + capabilities: { feed: true }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 7d0fbfdf69..9463a91580 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { ViewSchema } from '../ui/view.zod'; -import { ApiRoutesSchema, ServiceInfoSchema } from './discovery.zod'; +import { ApiRoutesSchema, ServiceInfoSchema, WellKnownCapabilitiesSchema } from './discovery.zod'; import { BatchUpdateRequestSchema, BatchUpdateResponseSchema, BatchOptionsSchema } from './batch.zod'; import { MetadataCacheRequestSchema, MetadataCacheResponseSchema } from './http-cache.zod'; import { QuerySchema } from '../data/query.zod'; @@ -129,6 +129,7 @@ export const GetDiscoveryResponseSchema = z.object({ apiName: z.string().describe('API name'), routes: ApiRoutesSchema.optional().describe('Available endpoint paths'), services: z.record(z.string(), ServiceInfoSchema).optional().describe('Per-service availability map'), + capabilities: WellKnownCapabilitiesSchema.optional().describe('Well-known capability flags for frontend adaptation'), }); /**