Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |
Expand Down
40 changes: 40 additions & 0 deletions packages/client/src/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -786,4 +786,44 @@ describe('ObjectStackClient.automation', () => {
expect.objectContaining({ method: 'POST' }),
);
});

// ==========================================
// capabilities getter
// ==========================================

Comment on lines +792 to +793

CopilotAIFeb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new tests validate the top-level ObjectStackClient.capabilities getter, but they’re nested under describe('ObjectStackClient.automation', ...), which makes the suite misleading and harder to find. Consider moving them into a dedicated describe('ObjectStackClient.capabilities', ...) block.

Suggested change
// ==========================================
// ==========================================
});
describe('ObjectStackClient.capabilities',()=>{

Copilot uses AI. Check for mistakes.
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);
});
});
11 changes: 11 additions & 0 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,7 @@ import {
GetChangelogResponse,
SubscribeResponse,
UnsubscribeResponse,
WellKnownCapabilities,
} from '@objectstack/spec/api';
import { Logger, createLogger } from '@objectstack/core';

Expand DownExpand Up@@ -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
*/
Expand DownExpand Up@@ -1747,4 +1757,5 @@ export type {
GetChangelogResponse,
SubscribeResponse,
UnsubscribeResponse,
WellKnownCapabilities,
} from '@objectstack/spec/api';
70 changes: 65 additions & 5 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any>();
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<string, any>();
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<string, any>();
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<string, any>();
mockServices.set('queue', {});

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();

expect(discovery.capabilities!.export).toBe(true);
});
});
14 changes: 13 additions & 1 deletion packages/objectql/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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,
};
}

Expand Down
63 changes: 63 additions & 0 deletions packages/spec/src/api/discovery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,11 @@ import {
DiscoverySchema,
ApiRoutesSchema,
ServiceInfoSchema,
WellKnownCapabilitiesSchema,
type DiscoveryResponse,
type ApiRoutes,
type ServiceInfo,
type WellKnownCapabilities,
} from './discovery.zod';

describe('ApiRoutesSchema', () => {
Expand DownExpand Up@@ -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();
});
});
24 changes: 24 additions & 0 deletions packages/spec/src/api/discovery.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof WellKnownCapabilitiesSchema>;
export type DiscoveryResponse = z.infer<typeof DiscoverySchema>;
export type ApiRoutes = z.infer<typeof ApiRoutesSchema>;
export type ServiceInfo = z.infer<typeof ServiceInfoSchema>;
48 changes: 48 additions & 0 deletions packages/spec/src/api/protocol.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -319,3 +319,51 @@ describe('ObjectStack Protocol', () => {
});

});

// ==========================================
// GetDiscoveryResponseSchema — capabilities
// ==========================================
import { GetDiscoveryResponseSchema } from './protocol.zod';

Comment on lines +326 to +327

CopilotAIFeb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file already imports many schemas from ./protocol.zod at the top; adding a second import later in the file makes the import section harder to maintain and can trip import-order linting. Consider moving GetDiscoveryResponseSchema into the existing top import list instead of importing it at the bottom.

Copilot uses AI. Check for mistakes.
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);
});
});
3 changes: 2 additions & 1 deletion packages/spec/src/api/protocol.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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'),

CopilotAIFeb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for GetDiscoveryResponseSchema still says “capabilities was removed — derive from services[x].enabled”, but this schema now includes a capabilities field again. Please update/remove that note so the documentation matches the current contract (and clarifies how these well-known flags relate to services).

Copilot uses AI. Check for mistakes.
});

/**
Expand Down