diff --git a/sdk/src/v1.generated.ts b/sdk/src/v1.generated.ts index 92db009..923e73c 100644 --- a/sdk/src/v1.generated.ts +++ b/sdk/src/v1.generated.ts @@ -2,7 +2,7 @@ // Regenerate: bun run scripts/generate-sdk.ts // @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT. -// Source: Instructions V1 API 0.3.0 +// Source: Instructions V1 API 0.4.19 export interface Config { "id"?: string; "name"?: string; "slug"?: string; "kind"?: string; "category"?: string; "agent"?: string; "target_path"?: string | null; "outputs"?: Array>; "format"?: string; "content"?: string; "description"?: string | null; "tags"?: Array; "is_template"?: boolean; "version"?: number; "created_at"?: string; "updated_at"?: string; "synced_at"?: string | null } @@ -14,6 +14,16 @@ export interface UpdateConfigInput { "name"?: string; "category"?: string; "agen export interface CreateProfileInput { "name": string; "description"?: string; "selectors"?: Record; "variables"?: Record } +export interface ProfileWithConfigs { "id"?: string; "name"?: string; "slug"?: string; "description"?: string | null; "selectors"?: Record; "variables"?: Record; "created_at"?: string; "updated_at"?: string; "configs"?: Array } + +export interface BoundedProfilePage { "profiles"?: Array; "items": Array; "count"?: number; "total": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "complete": boolean; "truncated": boolean; "source_bounded": boolean } + +export interface BoundedConfigPage { "items": Array; "total": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "complete": boolean; "truncated": boolean; "source_bounded": boolean } + +export interface ProfileShowResponse { "profile": ProfileWithConfigs; "configs": BoundedConfigPage } + +export interface ProfileResolutionRead { "profile": Profile | null; "scanned": number | null; "total": number | null; "batch_limit": number | null; "source_bounded": boolean; "complete": boolean; "truncated": boolean } + export interface InstructionsV1ClientOptions { /** Base URL, e.g. process.env.APP_API_URL. */ baseUrl: string; @@ -132,11 +142,11 @@ export class InstructionsV1Client { }); } - /** List profiles */ - async listProfiles(init?: RequestInit): Promise<{ "profiles"?: Array; "count"?: number }> { + /** List profiles with producer-side bounds */ + async listProfiles(query?: { "limit"?: number; "cursor"?: number }, init?: RequestInit): Promise { return this.request("GET", `/v1/profiles`, { body: undefined, - query: undefined, + query, init, }); } @@ -150,11 +160,20 @@ export class InstructionsV1Client { }); } + /** Resolve a machine profile by scanning producer-bounded batches */ + async resolveProfile(query?: { "hostname"?: string; "os"?: string; "arch"?: string; "limit"?: number }, init?: RequestInit): Promise { + return this.request("GET", `/v1/profiles/resolve`, { + body: undefined, + query, + init, + }); + } + /** Get a profile (with its configs) by id or slug */ - async getProfile(id: string, init?: RequestInit): Promise<{ "profile"?: Profile }> { + async getProfile(id: string, query?: { "limit"?: number; "cursor"?: number }, init?: RequestInit): Promise { return this.request("GET", `/v1/profiles/${encodeURIComponent(String(id))}`, { body: undefined, - query: undefined, + query, init, }); } diff --git a/src/cli/index.tsx b/src/cli/index.tsx index d51e963..627e392 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -329,10 +329,12 @@ function formatProfileVariables(profile: Pick): string { async function getMachineProfileContext( opts: { hostname?: string; os?: string; arch?: string }, store: ConfigStore, + readOptions: { limit?: unknown } = {}, ) { const machine = detectMachineContext({ hostname: opts.hostname, os: opts.os, arch: opts.arch }); - const profile = await store.resolveProfileForMachine(machine); - return { machine, profile, vars: resolveProfileVariables(profile, machine) }; + const resolution = await store.resolveProfileForMachineRead(machine, readOptions); + const profile = resolution.profile; + return { machine, profile, resolution, vars: resolveProfileVariables(profile, machine) }; } // ── list ───────────────────────────────────────────────────────────────────── @@ -830,24 +832,23 @@ profileCmd.command("list").description("List all profiles") .option("-f, --format ", "compact|table|json", "compact") .option("--verbose", "show expanded profile metadata") .option("--json", "output full profiles as JSON") - .option("--limit ", `max rows for human output (default ${DEFAULT_LIST_LIMIT})`) - .option("--cursor ", "zero-based pagination cursor for human output") + .option("--limit ", `max rows requested from the source (default ${DEFAULT_LIST_LIMIT})`) + .option("--cursor ", "zero-based source pagination cursor") .action(async (opts) => { const fmt = opts.json ? "json" : opts.verbose ? "table" : opts.brief ? "compact" : opts.format; const store = resolveConfigStore(); - const profiles = await store.listProfiles(); - if (fmt === "json") { printJson(profiles); return; } - if (profiles.length === 0) { console.log(chalk.dim("No profiles.")); return; } - const page = paginate(profiles, { limit: opts.limit, cursor: opts.cursor }); + const page = await store.listProfilesPage({ limit: opts.limit, cursor: opts.cursor }); + if (fmt === "json") { printJson(page); return; } + if (page.total === 0) { console.log(chalk.dim("No profiles.")); return; } if (fmt === "compact") console.log(`${pad("slug", 28)} ${pad("configs", 8)} ${pad("match", 36)} vars`); for (const p of page.items) { + const configCount = (await store.getProfileConfigsPage(p.id, { limit: 1 })).total; if (fmt === "compact") { const selectorSummary = formatProfileSelectorSummary(p); - console.log(`${pad(p.slug, 28)} ${pad(String((await store.getProfileConfigs(p.id)).length), 8)} ${pad(selectorSummary || "-", 36)} ${Object.keys(p.variables).length}`); + console.log(`${pad(p.slug, 28)} ${pad(String(configCount), 8)} ${pad(selectorSummary || "-", 36)} ${Object.keys(p.variables).length}`); continue; } - const configs = await store.getProfileConfigs(p.id); - console.log(`${chalk.bold(p.name)} ${chalk.dim(`(${p.slug})`)} — ${configs.length} config(s)`); + console.log(`${chalk.bold(p.name)} ${chalk.dim(`(${p.slug})`)} — ${configCount} config(s)`); if (p.description) console.log(` ${chalk.dim(p.description)}`); const selectorSummary = formatProfileSelectorSummary(p); if (selectorSummary) console.log(` ${chalk.dim(`match: ${selectorSummary}`)}`); @@ -902,19 +903,23 @@ profileCmd.command("update ").description("Update an existing profile's vari profileCmd.command("show ").description("Show profile and its configs") .option("--limit ", `max config rows (default ${DEFAULT_LIST_LIMIT})`) .option("--cursor ", "zero-based pagination cursor") + .option("--json", "output the profile and bounded membership page as JSON") .action(async (id, opts) => { try { const store = resolveConfigStore(); const p = await store.getProfile(id); - const configs = await store.getProfileConfigs(id); + const page = await store.getProfileConfigsPage(id, { limit: opts.limit, cursor: opts.cursor }); + if (opts.json) { + printJson({ profile: p, configs: page }); + return; + } console.log(chalk.bold(p.name) + chalk.dim(` (${p.slug})`)); if (p.description) console.log(chalk.dim(p.description)); const selectorSummary = formatProfileSelectorSummary(p); if (selectorSummary) console.log(chalk.dim(`match: ${selectorSummary}`)); const varSummary = formatProfileVariables(p); if (varSummary) console.log(chalk.dim(`vars: ${varSummary}`)); - console.log(chalk.cyan(`${configs.length} config(s):`)); - const page = paginate(configs, { limit: opts.limit, cursor: opts.cursor }); + console.log(chalk.cyan(`${page.total} config(s):`)); for (const c of page.items) console.log(` ${c.slug} ${chalk.dim(`[${c.category}/${c.agent}]`)}`); if (page.has_more) { console.log(chalk.dim(`Showing ${page.items.length} of ${page.total}. Next: configs profile show ${id} --cursor ${page.next_cursor} --limit ${page.limit}`)); @@ -996,9 +1001,16 @@ profileCmd.command("resolve").description("Resolve the matching machine-aware pr .option("--hostname ", "override detected hostname") .option("--os ", "override detected OS") .option("--arch ", "override detected arch") + .option("--limit ", `maximum profiles per source scan batch (default ${DEFAULT_LIST_LIMIT})`) + .option("--json", "output the complete bounded resolution read as JSON") .action(async (opts) => { const store = resolveConfigStore(); - const { machine, profile, vars } = await getMachineProfileContext(opts, store); + const { machine, profile, resolution, vars } = await getMachineProfileContext(opts, store, { limit: opts.limit }); + if (opts.json) { + printJson({ ...resolution, machine, vars }); + if (!profile) process.exitCode = 1; + return; + } if (!profile) { console.log(chalk.yellow(`No matching profile for ${machine.hostname} ${machine.os_family}/${machine.arch}`)); process.exit(1); diff --git a/src/cli/profile-reads.test.ts b/src/cli/profile-reads.test.ts new file mode 100644 index 0000000..026597e --- /dev/null +++ b/src/cli/profile-reads.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createConfig } from "../db/configs"; +import { getDatabase, resetDatabase } from "../db/database"; +import { addConfigToProfile, createProfile } from "../db/profiles"; +import { makeTempRoot } from "../lib/test-temp-root"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const tempDirs: string[] = []; + +function runCli(args: string[], dbPath: string) { + return spawnSync("bun", ["src/cli/index.tsx", ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + HASNA_INSTRUCTIONS_DB_PATH: dbPath, + HASNA_INSTRUCTIONS_API_URL: "", + HASNA_INSTRUCTIONS_API_KEY: "", + NO_COLOR: "1", + FORCE_COLOR: "0", + }, + }); +} + +function seedProfileReads(): { dbPath: string; targetSlug: string } { + const root = makeTempRoot("instructions-profile-reads-"); + tempDirs.push(root); + const dbPath = join(root, "instructions.db"); + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = dbPath; + process.env["HASNA_INSTRUCTIONS_API_URL"] = ""; + process.env["HASNA_INSTRUCTIONS_API_KEY"] = ""; + resetDatabase(); + const db = getDatabase(); + + for (let i = 1; i <= 5; i++) { + createProfile({ + name: `Profile ${String(i).padStart(2, "0")}`, + selectors: { hostnames: [`other-${i}`] }, + }, db); + } + const target = createProfile({ + name: "Z Target", + selectors: { hostnames: ["station02"], os: ["linux"], arch: ["x64"] }, + }, db); + for (let i = 1; i <= 5; i++) { + const config = createConfig({ + name: `Config ${String(i).padStart(2, "0")}`, + category: "rules", + content: `rule ${i}`, + }, db); + addConfigToProfile(target.id, config.id, db); + } + + resetDatabase(); + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_API_URL"]; + delete process.env["HASNA_INSTRUCTIONS_API_KEY"]; + return { dbPath, targetSlug: target.slug }; +} + +afterEach(() => { + resetDatabase(); + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_API_URL"]; + delete process.env["HASNA_INSTRUCTIONS_API_KEY"]; + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("bounded profile reads", () => { + test("profile list JSON exposes bounded pages and an authoritative terminal page", () => { + const { dbPath } = seedProfileReads(); + const first = runCli(["profile", "list", "--json", "--limit", "2"], dbPath); + + expect(first.status).toBe(0); + const firstPage = JSON.parse(first.stdout) as { + items: unknown[]; + total: number; + limit: number; + cursor: number; + next_cursor: number | null; + has_more: boolean; + complete: boolean; + truncated: boolean; + }; + expect(firstPage).toMatchObject({ + total: 6, + limit: 2, + cursor: 0, + next_cursor: 2, + has_more: true, + complete: false, + truncated: false, + }); + expect(firstPage.items).toHaveLength(2); + + const terminal = runCli(["profile", "list", "--json", "--limit", "2", "--cursor", "4"], dbPath); + expect(terminal.status).toBe(0); + expect(JSON.parse(terminal.stdout)).toMatchObject({ + total: 6, + cursor: 4, + next_cursor: null, + has_more: false, + complete: true, + truncated: false, + }); + }); + + test("profile show JSON returns a producer-bounded membership page", () => { + const { dbPath, targetSlug } = seedProfileReads(); + const result = runCli(["profile", "show", targetSlug, "--json", "--limit", "2", "--cursor", "4"], dbPath); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + profile: { slug: string }; + configs: { + items: Array<{ slug: string }>; + total: number; + limit: number; + cursor: number; + complete: boolean; + truncated: boolean; + }; + }; + expect(payload.profile.slug).toBe(targetSlug); + expect(payload.configs).toMatchObject({ + total: 5, + limit: 2, + cursor: 4, + complete: true, + truncated: false, + }); + expect(payload.configs.items.map((config) => config.slug)).toEqual(["config-05"]); + }); + + test("profile resolve JSON scans the complete source in bounded batches", () => { + const { dbPath, targetSlug } = seedProfileReads(); + const result = runCli([ + "profile", + "resolve", + "--json", + "--limit", + "2", + "--hostname", + "station02", + "--os", + "linux", + "--arch", + "x64", + ], dbPath); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + profile: { slug: string }; + scanned: number; + total: number; + batch_limit: number; + complete: boolean; + truncated: boolean; + }; + expect(payload).toMatchObject({ + scanned: 6, + total: 6, + batch_limit: 2, + complete: true, + truncated: false, + }); + expect(payload.profile.slug).toBe(targetSlug); + }); +}); diff --git a/src/cli/profile-update.test.ts b/src/cli/profile-update.test.ts index 208fc19..c2491c7 100644 --- a/src/cli/profile-update.test.ts +++ b/src/cli/profile-update.test.ts @@ -25,11 +25,12 @@ function runCli(args: string[], root: string) { function listProfiles(root: string) { const result = runCli(["profile", "list", "--json"], root); expect(result.status).toBe(0); - return JSON.parse(result.stdout) as Array<{ + const page = JSON.parse(result.stdout) as { items: Array<{ id: string; slug: string; variables: Record; - }>; + }> }; + return page.items; } describe("instructions profile update", () => { diff --git a/src/data/config-store.test.ts b/src/data/config-store.test.ts index 3c44d6e..c81ca5b 100644 --- a/src/data/config-store.test.ts +++ b/src/data/config-store.test.ts @@ -9,6 +9,7 @@ import { resolveCloudConfig, resolveConfigStore, } from "./config-store.js"; +import type { MachineContext } from "../types/index.js"; interface RecordedCall { url: string; @@ -65,6 +66,44 @@ const SAMPLE = { updated_at: "", synced_at: null, }; +const SAMPLE_PROFILE = { + id: "p1", + name: "Profile", + slug: "profile", + description: null, + selectors: { hostnames: ["station02"] }, + variables: {}, + created_at: "", + updated_at: "", +}; +const SAMPLE_MACHINE: MachineContext = { + id: "machine-1", + hostname: "station02", + os: "linux", + arch: "x64", + os_family: "linux", + home_dir: "/tmp", + workspace_root: "/tmp/workspace", + bun_bin_dir: "/tmp/bin", + bun_path: "/tmp/bin/bun", + path_prefix: "/tmp/bin", + last_applied_at: null, + created_at: "", +}; + +function page(items: T[], total = items.length, limit = 20, cursor = 0) { + const complete = cursor + items.length >= total; + return { + items, + total, + limit, + cursor, + next_cursor: complete ? null : cursor + items.length, + has_more: !complete, + complete, + truncated: false, + }; +} let active: { restore(): void } | undefined; afterEach(() => { @@ -170,12 +209,143 @@ describe("CloudConfigStore CRUD mapping", () => { }); test("getProfileConfigs -> GET /v1/profiles/:id embeds configs", async () => { - const m = mockFetch(() => ({ json: { profile: { id: "p1", name: "P", slug: "p", configs: [SAMPLE] } } })); + const m = mockFetch(() => ({ json: { profile: { ...SAMPLE_PROFILE, configs: [SAMPLE] }, configs: page([SAMPLE]) } })); active = m; const store = new CloudConfigStore(CONFIG); const configs = await store.getProfileConfigs("p"); expect(configs).toHaveLength(1); }); + + test("listProfilesPage sends producer bounds and requires complete metadata", async () => { + const m = mockFetch(() => ({ json: page([SAMPLE_PROFILE], 3, 2, 2) })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.listProfilesPage({ limit: 2, cursor: 2 }); + + expect(m.calls[0].url).toBe("https://instructions.hasna.xyz/v1/profiles?limit=2&cursor=2"); + expect(result).toMatchObject({ total: 3, limit: 2, cursor: 2, complete: true, truncated: false }); + }); + + test("getProfileConfigsPage sends membership bounds", async () => { + const m = mockFetch(() => ({ + json: { + profile: { ...SAMPLE_PROFILE, configs: [SAMPLE] }, + configs: page([SAMPLE], 5, 2, 4), + }, + })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.getProfileConfigsPage("profile", { limit: 2, cursor: 4 }); + + expect(m.calls[0].url).toBe("https://instructions.hasna.xyz/v1/profiles/profile?limit=2&cursor=4"); + expect(result).toMatchObject({ total: 5, cursor: 4, complete: true, truncated: false }); + }); + + test("resolveProfileForMachineRead sends the source scan bound", async () => { + const m = mockFetch(() => ({ + json: { + profile: SAMPLE_PROFILE, + scanned: 5, + total: 5, + batch_limit: 2, + complete: true, + truncated: false, + }, + })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.resolveProfileForMachineRead(SAMPLE_MACHINE, { limit: 2 }); + + expect(m.calls[0].url).toBe("https://instructions.hasna.xyz/v1/profiles/resolve?hostname=station02&os=linux&arch=x64&limit=2"); + expect(result).toMatchObject({ scanned: 5, total: 5, batch_limit: 2, complete: true, truncated: false }); + }); + + test("new client sends explicit default bounds and safely pages an old server's complete profile array", async () => { + const legacyProfiles = Array.from({ length: 5 }, (_, index) => ({ + ...SAMPLE_PROFILE, + id: `p${index + 1}`, + name: `Profile ${index + 1}`, + slug: `profile-${index + 1}`, + })); + const m = mockFetch(() => ({ json: { profiles: legacyProfiles, count: legacyProfiles.length } })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.listProfilesPage(); + + expect(m.calls[0].url).toBe("https://instructions.hasna.xyz/v1/profiles?limit=20&cursor=0"); + expect(result.items.map((profile) => profile.slug)).toEqual(legacyProfiles.map((profile) => profile.slug)); + expect(result).toMatchObject({ + total: 5, + limit: 20, + cursor: 0, + complete: true, + truncated: false, + }); + }); + + test("new client safely pages an old server's complete embedded profile membership", async () => { + const legacyConfigs = Array.from({ length: 5 }, (_, index) => ({ + ...SAMPLE, + id: `cfg-${index + 1}`, + name: `Config ${index + 1}`, + slug: `config-${index + 1}`, + })); + const m = mockFetch(() => ({ + json: { profile: { ...SAMPLE_PROFILE, configs: legacyConfigs } }, + })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.getProfileConfigsPage("profile", { limit: 2, cursor: 2 }); + + expect(m.calls[0].url).toBe("https://instructions.hasna.xyz/v1/profiles/profile?limit=2&cursor=2"); + expect(result.items.map((config) => config.slug)).toEqual(["config-3", "config-4"]); + expect(result).toMatchObject({ + total: 5, + limit: 2, + cursor: 2, + next_cursor: 4, + complete: false, + truncated: false, + }); + }); + + test("new client labels an old server's complete resolver response without inventing bounded counts", async () => { + const m = mockFetch(() => ({ json: { profile: SAMPLE_PROFILE } })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.resolveProfileForMachineRead( + { ...SAMPLE_MACHINE, os: null, arch: null }, + { limit: 2 }, + ); + + expect(m.calls[0].url).toBe("https://instructions.hasna.xyz/v1/profiles/resolve?hostname=station02&limit=2"); + expect(result).toMatchObject({ + profile: SAMPLE_PROFILE, + scanned: null, + total: null, + batch_limit: null, + source_bounded: false, + complete: true, + truncated: false, + }); + }); + + test("new client safely maps an old server's no-match 404 to a complete legacy result", async () => { + const m = mockFetch(() => ({ status: 404, json: { error: "no matching machine-aware profile" } })); + active = m; + const store = new CloudConfigStore(CONFIG); + const result = await store.resolveProfileForMachineRead({ ...SAMPLE_MACHINE, hostname: "missing" }, { limit: 2 }); + + expect(result).toMatchObject({ + profile: null, + scanned: null, + total: null, + batch_limit: null, + source_bounded: false, + complete: true, + truncated: false, + }); + }); }); describe("revoked / invalid API key handling", () => { diff --git a/src/data/config-store.ts b/src/data/config-store.ts index 4418b72..87c44a8 100644 --- a/src/data/config-store.ts +++ b/src/data/config-store.ts @@ -28,10 +28,10 @@ import { createProfile as dbCreateProfile, deleteProfile as dbDeleteProfile, getProfile as dbGetProfile, - getProfileConfigs as dbGetProfileConfigs, - listProfiles as dbListProfiles, + getProfileConfigsPage as dbGetProfileConfigsPage, + listProfilesPage as dbListProfilesPage, removeConfigFromProfile as dbRemoveConfigFromProfile, - resolveProfileForMachine as dbResolveProfileForMachine, + resolveProfileForMachineRead as dbResolveProfileForMachineRead, updateProfile as dbUpdateProfile, } from "../db/profiles.js"; import { @@ -57,9 +57,13 @@ import type { Machine, MachineContext, Profile, + BoundedReadOptions, + BoundedReadPage, + ProfileResolutionRead, UpdateConfigInput, UpdateProfileInput, } from "../types/index.js"; +import { boundedReadPage, normalizeBoundedReadOptions } from "../lib/bounded-read.js"; export interface CloudConfig { apiUrl: string; @@ -74,6 +78,66 @@ export class CloudHttpError extends Error { } } +function parseBoundedPagePayload(value: unknown, label: string): BoundedReadPage { + const page = value as Partial> | null; + const consumed = Number(page?.cursor) + (page?.items?.length ?? 0); + const complete = Boolean(page && Number.isSafeInteger(page.total) && consumed >= Number(page.total)); + if ( + !page || + !Array.isArray(page.items) || + !Number.isSafeInteger(page.total) || + Number(page.total) < 0 || + !Number.isSafeInteger(page.limit) || + Number(page.limit) < 1 || + !Number.isSafeInteger(page.cursor) || + Number(page.cursor) < 0 || + page.items.length > Number(page.limit) || + typeof page.has_more !== "boolean" || + typeof page.complete !== "boolean" || + page.truncated !== false || + (page.next_cursor !== null && !Number.isSafeInteger(page.next_cursor)) || + page.complete !== complete || + page.has_more !== !complete || + page.next_cursor !== (complete ? null : consumed) + ) { + throw new CloudHttpError(502, `${label} returned an invalid or truncated bounded-read envelope`, value); + } + return { + ...(page as BoundedReadPage), + source_bounded: page.source_bounded ?? true, + }; +} + +function parseBoundedOrLegacyPage( + value: unknown, + legacyItems: unknown, + options: BoundedReadOptions, + label: string, +): BoundedReadPage { + if (value && typeof value === "object") { + const candidate = value as Record; + if ( + "items" in candidate || + "total" in candidate || + "complete" in candidate || + "truncated" in candidate || + "next_cursor" in candidate + ) { + return parseBoundedPagePayload(value, label); + } + } + if (!Array.isArray(legacyItems)) { + throw new CloudHttpError(502, `${label} returned neither a bounded envelope nor a complete legacy array`, value); + } + const normalized = normalizeBoundedReadOptions(options); + const page = boundedReadPage( + legacyItems.slice(normalized.cursor, normalized.cursor + normalized.limit) as T[], + legacyItems.length, + normalized, + ); + return { ...page, source_bounded: false }; +} + const API_URL_ENV = "HASNA_INSTRUCTIONS_API_URL"; const API_KEY_ENV = "HASNA_INSTRUCTIONS_API_KEY"; @@ -160,14 +224,17 @@ export interface ConfigStore { pruneSnapshots(configId: string, keep?: number): Promise; // Profiles listProfiles(): Promise; + listProfilesPage(options?: BoundedReadOptions): Promise>; getProfile(idOrSlug: string): Promise; getProfileConfigs(idOrSlug: string): Promise; + getProfileConfigsPage(idOrSlug: string, options?: BoundedReadOptions): Promise>; createProfile(input: CreateProfileInput): Promise; updateProfile(idOrSlug: string, input: UpdateProfileInput): Promise; deleteProfile(idOrSlug: string): Promise; addConfigToProfile(profileIdOrSlug: string, configId: string): Promise; removeConfigFromProfile(profileIdOrSlug: string, configId: string): Promise; resolveProfileForMachine(machine?: MachineContext): Promise; + resolveProfileForMachineRead(machine?: MachineContext, options?: BoundedReadOptions): Promise; // Machines registerMachine(hostname?: string, os?: string, arch?: string): Promise; updateMachineApplied(hostname?: string): Promise; @@ -232,13 +299,33 @@ export class LocalConfigStore implements ConfigStore { } // Profiles async listProfiles(): Promise { - return dbListProfiles(this.db); + const profiles: Profile[] = []; + let cursor = 0; + while (true) { + const page = await this.listProfilesPage({ limit: 100, cursor }); + profiles.push(...page.items); + if (page.complete) return profiles; + cursor = page.next_cursor!; + } + } + async listProfilesPage(options: BoundedReadOptions = {}): Promise> { + return dbListProfilesPage(options, this.db); } async getProfile(idOrSlug: string): Promise { return dbGetProfile(idOrSlug, this.db); } async getProfileConfigs(idOrSlug: string): Promise { - return dbGetProfileConfigs(idOrSlug, this.db); + const configs: Config[] = []; + let cursor = 0; + while (true) { + const page = await this.getProfileConfigsPage(idOrSlug, { limit: 100, cursor }); + configs.push(...page.items); + if (page.complete) return configs; + cursor = page.next_cursor!; + } + } + async getProfileConfigsPage(idOrSlug: string, options: BoundedReadOptions = {}): Promise> { + return dbGetProfileConfigsPage(idOrSlug, options, this.db); } async createProfile(input: CreateProfileInput): Promise { return dbCreateProfile(input, this.db); @@ -256,9 +343,15 @@ export class LocalConfigStore implements ConfigStore { dbRemoveConfigFromProfile(profileIdOrSlug, configId, this.db); } async resolveProfileForMachine(machine?: MachineContext): Promise { + return (await this.resolveProfileForMachineRead(machine)).profile; + } + async resolveProfileForMachineRead( + machine?: MachineContext, + options: BoundedReadOptions = {}, + ): Promise { return machine - ? dbResolveProfileForMachine(machine, this.db) - : dbResolveProfileForMachine(undefined, this.db); + ? dbResolveProfileForMachineRead(machine, options, this.db) + : dbResolveProfileForMachineRead(undefined, options, this.db); } // Machines async registerMachine(hostname?: string, os?: string, arch?: string): Promise { @@ -455,8 +548,27 @@ export class CloudConfigStore implements ConfigStore { // Profiles async listProfiles(): Promise { - const { data } = await this.request<{ profiles: Profile[] }>("GET", "/profiles"); - return data?.profiles ?? []; + const profiles: Profile[] = []; + let cursor = 0; + while (true) { + const page = await this.listProfilesPage({ limit: 100, cursor }); + profiles.push(...page.items); + if (page.complete) return profiles; + cursor = page.next_cursor!; + } + } + + async listProfilesPage(options: BoundedReadOptions = {}): Promise> { + const normalized = normalizeBoundedReadOptions(options); + const params = new URLSearchParams(); + params.set("limit", String(normalized.limit)); + params.set("cursor", String(normalized.cursor)); + const qs = params.toString(); + const { data } = await this.request & { profiles?: Profile[] }>( + "GET", + `/profiles${qs ? `?${qs}` : ""}`, + ); + return parseBoundedOrLegacyPage(data, data?.profiles, normalized, "profile list"); } async getProfile(idOrSlug: string): Promise { @@ -472,14 +584,41 @@ export class CloudConfigStore implements ConfigStore { } async getProfileConfigs(idOrSlug: string): Promise { - const { status, data } = await this.request<{ profile: Profile & { configs?: Config[] } }>( + const configs: Config[] = []; + let cursor = 0; + while (true) { + const page = await this.getProfileConfigsPage(idOrSlug, { limit: 100, cursor }); + configs.push(...page.items); + if (page.complete) return configs; + cursor = page.next_cursor!; + } + } + + async getProfileConfigsPage( + idOrSlug: string, + options: BoundedReadOptions = {}, + ): Promise> { + const normalized = normalizeBoundedReadOptions(options); + const params = new URLSearchParams(); + params.set("limit", String(normalized.limit)); + params.set("cursor", String(normalized.cursor)); + const qs = params.toString(); + const { status, data } = await this.request<{ + profile: Profile & { configs?: Config[] }; + configs?: BoundedReadPage; + }>( "GET", - `/profiles/${encodeURIComponent(idOrSlug)}`, + `/profiles/${encodeURIComponent(idOrSlug)}${qs ? `?${qs}` : ""}`, undefined, { allow404: true }, ); if (status === 404 || !data?.profile) throw new ProfileNotFoundError(idOrSlug); - return data.profile.configs ?? []; + return parseBoundedOrLegacyPage( + data.configs, + data.profile.configs, + normalized, + "profile membership", + ); } async createProfile(input: CreateProfileInput): Promise { @@ -527,19 +666,57 @@ export class CloudConfigStore implements ConfigStore { } async resolveProfileForMachine(machine?: MachineContext): Promise { + return (await this.resolveProfileForMachineRead(machine)).profile; + } + + async resolveProfileForMachineRead( + machine?: MachineContext, + options: BoundedReadOptions = {}, + ): Promise { + const normalized = normalizeBoundedReadOptions(options); const params = new URLSearchParams(); if (machine?.hostname) params.set("hostname", machine.hostname); if (machine?.os) params.set("os", machine.os); if (machine?.arch) params.set("arch", machine.arch); + params.set("limit", String(normalized.limit)); const qs = params.toString(); - const { status, data } = await this.request<{ profile: Profile | null }>( + const { status, data } = await this.request( "GET", `/profiles/resolve${qs ? `?${qs}` : ""}`, undefined, { allow404: true }, ); - if (status === 404 || !data?.profile) return null; - return data.profile; + if (status === 404) { + return { + profile: null, + scanned: null, + total: null, + batch_limit: null, + source_bounded: false, + complete: true, + truncated: false, + }; + } + if (data && "complete" in data) { + if (data.complete !== true || data.truncated !== false) { + throw new CloudHttpError(502, "profile resolve returned an incomplete or truncated read", data); + } + return { ...data, source_bounded: data.source_bounded ?? true }; + } + if (data && "profile" in data) { + return { + profile: data.profile, + scanned: null, + total: null, + batch_limit: null, + source_bounded: false, + complete: true, + truncated: false, + }; + } + { + throw new CloudHttpError(502, "profile resolve returned an incomplete or truncated read", data); + } } // Machines diff --git a/src/db/profiles.test.ts b/src/db/profiles.test.ts index 1b5b279..a684815 100644 --- a/src/db/profiles.test.ts +++ b/src/db/profiles.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { getDatabase, resetDatabase } from "./database"; import { createConfig } from "./configs"; -import { createProfile, getProfile, listProfiles, updateProfile, deleteProfile, addConfigToProfile, removeConfigFromProfile, getProfileConfigs, resolveProfileForMachine } from "./profiles"; +import { createProfile, getProfile, listProfiles, listProfilesPage, updateProfile, deleteProfile, addConfigToProfile, removeConfigFromProfile, getProfileConfigs, getProfileConfigsPage, resolveProfileForMachine, resolveProfileForMachineRead } from "./profiles"; import type { Database } from "bun:sqlite"; import { detectMachineContext } from "../lib/machine"; @@ -41,6 +41,22 @@ describe("profiles", () => { expect(listProfiles(db).length).toBe(2); }); + test("listProfilesPage returns exact source-bounded metadata", () => { + for (const name of ["A", "B", "C", "D", "E"]) createProfile({ name }, db); + const page = listProfilesPage({ limit: 2, cursor: 2 }, db); + + expect(page.items.map((profile) => profile.name)).toEqual(["C", "D"]); + expect(page).toMatchObject({ + total: 5, + limit: 2, + cursor: 2, + next_cursor: 4, + has_more: true, + complete: false, + truncated: false, + }); + }); + test("updateProfile changes name and slug", () => { const p = createProfile({ name: "Old" }, db); const updated = updateProfile(p.id, { @@ -69,6 +85,26 @@ describe("profiles", () => { expect(configs[0]!.id).toBe(c.id); }); + test("getProfileConfigsPage bounds membership rows at the source", () => { + const p = createProfile({ name: "P" }, db); + for (let i = 1; i <= 5; i++) { + const config = createConfig({ name: `C${i}`, category: "rules", content: "" }, db); + addConfigToProfile(p.id, config.id, db); + } + const page = getProfileConfigsPage(p.id, { limit: 2, cursor: 4 }, db); + + expect(page.items.map((config) => config.slug)).toEqual(["c5"]); + expect(page).toMatchObject({ + total: 5, + limit: 2, + cursor: 4, + next_cursor: null, + has_more: false, + complete: true, + truncated: false, + }); + }); + test("removeConfigFromProfile removes it", () => { const p = createProfile({ name: "P" }, db); const c = createConfig({ name: "C", category: "rules", content: "" }, db); @@ -98,4 +134,26 @@ describe("profiles", () => { expect(profile?.slug).toBe("macos-arm64"); }); + + test("resolveProfileForMachineRead scans every source page before resolving", () => { + for (let i = 1; i <= 4; i++) { + createProfile({ name: `A${i}`, selectors: { hostnames: [`other-${i}`] } }, db); + } + createProfile({ name: "Z target", selectors: { hostnames: ["station02"] } }, db); + + const resolution = resolveProfileForMachineRead(detectMachineContext({ + hostname: "station02", + os: "Linux", + arch: "x64", + }), { limit: 2 }, db); + + expect(resolution.profile?.slug).toBe("z-target"); + expect(resolution).toMatchObject({ + scanned: 5, + total: 5, + batch_limit: 2, + complete: true, + truncated: false, + }); + }); }); diff --git a/src/db/profiles.ts b/src/db/profiles.ts index 9e8d1bb..ace7fc9 100644 --- a/src/db/profiles.ts +++ b/src/db/profiles.ts @@ -8,11 +8,15 @@ import type { ProfileRow, UpdateProfileInput, MachineContext, + BoundedReadOptions, + BoundedReadPage, + ProfileResolutionRead, } from "../types/index.js"; import { ProfileNotFoundError } from "../types/index.js"; import { getDatabase, now, slugify, uuid } from "./database.js"; -import { listConfigs } from "./configs.js"; +import { getConfigById } from "./configs.js"; import { detectMachineContext, normalizeOsFamily } from "../lib/machine.js"; +import { boundedReadPage, normalizeBoundedReadOptions } from "../lib/bounded-read.js"; function rowToProfile(row: ProfileRow): Profile { return { @@ -75,6 +79,20 @@ export function listProfiles(db?: Database): Profile[] { .map(rowToProfile); } +export function listProfilesPage( + options: BoundedReadOptions = {}, + db?: Database, +): BoundedReadPage { + const d = db || getDatabase(); + const normalized = normalizeBoundedReadOptions(options); + const total = d.query<{ total: number }, []>("SELECT COUNT(*) AS total FROM profiles").get()?.total ?? 0; + const rows = d + .query("SELECT * FROM profiles ORDER BY name LIMIT ? OFFSET ?") + .all(normalized.limit, normalized.cursor) + .map(rowToProfile); + return boundedReadPage(rows, total, normalized); +} + export function updateProfile( idOrSlug: string, input: UpdateProfileInput, @@ -146,16 +164,34 @@ export function removeConfigFromProfile( } export function getProfileConfigs(profileIdOrSlug: string, db?: Database): Config[] { + const d = db || getDatabase(); + const configs: Config[] = []; + let cursor = 0; + while (true) { + const page = getProfileConfigsPage(profileIdOrSlug, { limit: 100, cursor }, d); + configs.push(...page.items); + if (page.complete) return configs; + cursor = page.next_cursor!; + } +} + +export function getProfileConfigsPage( + profileIdOrSlug: string, + options: BoundedReadOptions = {}, + db?: Database, +): BoundedReadPage { const d = db || getDatabase(); const profile = getProfile(profileIdOrSlug, d); + const normalized = normalizeBoundedReadOptions(options); + const total = d + .query<{ total: number }, [string]>("SELECT COUNT(*) AS total FROM profile_configs WHERE profile_id = ?") + .get(profile.id)?.total ?? 0; const rows = d - .query<{ config_id: string }, [string]>( - "SELECT config_id FROM profile_configs WHERE profile_id = ? ORDER BY sort_order" + .query<{ config_id: string }, [string, number, number]>( + "SELECT config_id FROM profile_configs WHERE profile_id = ? ORDER BY sort_order LIMIT ? OFFSET ?", ) - .all(profile.id); - if (rows.length === 0) return []; - const ids = rows.map((r) => r.config_id); - return listConfigs(undefined, d).filter((c) => ids.includes(c.id)); + .all(profile.id, normalized.limit, normalized.cursor); + return boundedReadPage(rows.map((row) => getConfigById(row.config_id, d)), total, normalized); } export function profileHasSelectors(profile: Pick): boolean { @@ -186,18 +222,51 @@ export function resolveProfileForMachine( machine: MachineContext = detectMachineContext(), db?: Database ): Profile | null { - const profiles = listProfiles(db).filter(profileHasSelectors); - const matches = profiles - .filter((profile) => profileMatchesMachine(profile, machine)) - .map((profile) => { + return resolveProfileForMachineRead(machine, {}, db).profile; +} + +export function resolveProfileForMachineRead( + machine: MachineContext = detectMachineContext(), + options: BoundedReadOptions = {}, + db?: Database, +): ProfileResolutionRead { + const d = db || getDatabase(); + const { limit } = normalizeBoundedReadOptions(options); + let cursor = 0; + let scanned = 0; + let total = 0; + let selected: { profile: Profile; score: number } | null = null; + + while (true) { + const page = listProfilesPage({ limit, cursor }, d); + total = page.total; + scanned += page.items.length; + for (const profile of page.items) { + if (!profileHasSelectors(profile) || !profileMatchesMachine(profile, machine)) continue; const selectors = profile.selectors; const score = (selectors.hostnames?.length ? 100 : 0) + (selectors.os?.length ? 10 : 0) + (selectors.arch?.length ? 10 : 0); - return { profile, score }; - }) - .sort((a, b) => b.score - a.score || a.profile.name.localeCompare(b.profile.name)); + if ( + !selected || + score > selected.score || + (score === selected.score && profile.name.localeCompare(selected.profile.name) < 0) + ) { + selected = { profile, score }; + } + } + if (page.complete) break; + cursor = page.next_cursor!; + } - return matches[0]?.profile ?? null; + return { + profile: selected?.profile ?? null, + scanned, + total, + batch_limit: limit, + source_bounded: true, + complete: true, + truncated: false, + }; } diff --git a/src/index.ts b/src/index.ts index fcd3356..0f637fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { resolveConfigStore, } from "./data/config-store.js"; export type { CloudConfig, ConfigStore } from "./data/config-store.js"; +export { boundedReadPage, normalizeBoundedReadOptions } from "./lib/bounded-read.js"; // Machine + slug helpers (pure) export { currentHostname, currentOs, currentArch } from "./db/machines.js"; diff --git a/src/lib/bounded-read.ts b/src/lib/bounded-read.ts new file mode 100644 index 0000000..dca4295 --- /dev/null +++ b/src/lib/bounded-read.ts @@ -0,0 +1,38 @@ +import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, parseCursor, parseLimit } from "./compact-output.js"; +import type { BoundedReadOptions, BoundedReadPage } from "../types/index.js"; + +export function normalizeBoundedReadOptions( + options: BoundedReadOptions = {}, +): { limit: number; cursor: number } { + return { + limit: parseLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT), + cursor: parseCursor(options.cursor), + }; +} + +export function boundedReadPage( + items: T[], + total: number, + options: BoundedReadOptions = {}, +): BoundedReadPage { + const { limit, cursor } = normalizeBoundedReadOptions(options); + if (items.length > limit) { + throw new Error(`bounded read returned ${items.length} rows for limit ${limit}`); + } + const consumed = cursor + items.length; + const complete = consumed >= total; + if (!complete && items.length === 0) { + throw new Error(`bounded read did not advance at cursor ${cursor} of ${total}`); + } + return { + items, + total, + limit, + cursor, + next_cursor: complete ? null : consumed, + has_more: !complete, + complete, + truncated: false, + source_bounded: true, + }; +} diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 9686a22..6a3ca5f 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -97,6 +97,66 @@ export function buildV1OpenApiDocument(version = getPackageVersion()) { variables: { type: "object" }, }, }, + ProfileWithConfigs: { + type: "object", + properties: { + ...profileSchema.properties, + configs: { type: "array", items: { $ref: "#/components/schemas/Config" } }, + }, + }, + BoundedProfilePage: { + type: "object", + required: ["items", "total", "limit", "cursor", "next_cursor", "has_more", "complete", "truncated", "source_bounded"], + properties: { + profiles: { type: "array", items: { $ref: "#/components/schemas/Profile" } }, + items: { type: "array", items: { $ref: "#/components/schemas/Profile" } }, + count: { type: "number" }, + total: { type: "number" }, + limit: { type: "number" }, + cursor: { type: "number" }, + next_cursor: { type: "number", nullable: true }, + has_more: { type: "boolean" }, + complete: { type: "boolean" }, + truncated: { type: "boolean", const: false }, + source_bounded: { type: "boolean" }, + }, + }, + BoundedConfigPage: { + type: "object", + required: ["items", "total", "limit", "cursor", "next_cursor", "has_more", "complete", "truncated", "source_bounded"], + properties: { + items: { type: "array", items: { $ref: "#/components/schemas/Config" } }, + total: { type: "number" }, + limit: { type: "number" }, + cursor: { type: "number" }, + next_cursor: { type: "number", nullable: true }, + has_more: { type: "boolean" }, + complete: { type: "boolean" }, + truncated: { type: "boolean", const: false }, + source_bounded: { type: "boolean" }, + }, + }, + ProfileShowResponse: { + type: "object", + required: ["profile", "configs"], + properties: { + profile: { $ref: "#/components/schemas/ProfileWithConfigs" }, + configs: { $ref: "#/components/schemas/BoundedConfigPage" }, + }, + }, + ProfileResolutionRead: { + type: "object", + required: ["profile", "scanned", "total", "batch_limit", "source_bounded", "complete", "truncated"], + properties: { + profile: { oneOf: [{ $ref: "#/components/schemas/Profile" }, { type: "null" }] }, + scanned: { type: "number", nullable: true }, + total: { type: "number", nullable: true }, + batch_limit: { type: "number", nullable: true }, + source_bounded: { type: "boolean" }, + complete: { type: "boolean", const: true }, + truncated: { type: "boolean", const: false }, + }, + }, }, }, security: [{ apiKey: [] }], @@ -210,18 +270,16 @@ export function buildV1OpenApiDocument(version = getPackageVersion()) { "/v1/profiles": { get: { operationId: "listProfiles", - summary: "List profiles", + summary: "List profiles with producer-side bounds", + parameters: [ + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, + { name: "cursor", in: "query", schema: { type: "integer", minimum: 0 } }, + ], responses: { "200": { content: { "application/json": { - schema: { - type: "object", - properties: { - profiles: { type: "array", items: { $ref: "#/components/schemas/Profile" } }, - count: { type: "number" }, - }, - }, + schema: { $ref: "#/components/schemas/BoundedProfilePage" }, }, }, }, @@ -237,12 +295,37 @@ export function buildV1OpenApiDocument(version = getPackageVersion()) { responses: { "201": { content: { "application/json": { schema: { type: "object", properties: { profile: { $ref: "#/components/schemas/Profile" } } } } } } }, }, }, + "/v1/profiles/resolve": { + get: { + operationId: "resolveProfile", + summary: "Resolve a machine profile by scanning producer-bounded batches", + parameters: [ + { name: "hostname", in: "query", schema: { type: "string" } }, + { name: "os", in: "query", schema: { type: "string" } }, + { name: "arch", in: "query", schema: { type: "string" } }, + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProfileResolutionRead" }, + }, + }, + }, + }, + }, + }, "/v1/profiles/{id}": { get: { operationId: "getProfile", summary: "Get a profile (with its configs) by id or slug", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], - responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { profile: { $ref: "#/components/schemas/Profile" } } } } } } }, + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, + { name: "cursor", in: "query", schema: { type: "integer", minimum: 0 } }, + ], + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ProfileShowResponse" } } } } }, }, delete: { operationId: "deleteProfile", diff --git a/src/server/profile-contract.test.ts b/src/server/profile-contract.test.ts new file mode 100644 index 0000000..9490eaa --- /dev/null +++ b/src/server/profile-contract.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import * as cloud from "./cloud.js"; +import * as store from "../storage/cloud-store.js"; +import type { Config, Profile } from "../types/index.js"; +import { buildV1OpenApiDocument } from "./openapi.js"; +import { handleV1Request } from "./v1.js"; + +const spies: Array<{ mockRestore(): void }> = []; + +function track(spy: T): T { + spies.push(spy); + return spy; +} + +afterEach(() => { + while (spies.length > 0) spies.pop()?.mockRestore(); +}); + +const profiles: Profile[] = Array.from({ length: 25 }, (_, index) => ({ + id: `profile-${index + 1}`, + name: `Profile ${index + 1}`, + slug: `profile-${index + 1}`, + description: null, + selectors: {}, + variables: {}, + created_at: "", + updated_at: "", +})); + +const configs: Config[] = Array.from({ length: 25 }, (_, index) => ({ + id: `config-${index + 1}`, + name: `Config ${index + 1}`, + slug: `config-${index + 1}`, + kind: "file", + category: "rules", + agent: "global", + target_path: null, + outputs: [], + format: "markdown", + content: "", + description: null, + tags: [], + is_template: false, + version: 1, + created_at: "", + updated_at: "", + synced_at: null, +})); + +function mockCloudBoundary() { + track(spyOn(cloud, "ensureCloudSchema").mockResolvedValue(undefined)); + track(spyOn(cloud, "getCloudClient").mockReturnValue({} as never)); +} + +describe("mixed-version profile HTTP compatibility", () => { + test("old client list request receives every legacy profile instead of the default bounded page", async () => { + mockCloudBoundary(); + track(spyOn(store, "listProfiles").mockResolvedValue(profiles)); + track(spyOn(store, "listProfilesPage").mockResolvedValue({ + items: profiles.slice(0, 20), + total: profiles.length, + limit: 20, + cursor: 0, + next_cursor: 20, + has_more: true, + complete: false, + truncated: false, + source_bounded: true, + })); + + const response = await handleV1Request( + new Request("https://instructions.hasna.xyz/v1/profiles"), + new URL("https://instructions.hasna.xyz/v1/profiles"), + ); + const payload = await response?.json() as { profiles: unknown[]; items: unknown[]; complete: boolean }; + + expect(payload.profiles).toHaveLength(25); + expect(payload.items).toHaveLength(25); + expect(payload.complete).toBe(true); + }); + + test("old client show request receives every embedded config instead of the default bounded page", async () => { + mockCloudBoundary(); + track(spyOn(store, "getProfile").mockResolvedValue(profiles[0]!)); + track(spyOn(store, "getProfileConfigs").mockResolvedValue(configs)); + track(spyOn(store, "getProfileConfigsPage").mockResolvedValue({ + items: configs.slice(0, 20), + total: configs.length, + limit: 20, + cursor: 0, + next_cursor: 20, + has_more: true, + complete: false, + truncated: false, + source_bounded: true, + })); + + const response = await handleV1Request( + new Request("https://instructions.hasna.xyz/v1/profiles/profile-1"), + new URL("https://instructions.hasna.xyz/v1/profiles/profile-1"), + ); + const payload = await response?.json() as { + profile: { configs: unknown[] }; + configs: { items: unknown[]; complete: boolean }; + }; + + expect(payload.profile.configs).toHaveLength(25); + expect(payload.configs.items).toHaveLength(25); + expect(payload.configs.complete).toBe(true); + }); +}); + +describe("profile OpenAPI and generated SDK contract", () => { + test("documents bounded list, show membership, and resolve response envelopes", () => { + const spec = buildV1OpenApiDocument("test") as any; + const schemas = spec.components.schemas; + const listResponse = spec.paths["/v1/profiles"].get.responses["200"].content["application/json"].schema; + const showResponse = spec.paths["/v1/profiles/{id}"].get.responses["200"].content["application/json"].schema; + const resolveResponse = spec.paths["/v1/profiles/resolve"].get.responses["200"].content["application/json"].schema; + + expect(schemas.BoundedProfilePage).toBeDefined(); + expect(schemas.BoundedConfigPage).toBeDefined(); + expect(schemas.ProfileResolutionRead).toBeDefined(); + expect(listResponse.$ref).toBe("#/components/schemas/BoundedProfilePage"); + expect(schemas.BoundedProfilePage.properties.items.items.$ref).toBe("#/components/schemas/Profile"); + expect(showResponse.$ref).toBe("#/components/schemas/ProfileShowResponse"); + expect(schemas.ProfileShowResponse.properties.configs.$ref).toBe("#/components/schemas/BoundedConfigPage"); + expect(resolveResponse.$ref).toBe("#/components/schemas/ProfileResolutionRead"); + }); + + test("tracked generated SDK exposes bounded profile list, show, and resolve methods", () => { + const generated = readFileSync(join(import.meta.dir, "../../sdk/src/v1.generated.ts"), "utf8"); + + expect(generated).toContain("export interface BoundedProfilePage"); + expect(generated).toContain("export interface BoundedConfigPage"); + expect(generated).toContain("export interface ProfileResolutionRead"); + expect(generated).toContain("async listProfiles(query?:"); + expect(generated).toContain("async getProfile(id: string, query?:"); + expect(generated).toContain("async resolveProfile(query?:"); + expect(generated).toContain('"next_cursor": number | null'); + expect(generated).toContain('"scanned": number | null'); + expect(generated).toContain('"batch_limit": number | null'); + }); +}); diff --git a/src/server/v1.ts b/src/server/v1.ts index 565b159..4510aee 100644 --- a/src/server/v1.ts +++ b/src/server/v1.ts @@ -22,6 +22,20 @@ function errorResponse(status: number, message: string, extra?: Record(items: T[]) { + return { + items, + total: items.length, + limit: Math.max(items.length, 1), + cursor: 0, + next_cursor: null, + has_more: false, + complete: true, + truncated: false, + source_bounded: false, + } as const; +} + async function readJson(req: Request): Promise { try { const text = await req.text(); @@ -134,8 +148,15 @@ export async function handleV1Request(req: Request, url: URL): Promise[1]>(req); @@ -152,13 +173,16 @@ export async function handleV1Request(req: Request, url: URL): Promise[2]>(req); diff --git a/src/storage/cloud-store.ts b/src/storage/cloud-store.ts index 555a83f..2d19811 100644 --- a/src/storage/cloud-store.ts +++ b/src/storage/cloud-store.ts @@ -22,9 +22,13 @@ import { type Profile, type ProfileSelector, type ProfileVariables, + type BoundedReadOptions, + type BoundedReadPage, + type ProfileResolutionRead, type UpdateConfigInput, type UpdateProfileInput, } from "../types/index.js"; +import { boundedReadPage, normalizeBoundedReadOptions } from "../lib/bounded-read.js"; function slugify(name: string): string { return name @@ -370,8 +374,27 @@ function rowToProfile(row: ProfileDbRow): Profile { } export async function listProfiles(client: TypedQueryClient): Promise { - const rows = await client.many("SELECT * FROM profiles ORDER BY name"); - return rows.map(rowToProfile); + const profiles: Profile[] = []; + let cursor = 0; + while (true) { + const page = await listProfilesPage(client, { limit: 100, cursor }); + profiles.push(...page.items); + if (page.complete) return profiles; + cursor = page.next_cursor!; + } +} + +export async function listProfilesPage( + client: TypedQueryClient, + options: BoundedReadOptions = {}, +): Promise> { + const normalized = normalizeBoundedReadOptions(options); + const count = await client.get<{ total: number | string }>("SELECT COUNT(*) AS total FROM profiles"); + const rows = await client.many( + "SELECT * FROM profiles ORDER BY name LIMIT $1 OFFSET $2", + [normalized.limit, normalized.cursor], + ); + return boundedReadPage(rows.map(rowToProfile), Number(count?.total ?? 0), normalized); } export async function getProfile(client: TypedQueryClient, idOrSlug: string): Promise { @@ -387,15 +410,36 @@ export async function getProfileConfigs( client: TypedQueryClient, idOrSlug: string, ): Promise { + const configs: Config[] = []; + let cursor = 0; + while (true) { + const page = await getProfileConfigsPage(client, idOrSlug, { limit: 100, cursor }); + configs.push(...page.items); + if (page.complete) return configs; + cursor = page.next_cursor!; + } +} + +export async function getProfileConfigsPage( + client: TypedQueryClient, + idOrSlug: string, + options: BoundedReadOptions = {}, +): Promise> { const profile = await getProfile(client, idOrSlug); + const normalized = normalizeBoundedReadOptions(options); + const count = await client.get<{ total: number | string }>( + "SELECT COUNT(*) AS total FROM profile_configs WHERE profile_id = $1", + [profile.id], + ); const rows = await client.many( `SELECT c.* FROM configs c JOIN profile_configs pc ON pc.config_id = c.id WHERE pc.profile_id = $1 - ORDER BY pc.sort_order`, - [profile.id], + ORDER BY pc.sort_order + LIMIT $2 OFFSET $3`, + [profile.id, normalized.limit, normalized.cursor], ); - return rows.map(rowToConfig); + return boundedReadPage(rows.map(rowToConfig), Number(count?.total ?? 0), normalized); } export async function createProfile( @@ -502,24 +546,59 @@ export async function resolveProfileForMachine( client: TypedQueryClient, machine: { hostname?: string; os?: string; arch?: string }, ): Promise { - const profiles = (await listProfiles(client)).filter((p) => profileHasSelectors(p.selectors)); + return (await resolveProfileForMachineRead(client, machine)).profile; +} + +export async function resolveProfileForMachineRead( + client: TypedQueryClient, + machine: { hostname?: string; os?: string; arch?: string }, + options: BoundedReadOptions = {}, +): Promise { + const { limit } = normalizeBoundedReadOptions(options); const host = (machine.hostname ?? "").trim().toLowerCase(); const os = (machine.os ?? "").trim().toLowerCase(); const arch = (machine.arch ?? "").trim().toLowerCase(); - const matches = profiles - .filter((p) => { + let cursor = 0; + let scanned = 0; + let total = 0; + let selected: { profile: Profile; score: number } | null = null; + + while (true) { + const page = await listProfilesPage(client, { limit, cursor }); + total = page.total; + scanned += page.items.length; + for (const p of page.items) { + if (!profileHasSelectors(p.selectors)) continue; const s = p.selectors; const osOk = !s.os?.length || s.os.some((c) => c.trim().toLowerCase() === os); const archOk = !s.arch?.length || s.arch.some((c) => c.trim().toLowerCase() === arch); const hostOk = !s.hostnames?.length || s.hostnames.some((c) => c.trim().toLowerCase() === host); - return osOk && archOk && hostOk; - }) - .map((p) => ({ - profile: p, - score: (p.selectors.hostnames?.length ? 100 : 0) + (p.selectors.os?.length ? 10 : 0) + (p.selectors.arch?.length ? 10 : 0), - })) - .sort((a, b) => b.score - a.score || a.profile.name.localeCompare(b.profile.name)); - return matches[0]?.profile ?? null; + if (!osOk || !archOk || !hostOk) continue; + const score = + (p.selectors.hostnames?.length ? 100 : 0) + + (p.selectors.os?.length ? 10 : 0) + + (p.selectors.arch?.length ? 10 : 0); + if ( + !selected || + score > selected.score || + (score === selected.score && p.name.localeCompare(selected.profile.name) < 0) + ) { + selected = { profile: p, score }; + } + } + if (page.complete) break; + cursor = page.next_cursor!; + } + + return { + profile: selected?.profile ?? null, + scanned, + total, + batch_limit: limit, + source_bounded: true, + complete: true, + truncated: false, + }; } // ── Machines ───────────────────────────────────────────────────────────────── diff --git a/src/types/index.ts b/src/types/index.ts index 8309fb2..3bf4290 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -194,6 +194,33 @@ export interface UpdateProfileInput { variables?: ProfileVariables; } +export interface BoundedReadOptions { + limit?: unknown; + cursor?: unknown; +} + +export interface BoundedReadPage { + items: T[]; + total: number; + limit: number; + cursor: number; + next_cursor: number | null; + has_more: boolean; + complete: boolean; + truncated: false; + source_bounded: boolean; +} + +export interface ProfileResolutionRead { + profile: Profile | null; + scanned: number | null; + total: number | null; + batch_limit: number | null; + source_bounded: boolean; + complete: true; + truncated: false; +} + // Profile ↔ Config join export interface ProfileConfig { profile_id: string;