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
1 change: 1 addition & 0 deletions apps/studio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Patch Changes

- Fix simulateBrowser mock handlers to properly support query parameters (top, skip, sort, select, filter) in data endpoints, use protocol service for metadata endpoints (types, items), and return correct response formats matching the ObjectStack protocol spec
- Updated dependencies [ee39bff]
- @objectstack/service-ai@4.0.3
- @objectstack/plugin-auth@4.0.3
Expand Down
88 changes: 66 additions & 22 deletions apps/studio/src/mocks/simulateBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,51 @@ import { http, HttpResponse } from 'msw';
import { ObjectStackClient } from '@objectstack/client';
import studioConfig from '../../objectstack.config';

/**
* Parse query parameters from a request URL into an ObjectQL query options object.
* Supports: top, skip, sort, select, filter (JSON), and individual key-value filters.
*/
function parseQueryOptions(url: URL): Record<string, any> {
const query: Record<string, any> = {};
const where: Record<string, any> = {};

url.searchParams.forEach((val, key) => {
switch (key) {
case 'top':
case '$top':
query.limit = parseInt(val, 10);
break;
case 'skip':
case '$skip':
query.offset = parseInt(val, 10);
break;
case 'sort':
case '$sort': {
// JSON array or comma-separated string
try { query.orderBy = JSON.parse(val); } catch {
query.orderBy = val.split(',').map((s: string) => s.trim());
Comment on lines +30 to +31
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

parseQueryOptions() maps the sort query param to query.orderBy as a string array when the value is comma-separated (e.g. "-created_at"). EngineQueryOptions.orderBy is an array of SortNode objects ({ field, order }), and the in-memory driver’s applySort() ignores string items, so sorting won’t be applied for the common sort=-field form sent by ObjectStackClient.data.find(). Convert string tokens into SortNode objects (handle leading '-' for desc) instead of returning raw strings.

Suggested change
try { query.orderBy = JSON.parse(val); } catch {
query.orderBy = val.split(',').map((s: string) => s.trim());
try {
query.orderBy = JSON.parse(val);
} catch {
query.orderBy = val
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
.map((token: string) => ({
field: token.startsWith('-') ? token.slice(1) : token,
order: token.startsWith('-') ? 'desc' : 'asc',
}));

Copilot uses AI. Check for mistakes.
}
break;
}
case 'select':
case '$select':
query.fields = val.split(',').map((s: string) => s.trim());
break;
case 'filter':
case '$filter':
try { Object.assign(where, JSON.parse(val)); } catch { /* ignore malformed */ }
break;
Comment on lines +39 to +42
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

filter is JSON-parsed and merged into where via Object.assign(where, JSON.parse(val)). When the client sends an AST/array filter (it serializes arrays into the filter param), this produces a { "0": ..., "1": ... } object rather than a valid where condition, so filtering will silently fail. If the parsed value is an array (or already a FilterCondition shape), assign it to query.where directly rather than merging into an object.

Copilot uses AI. Check for mistakes.
default:
// Individual key-value filter params (e.g. id=abc)
where[key] = val;
break;
Comment on lines +43 to +46
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

ObjectStackClient.data.find() can send aggregations (JSON) and groupBy (comma-separated) query params, but parseQueryOptions() currently treats unknown params as field filters and puts them into where. This breaks aggregation queries by turning control params into WHERE conditions. Add explicit parsing/forwarding for these keys (or explicitly ignore them) so they don’t end up in where.

Copilot uses AI. Check for mistakes.
}
});

if (Object.keys(where).length > 0) query.where = where;
return query;
}

/**
* Creates a Realistic Browser Simulation
*
Expand All @@ -28,6 +73,7 @@ export async function simulateBrowser() {
// 2. Define Network Handlers (The "Virtual Router")
// These map HTTP requests -> Kernel ObjectQL service actions
const ql = (kernel as any).context?.getService('objectql');
const protocol = (kernel as any).context?.getService('protocol');
const handlers = [
// Discovery
http.get('http://localhost:3000/.well-known/objectstack', () => {
Expand All @@ -47,17 +93,14 @@ export async function simulateBrowser() {
});
}),

// Query / Find
// Query / Find — parse query params for filtering, pagination, sorting, field selection
http.get('http://localhost:3000/api/v1/data/:object', async ({ params, request }) => {
const url = new URL(request.url);
const filters = {};
url.searchParams.forEach((val, key) => {
(filters as any)[key] = val;
});
console.log(`[VirtualNetwork] GET /data/${params.object}`, filters);
const queryOpts = parseQueryOptions(url);
console.log(`[VirtualNetwork] GET /data/${params.object}`, queryOpts);

try {
let all = await ql.find(params.object);
let all = await ql.find(params.object, queryOpts);
if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value;
if (!all) all = [];
const result = { object: params.object, records: all, total: all.length };
Expand Down Expand Up @@ -122,12 +165,12 @@ export async function simulateBrowser() {
}
}),

// Metadata - Get all types (base route returns types)
// Metadata - Get all types (merges SchemaRegistry + MetadataService types)
http.get('http://localhost:3000/api/v1/meta', async () => {
console.log('[VirtualNetwork] GET /meta (types)');
try {
const types = ql?.registry?.getRegisteredTypes?.() || [];
return HttpResponse.json({ success: true, data: { types } });
const result = await protocol?.getMetaTypes?.();
return HttpResponse.json({ success: true, data: result || { types: [] } });
} catch (err: any) {
return HttpResponse.json({ error: err.message }, { status: 500 });
}
Expand All @@ -137,43 +180,44 @@ export async function simulateBrowser() {
http.get('http://localhost:3000/api/v1/meta/object', async () => {
console.log('[VirtualNetwork] GET /meta/object');
try {
const objects = ql?.getObjects?.() || {};
return HttpResponse.json({ success: true, data: objects });
const result = await protocol?.getMetaItems?.({ type: 'object' });
return HttpResponse.json({ success: true, data: result || { type: 'object', items: [] } });
} catch (err: any) {
return HttpResponse.json({ error: err.message }, { status: 500 });
}
}),
http.get('http://localhost:3000/api/v1/meta/objects', async () => {
console.log('[VirtualNetwork] GET /meta/objects');
try {
const objects = ql?.getObjects?.() || {};
return HttpResponse.json({ success: true, data: objects });
const result = await protocol?.getMetaItems?.({ type: 'object' });
return HttpResponse.json({ success: true, data: result || { type: 'object', items: [] } });
} catch (err: any) {
return HttpResponse.json({ error: err.message }, { status: 500 });
}
}),

// Metadata - Object Detail (Singular & Plural support)
// Returns the raw ServiceObject directly (with name, fields, etc.)
http.get('http://localhost:3000/api/v1/meta/object/:name', async ({ params }) => {
console.log(`[VirtualNetwork] GET /meta/object/${params.name}`);
try {
const result = ql?.registry?.getObject?.(params.name);
if (!result) {
const result = await protocol?.getMetaItem?.({ type: 'object', name: params.name as string });
if (!result?.item) {
return HttpResponse.json({ error: 'Not Found' }, { status: 404 });
}
return HttpResponse.json({ success: true, data: result });
return HttpResponse.json({ success: true, data: result.item });
} catch (err: any) {
return HttpResponse.json({ error: err.message }, { status: 500 });
}
}),
http.get('http://localhost:3000/api/v1/meta/objects/:name', async ({ params }) => {
console.log(`[VirtualNetwork] GET /meta/objects/${params.name}`);
try {
const result = ql?.registry?.getObject?.(params.name);
if (!result) {
const result = await protocol?.getMetaItem?.({ type: 'object', name: params.name as string });
if (!result?.item) {
return HttpResponse.json({ error: 'Not Found' }, { status: 404 });
}
return HttpResponse.json({ success: true, data: result });
return HttpResponse.json({ success: true, data: result.item });
} catch (err: any) {
return HttpResponse.json({ error: err.message }, { status: 500 });
}
Expand All @@ -186,8 +230,8 @@ export async function simulateBrowser() {
}
console.log(`[VirtualNetwork] GET /meta/${params.type}`);
try {
const items = ql?.registry?.listItems?.(params.type) || [];
return HttpResponse.json({ success: true, data: items });
const result = await protocol?.getMetaItems?.({ type: params.type as string });
return HttpResponse.json({ success: true, data: result || { type: params.type, items: [] } });
} catch (err: any) {
return HttpResponse.json({ error: err.message }, { status: 500 });
}
Expand Down
Loading