Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tidy-action-query-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Preserve nested object parameters when browser clients call GET actions.
23 changes: 23 additions & 0 deletions packages/core/src/client/use-action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ describe("serializeActionQueryParams", () => {
expect(params.has("empty")).toBe(false);
expect(params.has("none")).toBe(false);
});

it("serializes nested object GET params as JSON", () => {
const tableQuery = {
filters: [
{
propertyId: "status",
operator: "equals",
value: "published",
},
],
sorts: [{ propertyId: "date", direction: "desc" }],
};

const query = serializeActionQueryParams({
documentId: "doc-1",
tableQuery,
});

const params = new URLSearchParams(query);
expect(params.get("documentId")).toBe("doc-1");
expect(params.get("tableQuery")).toBe(JSON.stringify(tableQuery));
expect(query).not.toContain("%5Bobject+Object%5D");
});
});

describe("callAction", () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/client/use-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ function appendActionQueryParam(
}
return;
}
if (typeof value === "object") {
// defineAction restores JSON strings when the schema expects an object.
// Preserve nested GET params instead of collapsing them to "[object Object]".
qs.append(key, JSON.stringify(value));
Comment on lines +210 to +213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Preserve non-plain GET parameter values

This branch JSON-serializes every object, including Date and URLSearchParams. A Date now arrives JSON-quoted instead of in its prior parseable string form, while URLSearchParams serializes to {} and loses its entries; restrict this path to plain records and retain the existing string conversion for other object types.

Additional Info
Found by 1 of 3 incremental review agents; confirmed from the changed serializer behavior.

Fix in Builder

return;
}
qs.append(key, String(value));
}

Expand Down
46 changes: 45 additions & 1 deletion packages/core/src/server/action-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,23 @@ describe("mountActionRoutes", () => {
baseCurrency: z.string().optional(),
includeSeries: z.boolean().optional(),
limit: z.number().optional(),
tableQuery: z
.object({
filters: z.array(
z.object({
propertyId: z.string(),
operator: z.string(),
value: z.string(),
}),
),
sorts: z.array(
z.object({
propertyId: z.string(),
direction: z.enum(["asc", "desc"]),
}),
),
})
.optional(),
}),
run: async (params: any) => ({ params }),
});
Expand All @@ -836,7 +853,24 @@ describe("mountActionRoutes", () => {
const result = await mounted[0].handler({
_method: "GET",
req: {
url: "http://app.test/_agent-native/actions/instrument-overview?portfolioId=p1&isin=US67066G1040&includeSeries=true&limit=5",
url: `http://app.test/_agent-native/actions/instrument-overview?${new URLSearchParams(
{
portfolioId: "p1",
isin: "US67066G1040",
includeSeries: "true",
limit: "5",
tableQuery: JSON.stringify({
filters: [
{
propertyId: "status",
operator: "equals",
value: "published",
},
],
sorts: [{ propertyId: "date", direction: "desc" }],
}),
},
)}`,
},
});

Expand All @@ -846,6 +880,16 @@ describe("mountActionRoutes", () => {
isin: "US67066G1040",
includeSeries: true,
limit: 5,
tableQuery: {
filters: [
{
propertyId: "status",
operator: "equals",
value: "published",
},
],
sorts: [{ propertyId: "date", direction: "desc" }],
},
},
});
});
Expand Down
1,182 changes: 1,182 additions & 0 deletions plans/content-blog-table-performance-research.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions templates/content/actions/_batch-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";

import { processWithConcurrency } from "./_batch-utils.js";

describe("processWithConcurrency", () => {
it("starts the next item as soon as one worker becomes available", async () => {
const releases = new Map<number, () => void>();
const started: number[] = [];
const run = processWithConcurrency([1, 2, 3, 4], 2, async (item) => {
started.push(item);
await new Promise<void>((resolve) => releases.set(item, resolve));
});

await expect.poll(() => started).toEqual([1, 2]);
releases.get(1)?.();
await expect.poll(() => started).toEqual([1, 2, 3]);
releases.get(3)?.();
await expect.poll(() => started).toEqual([1, 2, 3, 4]);
releases.get(2)?.();
releases.get(4)?.();

await run;
});
});
33 changes: 33 additions & 0 deletions templates/content/actions/_batch-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getDialect, type Dialect } from "@agent-native/core/db";

export function chunks<T>(items: T[], size: number): T[][] {
if (items.length === 0) return [];
const out: T[][] = [];
Expand All @@ -6,3 +8,34 @@ export function chunks<T>(items: T[], size: number): T[][] {
}
return out;
}

export function bulkChunkSizeForColumnCount(
columnCount: number,
dialect: Dialect = getDialect(),
) {
// D1 rejects statements with more than 100 bound params, so derive every
// bulk chunk from the statement's column count instead of a fixed row count.
const budget = dialect === "d1" ? 90 : 900;
return Math.max(1, Math.floor(budget / Math.max(1, columnCount)));
}

export async function processWithConcurrency<T>(
items: readonly T[],
concurrency: number,
worker: (item: T) => Promise<void>,
) {
let nextIndex = 0;
const workerCount = Math.min(
items.length,
Math.max(1, Math.floor(concurrency)),
);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
if (item !== undefined) await worker(item);
}
}),
);
}
Loading
Loading