chore(ts-migration): migrate API transport to TypeScript

Phase 4a: `demoUser` and `httpClient`. Type-only. The production bundle is
byte-identical - built from this commit's parent and from this commit, all six
chunk hashes match.

`httpClient` is the contract boundary, so the annotations are deliberately
conservative:

  - `KrowApiError` is a CAST, not a class. The three error sites still build a
    plain `Error` and assign `.name`, `.status`, `.code` and `.details` onto it
    exactly as before. `class KrowApiError extends Error` would have read
    better and changed three things that callers depend on: the prototype
    chain, `instanceof`, and how `name` comes to be set.

  - `RESOURCE_PATHS` becomes `Record<EntityName, EntityResourcePath>`. That is
    the first thing in the repo to check the eighteen entity names against the
    eighteen in `ENTITY_NAMES`; until now the two lists agreed only by habit,
    and a divergence would have surfaced as `base44.entities.Whatever` being
    undefined with nothing to say why.

  - `createEntity`'s parameter stays `string` and its return stays inferred.
    Annotating the return `EntityClient<any>` was tried and reverted: it makes
    `list()` return `Promise<any[]>` where inference gives `Promise<any>`, and
    `agentStore.js` does `qc.getQueryData(KEY) || (await ...list(...))`. React
    Query types `getQueryData` as `unknown`; `unknown || any` collapses to
    `any`, `unknown || any[]` stays `unknown`, and `.find` on the next line
    stopped compiling. Two new errors in a file this phase does not migrate,
    for no gain. `EntityClientFor<K>` is ready for the phase that migrates
    those consumers.

Unchanged and verified in the emitted output: `credentials: 'include'`, both
header branches on `body === undefined`, the URLSearchParams query encoding with
its repeated-array and undefined-omission rules, the `payload.data` unwrap that
drops `meta`, the verbatim server message, `status: 0` / `code: 'unreachable'`
for a transport failure, the `-created_date` and `limit` defaults, path
construction through `encodeURIComponent`, and `bulkCreate`'s sequential
`this.create` loop.

`DEMO_USER` is annotated `User`, which does real work: unannotated,
`role: 'admin'` widens to `string` and the default shape did not satisfy the
type the app uses for the thing it defaults.

Verified in isolation from the parallel feature work (d1425f9 plus these two
files): tsc 64 -> 53, the eleven removed being exactly this file's, and a
set-difference against the baseline showing none added. skill-check 1641/1642
with only the known stale-fixture failure, Owliver baseline 59/59, lint 0
errors, build succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-17 12:16:16 +05:30
parent d1425f974c
commit f96f128839
2 changed files with 53 additions and 13 deletions

View File

@@ -1,3 +1,5 @@
import type { User } from '@/types/user';
/**
* The shape of a signed-in user, before the server has answered.
*
@@ -22,7 +24,7 @@
* the server has never stored still has to resolve to something rather than to
* `undefined`.
*/
export const DEMO_USER = {
export const DEMO_USER: User = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',

View File

@@ -27,6 +27,9 @@
* did not answer.
*/
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
import type { EntityName, EntityResourcePath } from '@/types/entities';
/**
* Where the API lives.
*
@@ -93,7 +96,13 @@ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
* `user-activity` is three special cases wearing a trench coat, and a wrong
* guess here is a 404 at runtime instead of a mistake anyone can see.
*/
const RESOURCE_PATHS = {
/* `Record<EntityName, EntityResourcePath>` is doing two checks at once, and
both were previously unavailable. Every one of the eighteen names must be
present as a key — so this map and `ENTITY_NAMES` in `base44Client.js` can no
longer drift apart silently — and every value must be one of the declared
paths, so a typo in a resource segment is a compile error rather than a 404
at run time. The object itself is unchanged, key for key. */
const RESOURCE_PATHS: Record<EntityName, EntityResourcePath> = {
JobPosting: 'job-postings',
JobApplication: 'job-applications',
AIInterview: 'ai-interviews',
@@ -128,7 +137,7 @@ const RESOURCE_PATHS = {
* `got === undefined` against real values and matched nothing, and sending the
* string "undefined" would be a filter on a value no column holds.
*/
function queryString(params) {
function queryString(params: Record<string, unknown>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
@@ -153,9 +162,14 @@ function queryString(params) {
* ride along as properties — new information a local store never had, and
* additive, so nothing that only reads `.message` notices.
*/
function apiError(status, payload) {
function apiError(status: number, payload: ApiErrorResponse | null): KrowApiError {
const body = payload?.error;
const error = new Error(body?.message || `Request failed with status ${status}`);
/* Cast, not `class KrowApiError extends Error`. The properties below are
assigned onto a plain Error exactly as they always were, so the prototype
chain, `instanceof Error`, and the `error.name === 'KrowApiError'` test
callers use all behave identically. A subclass would change all three, and
the emitted JavaScript with it. */
const error = new Error(body?.message || `Request failed with status ${status}`) as KrowApiError;
error.name = 'KrowApiError';
error.status = status;
error.code = body?.code || 'internal';
@@ -171,10 +185,14 @@ function apiError(status, payload) {
* reads it (§4.2), and surfacing it would mean changing what the six methods
* return, which is the one thing Phase 2D must not do.
*/
async function request(method, path, { query, body } = {}) {
async function request(
method: string,
path: string,
{ query, body }: { query?: Record<string, unknown>; body?: unknown } = {},
) {
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
let response;
let response: Response;
try {
response = await fetch(url, {
method,
@@ -199,7 +217,7 @@ async function request(method, path, { query, body } = {}) {
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
`(${method} ${path})`
);
) as KrowApiError;
error.name = 'KrowApiError';
error.status = 0;
error.code = 'unreachable';
@@ -221,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
if (!response.ok) throw apiError(response.status, payload);
if (payload === null) {
const error = new Error(`${method} ${path} returned no JSON body`);
const error = new Error(`${method} ${path} returned no JSON body`) as KrowApiError;
error.name = 'KrowApiError';
error.status = response.status;
error.code = 'internal';
@@ -242,7 +260,7 @@ export { request };
* before the first login and after a session expires — so callers need to tell
* it apart from a real failure rather than treating both as "something broke".
*/
export function isUnauthenticated(error) {
export function isUnauthenticated(error: any) {
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
}
@@ -256,13 +274,33 @@ export function isUnauthenticated(error) {
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
* them rather than passing their own.
*/
export function createEntity(name) {
const path = RESOURCE_PATHS[name];
export function createEntity(name: string) {
/* `name` stays `string`, and the cast below is deliberate. Typing the
parameter as `EntityName` would be tighter and would immediately break the
one caller — `base44Client.js` builds the entity map by mapping over a
plain string array — which is a file this phase does not touch.
The return type is left inferred, and that is a decision rather than an
omission. Annotating it `EntityClient<any>` was tried and reverted: it
makes `list()` return `Promise<any[]>` where inference gives
`Promise<any>`, and two call sites in `src/lib/agents/agentStore.js` do
const rows = qc.getQueryData(KEY) || (await …list('-created_date', 200));
React Query types `getQueryData` as `unknown`, and `unknown || any`
collapses to `any` while `unknown || any[]` stays `unknown` — so `.find`
on the next line stopped compiling. Two new errors in a file this phase is
not migrating, for no gain: the surface is structurally identical either
way, and the emitted JavaScript is byte-for-byte the same.
`EntityClientFor<K>` and the generated record types are ready for the phase
that migrates those consumers. Nothing here has to change then. */
const path = RESOURCE_PATHS[name as EntityName];
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
const base = `/${path}`;
return {
entityName: name,
entityName: name as EntityName,
/**
* `sort` is always sent, even when empty. `?sort=` is not the same as