profile-sync.ts
export type Visibility = "public" | "ai_only" | "private";
export type Field = Readonly<{ value: string; visibility: Visibility }>;
export type Profile = Readonly<{
version: number;
owner: string;
fields: Readonly<Record<string, Field>>;
}>;
export type Rejection = Readonly<{
outcome: "rejected";
reason: string;
field?: string;
}>;
export type Outcome =
| Readonly<{ outcome: "applied" | "unchanged" | "stale"; version: number }>
| Rejection;
export type UpsertItem = Readonly<
{ action: "upsert"; external_id: string } & Profile
>;
export type DeleteItem = Readonly<{
action: "delete";
external_id: string;
version: number;
}>;
export type BatchItem = UpsertItem | DeleteItem;
export type BatchResult = Readonly<{ external_id: string }> & Outcome;
export type ListedProfile = Readonly<{
external_id: string;
version: number;
deleted: boolean;
}>;
export type ProfileCopy = Readonly<{
external_id: string;
version: number;
synced_at: string;
deleted: boolean;
fields: Readonly<
Record<
string,
Readonly<{ value: string; visibility: "public" | "ai_only" }>
>
>;
private_fields: readonly string[];
unflagged_fields: readonly string[];
}>;
export type SyncClient = Readonly<{
apiUrl: string;
secretKey: string;
attempts?: number;
firstDelayMs?: number;
}>;
export class SyncError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_DELAY_MS = 30_000;
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
function addressOf(client: SyncClient, path: string): URL {
const url = new URL(path, client.apiUrl);
const local = url.protocol === "http:" && LOOPBACK.has(url.hostname);
if (url.protocol !== "https:" && !local)
throw new Error(
"The API address must start with https://, so the secret key is never sent unencrypted. Plain http works only to this machine (localhost, 127.0.0.1 or [::1]).",
);
return url;
}
function isObject(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function delay(
client: SyncClient,
attempt: number,
retryAfter: string | null,
): number {
const seconds =
retryAfter === null ? Number.NaN : Number.parseInt(retryAfter, 10);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
const base = Math.min(
MAX_DELAY_MS,
(client.firstDelayMs ?? 1000) * 2 ** (attempt - 1),
);
return base / 2 + Math.random() * (base / 2);
}
async function jsonOf(response: Response): Promise<unknown> {
const text = await response.text();
try {
const parsed: unknown = JSON.parse(text);
return parsed;
} catch {
return undefined;
}
}
type Answer = Readonly<{ status: number; body: unknown }>;
async function send(
client: SyncClient,
method: string,
path: string,
body?: unknown,
attempt = 1,
): Promise<Answer> {
const address = addressOf(client, path);
let response: Response | undefined;
try {
response = await fetch(address, {
method,
headers: {
Authorization: `Bearer ${client.secretKey}`,
Accept: "application/json",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
redirect: "manual",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch {
response = undefined;
}
if (
response !== undefined &&
response.status !== 429 &&
response.status < 500
)
return { status: response.status, body: await jsonOf(response) };
const attempts = client.attempts ?? 6;
if (attempt >= attempts) {
const what = response === undefined ? "no answer" : String(response.status);
throw new SyncError(
response?.status ?? 0,
`${method} ${path}: ${what} after ${String(attempts)} attempts. Keep the change queued and send it again later.`,
);
}
await wait(
delay(client, attempt, response?.headers.get("Retry-After") ?? null),
);
return send(client, method, path, body, attempt + 1);
}
function isAccepted(
outcome: unknown,
): outcome is "applied" | "unchanged" | "stale" {
return (
outcome === "applied" || outcome === "unchanged" || outcome === "stale"
);
}
function rejection(reason: string, field: unknown): Rejection {
return typeof field === "string"
? { outcome: "rejected", reason, field }
: { outcome: "rejected", reason };
}
const REJECTION = /^\/problems\/([a-z-]+)$/;
function outcomeOf(method: string, path: string, answer: Answer): Outcome {
const { status, body } = answer;
if (status === 200 && isObject(body)) {
const { outcome, version } = body;
if (isAccepted(outcome) && typeof version === "number")
return { outcome, version };
}
if (status === 422 && isObject(body) && typeof body["type"] === "string") {
const reason = REJECTION.exec(body["type"])?.[1];
if (reason !== undefined) return rejection(reason, body["field"]);
}
throw failure(method, path, status);
}
function failure(method: string, path: string, status: number): SyncError {
if (status === 401)
return new SyncError(
status,
`${method} ${path}: the platform refused the secret key. Check it, or rotate it in the partner console.`,
);
if (status >= 300 && status < 400)
return new SyncError(
status,
`${method} ${path}: the answer was a redirect, which this client never follows, so the secret key went nowhere else. Set the API address to the one the platform team gave you.`,
);
return new SyncError(
status,
`${method} ${path}: the platform answered ${String(status)}. Fix the request before sending it again.`,
);
}
function profilePath(externalId: string): string {
return `/v1/profiles/${encodeURIComponent(externalId)}`;
}
export async function putProfile(
client: SyncClient,
externalId: string,
profile: Profile,
): Promise<Outcome> {
const path = profilePath(externalId);
return outcomeOf("PUT", path, await send(client, "PUT", path, profile));
}
export async function deleteProfile(
client: SyncClient,
externalId: string,
version: number,
): Promise<Outcome> {
const path = `${profilePath(externalId)}?version=${String(version)}`;
return outcomeOf("DELETE", path, await send(client, "DELETE", path));
}
export const BATCH_SIZE = 100;
export async function sendBatch(
client: SyncClient,
items: readonly BatchItem[],
total?: number,
): Promise<readonly BatchResult[]> {
if (items.length === 0 || items.length > BATCH_SIZE)
throw new Error(`A batch holds 1 to ${String(BATCH_SIZE)} items.`);
const path = "/v1/profiles/batch";
const { status, body } = await send(client, "POST", path, {
items,
...(total === undefined ? {} : { total }),
});
const results = isObject(body) ? body["results"] : undefined;
if (
status !== 200 ||
!Array.isArray(results) ||
results.length !== items.length
)
throw failure("POST", path, status);
return items.map((item, index) => {
const result: unknown = results[index];
if (!isObject(result)) throw failure("POST", path, status);
const { outcome, version, reason, field } = result;
if (isAccepted(outcome) && typeof version === "number")
return { external_id: item.external_id, outcome, version };
if (outcome === "rejected" && typeof reason === "string")
return { external_id: item.external_id, ...rejection(reason, field) };
throw failure("POST", path, status);
});
}
async function* batchesOf(
client: SyncClient,
items: readonly BatchItem[],
total: number | undefined,
): AsyncGenerator<readonly BatchResult[]> {
for (let start = 0; start < items.length; start += BATCH_SIZE)
yield sendBatch(client, items.slice(start, start + BATCH_SIZE), total);
}
export async function sendInBatches(
client: SyncClient,
items: readonly BatchItem[],
total?: number,
): Promise<readonly BatchResult[]> {
const results: BatchResult[] = [];
for await (const batch of batchesOf(client, items, total))
for (const result of batch) results.push(result);
return results;
}
async function readPage(
client: SyncClient,
pageSize: number,
after: string | undefined,
): Promise<Readonly<{ profiles: readonly ListedProfile[]; next?: string }>> {
const query = new URLSearchParams({
limit: String(pageSize),
...(after === undefined ? {} : { after }),
});
const path = `/v1/profiles?${query.toString()}`;
const { status, body } = await send(client, "GET", path);
const listed = isObject(body) ? body["profiles"] : undefined;
if (status !== 200 || !isObject(body) || !Array.isArray(listed))
throw failure("GET", path, status);
const profiles = listed.flatMap((profile: unknown) => {
if (!isObject(profile)) return [];
const { external_id, version, deleted } = profile;
return typeof external_id === "string" &&
typeof version === "number" &&
typeof deleted === "boolean"
? [{ external_id, version, deleted }]
: [];
});
const next = body["next"];
return typeof next === "string" ? { profiles, next } : { profiles };
}
export function pagesOf(
client: SyncClient,
pageSize = 1000,
): AsyncIterable<readonly ListedProfile[]> {
return {
[Symbol.asyncIterator]: () => {
let after: string | undefined;
let finished = false;
return {
next: async (): Promise<
IteratorResult<readonly ListedProfile[], undefined>
> => {
if (finished) return { done: true, value: undefined };
const page = await readPage(client, pageSize, after);
after = page.next;
finished = after === undefined;
return { done: false, value: page.profiles };
},
};
},
};
}
export async function listProfiles(
client: SyncClient,
pageSize = 1000,
): Promise<readonly ListedProfile[]> {
const profiles: ListedProfile[] = [];
for await (const page of pagesOf(client, pageSize))
for (const profile of page) profiles.push(profile);
return profiles;
}
export function reconcile(
site: readonly UpsertItem[],
held: readonly ListedProfile[],
): Readonly<{ behind: readonly UpsertItem[]; gone: readonly ListedProfile[] }> {
const platform = new Map(
held.map((profile) => [profile.external_id, profile]),
);
const listed = new Set(site.map((item) => item.external_id));
return {
behind: site.filter((item) => {
const copy = platform.get(item.external_id);
return copy === undefined || copy.version < item.version;
}),
gone: held.filter(
(profile) => !profile.deleted && !listed.has(profile.external_id),
),
};
}
export async function getProfile(
client: SyncClient,
externalId: string,
): Promise<ProfileCopy | undefined> {
const path = profilePath(externalId);
const { status, body } = await send(client, "GET", path);
if (status === 404) return undefined;
if (status !== 200 || !isObject(body)) throw failure("GET", path, status);
const {
external_id,
version,
synced_at,
deleted,
fields,
private_fields,
unflagged_fields,
} = body;
if (
typeof external_id !== "string" ||
typeof version !== "number" ||
typeof synced_at !== "string" ||
typeof deleted !== "boolean" ||
!isObject(fields) ||
!Array.isArray(private_fields) ||
!Array.isArray(unflagged_fields)
) {
throw failure("GET", path, status);
}
const kept: Record<
string,
Readonly<{ value: string; visibility: "public" | "ai_only" }>
> = {};
for (const [name, copied] of Object.entries(fields)) {
if (!isObject(copied)) continue;
const { value, visibility } = copied;
if (
typeof value === "string" &&
(visibility === "public" || visibility === "ai_only")
)
kept[name] = { value, visibility };
}
return {
external_id,
version,
synced_at,
deleted,
fields: kept,
private_fields: strings(private_fields),
unflagged_fields: strings(unflagged_fields),
};
}
function strings(list: readonly unknown[]): readonly string[] {
return list.filter((item): item is string => typeof item === "string");
}