first commit

This commit is contained in:
2026-06-09 18:35:55 -03:00
commit 3581e221d0
448 changed files with 519709 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { Clarizen, ClarizenApiError } from "@arco/clarizen-lib";
export function getApiKey(): string | undefined {
return process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
}
export function createClarizenClient(): Clarizen {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"Defina CLARIZEN_API_KEY ou API_KEY_AW no arquivo .env na raiz do projeto.",
);
}
return new Clarizen({
baseUrl: process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com",
apiToken: apiKey,
});
}
export async function checkClarizenAuth(
clarizen: Clarizen,
): Promise<
{ ok: true; userId: string } | { ok: false; message: string; status?: number }
> {
try {
const session = await clarizen.authentication.getSessionInfo();
return { ok: true, userId: session.userId };
} catch (err) {
if (err instanceof ClarizenApiError) {
return {
ok: false,
message: err.message,
status: err.status === 401 || err.status === 403 ? err.status : 502,
};
}
const message = err instanceof Error ? err.message : "Falha de autenticação";
return { ok: false, message, status: 502 };
}
}
+176
View File
@@ -0,0 +1,176 @@
import "./load-env.js";
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import {
cancel,
confirm,
intro,
isCancel,
outro,
select,
spinner,
text,
} from "@clack/prompts";
import {
checkClarizenAuth,
createClarizenClient,
} from "./clarizen/client.js";
import { formatFieldHint } from "./metadata/field-hints.js";
import { getEntityMetadata, pickEntityType, pickField } from "./metadata/picker.js";
import {
formatValueForDisplay,
parseFieldValue,
ValueParseError,
} from "./parse/value.js";
import { formatFilterSummary } from "./query/filter.js";
import { fetchAllEntities } from "./query/fetch-all.js";
import { promptEntityFilter } from "./query/prompt-filter.js";
import { bulkUpdateField } from "./update/bulk-field.js";
import type { FieldDescription, JsonValue } from "@arco/clarizen-lib";
function exitCancel(): never {
cancel("Operação cancelada.");
process.exit(0);
}
async function promptFieldValue(
field: FieldDescription,
): Promise<JsonValue | symbol> {
console.log("\n" + formatFieldHint(field) + "\n");
if (field.nullable) {
const action = await select({
message: "O que deseja fazer com este campo?",
options: [
{ value: "set", label: "Definir um valor" },
{ value: "clear", label: "Limpar campo (null)" },
],
});
if (isCancel(action)) return action;
if (action === "clear") return null;
}
while (true) {
const raw = await text({
message: "Informe o novo valor",
validate: (value) => {
if (!field.nullable && !value?.trim()) {
return "Valor obrigatório.";
}
return undefined;
},
});
if (isCancel(raw)) return raw;
try {
return parseFieldValue(raw, field);
} catch (err) {
if (err instanceof ValueParseError) {
console.error(`\n Erro: ${err.message}\n`);
continue;
}
throw err;
}
}
}
async function main(): Promise<void> {
intro("Atualização em massa de campo — Clarizen");
const clarizen = createClarizenClient();
const authSpinner = spinner();
authSpinner.start("Autenticando...");
const auth = await checkClarizenAuth(clarizen);
if (!auth.ok) {
authSpinner.stop(`Falha na autenticação: ${auth.message}`);
process.exit(1);
}
authSpinner.stop(`Autenticado (usuário: ${auth.userId})`);
const entityChoice = await pickEntityType(clarizen);
if (isCancel(entityChoice)) exitCancel();
const entityMeta = await getEntityMetadata(clarizen, entityChoice.typeName);
const entityFilter = await promptEntityFilter(
clarizen,
entityChoice.typeName,
entityMeta,
);
if (isCancel(entityFilter)) exitCancel();
const fieldChoice = await pickField(clarizen, entityChoice.typeName);
if (isCancel(fieldChoice)) exitCancel();
const { field } = fieldChoice;
const fieldName = field.name!;
const parsedValue = await promptFieldValue(field);
if (isCancel(parsedValue)) exitCancel();
const fetchSpinner = spinner();
fetchSpinner.start("Buscando objetos...");
const records = await fetchAllEntities(
clarizen,
entityChoice.typeName,
entityMeta,
entityFilter,
(count) => {
fetchSpinner.message(`Buscando objetos... (${count} encontrados)`);
},
);
fetchSpinner.stop(`${records.length} objeto(s) encontrado(s).`);
if (records.length === 0) {
outro("Nenhum objeto encontrado com esse filtro.");
return;
}
const entityLabel = entityChoice.label
? `${entityChoice.typeName} (${entityChoice.label})`
: entityChoice.typeName;
console.log(`
Resumo da operação:
Tipo: ${entityLabel}
Filtro: ${formatFilterSummary(entityFilter)}
Campo: ${field.label ?? fieldName} (${fieldName}) — ${field.type ?? "?"}
Valor: ${formatValueForDisplay(parsedValue)}
Objetos: ${records.length.toLocaleString("pt-BR")} registro(s)
`);
const proceed = await confirm({
message: `Confirma a atualização de ${records.length.toLocaleString("pt-BR")} objeto(s)?`,
initialValue: false,
});
if (isCancel(proceed) || !proceed) exitCancel();
console.log("\nAtualizando...\n");
const result = await bulkUpdateField(
clarizen,
entityChoice.typeName,
records,
fieldName,
parsedValue,
);
if (result.errors.length > 0) {
const errorFile = resolve(
process.cwd(),
`errors-${new Date().toISOString().replace(/[:.]/g, "-")}.json`,
);
writeFileSync(errorFile, JSON.stringify(result.errors, null, 2), "utf-8");
console.log(`\n${result.errors.length} erro(s) — detalhes em: ${errorFile}`);
}
outro(
`Concluído: ${result.updated.toLocaleString("pt-BR")} atualizado(s), ${result.errors.length.toLocaleString("pt-BR")} erro(s).`,
);
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
+16
View File
@@ -0,0 +1,16 @@
import { config } from "dotenv";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
for (const envPath of [
resolve(rootDir, ".env"),
resolve(rootDir, "../clarizen-lib/.env"),
]) {
if (existsSync(envPath)) {
config({ path: envPath });
break;
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { FieldDescription, FieldType } from "@arco/clarizen-lib";
const TYPE_EXAMPLES: Record<FieldType, string> = {
Boolean: "true, false, sim, não",
String: 'texto livre, ex: "Meu valor"',
Integer: "número inteiro, ex: 42",
Long: "número inteiro, ex: 42",
Double: "número decimal, ex: 3.14",
DateTime: "2026-06-09 ou 2026-06-09T14:30:00Z",
Date: "2026-06-09",
Entity: 'ID da entidade, ex: /User/abc-123 ou {"id":"/User/abc-123"}',
Duration: '{"value":5,"unit":"Days"}',
Money: '{"value":100,"currency":"BRL"}',
};
export function getFieldTypeExample(type: FieldType | undefined): string {
if (!type) return "valor conforme o tipo do campo";
return TYPE_EXAMPLES[type] ?? type;
}
export function formatFieldHint(field: FieldDescription): string {
const type = field.type ?? "desconhecido";
const example = getFieldTypeExample(field.type);
const lines = [
`Tipo: ${type}`,
`Exemplo: ${example}`,
];
if (field.nullable) {
lines.push("Este campo aceita valor vazio (null) para limpar.");
}
if (field.maxLength) {
lines.push(`Tamanho máximo: ${field.maxLength} caracteres`);
}
return lines.join("\n");
}
export function formatFieldOptionLabel(field: FieldDescription): string {
const label = field.label?.trim() || field.name || "?";
const name = field.name ?? "?";
const type = field.type ?? "?";
return `${label} (${name}) — ${type}`;
}
export function isUpdatableField(field: FieldDescription): boolean {
if (!field.name?.trim()) return false;
if (field.updateable === false) return false;
if (field.calculated === true) return false;
if (field.createOnly === true) return false;
if (field.internal === true) return false;
return true;
}
+186
View File
@@ -0,0 +1,186 @@
import type { Clarizen, EntityDescription, FieldDescription } from "@arco/clarizen-lib";
import { isCancel, select, text } from "@clack/prompts";
import {
formatFieldOptionLabel,
isUpdatableField,
} from "./field-hints.js";
export interface EntityChoice {
typeName: string;
label?: string;
}
export interface FieldChoice {
field: FieldDescription;
entity: EntityDescription;
}
async function describeEntity(
clarizen: Clarizen,
typeName: string,
): Promise<EntityDescription | undefined> {
const result = await clarizen.metadata.describeMetadata({
typeNames: [typeName],
flags: ["fields"],
});
return result.entityDescriptions?.find((e) => e.typeName === typeName);
}
export async function listEntityChoices(
clarizen: Clarizen,
): Promise<EntityChoice[]> {
const { typeNames } = await clarizen.metadata.listEntities();
const sorted = [...typeNames].sort((a, b) => a.localeCompare(b, "pt-BR"));
return sorted.map((typeName) => ({ typeName }));
}
function filterOptions<T extends { value: string; label: string }>(
allOptions: T[],
term: string,
): T[] {
const normalized = term.trim().toLowerCase();
if (!normalized) return allOptions;
return allOptions.filter(
(o) =>
o.label.toLowerCase().includes(normalized) ||
o.value.toLowerCase().includes(normalized),
);
}
async function pickFromList<T extends { value: string; label: string }>(
message: string,
options: T[],
): Promise<string | symbol> {
if (options.length === 0) {
throw new Error("Nenhuma opção disponível.");
}
if (options.length === 1) {
return options[0]!.value;
}
return select({ message, options });
}
async function pickFromFiltered<T extends { value: string; label: string }>(
message: string,
allOptions: T[],
filterPrompt: string,
): Promise<string | symbol> {
if (allOptions.length === 0) {
throw new Error("Nenhuma opção disponível.");
}
const mode = await select({
message: filterPrompt,
options: [
{ value: "all", label: "Ver lista completa" },
{ value: "filter", label: "Filtrar por texto" },
],
});
if (isCancel(mode)) return mode;
let options = allOptions;
if (mode === "filter") {
const filter = await text({
message: "Digite parte do nome para filtrar",
});
if (isCancel(filter)) return filter;
options = filterOptions(allOptions, filter);
if (options.length === 0) {
console.log("Nenhum resultado para esse filtro. Tente outro termo.\n");
return pickFromFiltered(message, allOptions, filterPrompt);
}
}
return pickFromList(message, options);
}
export async function pickEntityType(
clarizen: Clarizen,
): Promise<EntityChoice | symbol> {
const choices = await listEntityChoices(clarizen);
const options = choices.map((c) => ({
value: c.typeName,
label: c.typeName,
}));
const selected = await pickFromFiltered(
"Escolha o tipo de entidade (nível)",
options,
"Filtrar tipos de entidade",
);
if (isCancel(selected)) return selected;
const entity = await describeEntity(clarizen, selected);
return {
typeName: selected,
label: entity?.label,
};
}
export async function getUpdatableFields(
clarizen: Clarizen,
typeName: string,
): Promise<FieldChoice[]> {
const entity = await describeEntity(clarizen, typeName);
if (!entity) {
throw new Error(`Metadata não encontrada para ${typeName}.`);
}
const fields = (entity.fields ?? [])
.filter(isUpdatableField)
.sort((a, b) => {
const labelA = a.label ?? a.name ?? "";
const labelB = b.label ?? b.name ?? "";
return labelA.localeCompare(labelB, "pt-BR");
});
return fields.map((field) => ({ field, entity }));
}
export async function pickField(
clarizen: Clarizen,
typeName: string,
): Promise<FieldChoice | symbol> {
const fieldChoices = await getUpdatableFields(clarizen, typeName);
if (fieldChoices.length === 0) {
throw new Error(`Nenhum campo atualizável encontrado para ${typeName}.`);
}
const options = fieldChoices.map(({ field }) => ({
value: field.name!,
label: formatFieldOptionLabel(field),
}));
const selected = await pickFromFiltered(
"Escolha o campo a atualizar",
options,
"Filtrar campos",
);
if (isCancel(selected)) return selected;
const match = fieldChoices.find((c) => c.field.name === selected);
if (!match) {
throw new Error(`Campo não encontrado: ${selected}`);
}
return match;
}
export async function getEntityMetadata(
clarizen: Clarizen,
typeName: string,
): Promise<EntityDescription> {
const entity = await describeEntity(clarizen, typeName);
if (!entity) {
throw new Error(`Metadata não encontrada para ${typeName}.`);
}
return entity;
}
+118
View File
@@ -0,0 +1,118 @@
import type { FieldDescription, FieldType, JsonValue } from "@arco/clarizen-lib";
import { getFieldTypeExample } from "../metadata/field-hints.js";
export class ValueParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ValueParseError";
}
}
const BOOLEAN_TRUE = new Set(["true", "1", "sim", "s", "yes", "y"]);
const BOOLEAN_FALSE = new Set(["false", "0", "não", "nao", "n", "no"]);
function parseBoolean(raw: string): boolean {
const normalized = raw.trim().toLowerCase();
if (BOOLEAN_TRUE.has(normalized)) return true;
if (BOOLEAN_FALSE.has(normalized)) return false;
throw new ValueParseError(
`Valor booleano inválido: "${raw}". Use: ${getFieldTypeExample("Boolean")}`,
);
}
function parseNumber(raw: string, integer: boolean): number {
const trimmed = raw.trim();
const value = Number(trimmed);
if (!Number.isFinite(value)) {
throw new ValueParseError(`Número inválido: "${raw}"`);
}
if (integer && !Number.isInteger(value)) {
throw new ValueParseError(`Esperado número inteiro: "${raw}"`);
}
return value;
}
function parseEntity(raw: string): JsonValue {
const trimmed = raw.trim();
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof parsed.id === "string") {
return { id: parsed.id };
}
throw new ValueParseError('JSON de Entity deve conter "id".');
} catch (err) {
if (err instanceof ValueParseError) throw err;
throw new ValueParseError(`JSON inválido para Entity: "${raw}"`);
}
}
if (trimmed.startsWith("/")) {
return { id: trimmed };
}
throw new ValueParseError(
`Referência de Entity inválida: "${raw}". Use /Tipo/id ou {"id":"/Tipo/id"}`,
);
}
function parseJsonObject(raw: string, typeName: string): JsonValue {
try {
return JSON.parse(raw.trim()) as JsonValue;
} catch {
throw new ValueParseError(
`JSON inválido para ${typeName}: "${raw}". Exemplo: ${getFieldTypeExample(typeName as FieldType)}`,
);
}
}
function parseByType(raw: string, type: FieldType | undefined): JsonValue {
const trimmed = raw.trim();
switch (type) {
case "Boolean":
return parseBoolean(trimmed);
case "String":
return trimmed;
case "Integer":
case "Long":
return parseNumber(trimmed, true);
case "Double":
return parseNumber(trimmed, false);
case "DateTime":
case "Date":
return trimmed;
case "Entity":
return parseEntity(trimmed);
case "Duration":
case "Money":
return parseJsonObject(trimmed, type);
default:
return trimmed;
}
}
export function parseFieldValue(
raw: string,
field: FieldDescription,
): JsonValue {
const trimmed = raw.trim();
if (trimmed === "" && field.nullable) {
return null;
}
if (trimmed.toLowerCase() === "null" && field.nullable) {
return null;
}
if (trimmed === "") {
throw new ValueParseError("Valor não pode ser vazio.");
}
return parseByType(raw, field.type);
}
export function formatValueForDisplay(value: JsonValue): string {
if (value === null) return "(vazio / null)";
if (typeof value === "string") return `"${value}"`;
return JSON.stringify(value);
}
+65
View File
@@ -0,0 +1,65 @@
import type { Clarizen, EntityDescription, EntityId } from "@arco/clarizen-lib";
import { buildWhereCondition, type EntityFilter } from "./filter.js";
import { resolveDisplayField } from "./resolve-display-field.js";
export interface EntityRecord {
id: EntityId;
displayName?: string;
}
const PAGE_SIZE = 5000;
export async function fetchAllEntities(
clarizen: Clarizen,
typeName: string,
entityMeta: EntityDescription,
filter: EntityFilter = { kind: "all" },
onProgress?: (count: number) => void,
): Promise<EntityRecord[]> {
const displayField = resolveDisplayField(entityMeta);
const where = buildWhereCondition(filter);
const records: EntityRecord[] = [];
let from = 0;
while (true) {
const result = await clarizen.data.entityQuery({
typeName,
fields: [displayField],
...(where ? { where } : {}),
paging: { from, limit: PAGE_SIZE },
});
for (const entity of result.entities) {
if (!entity.id) continue;
const displayName = extractDisplayName(
entity as Record<string, unknown>,
displayField,
);
records.push({ id: entity.id, displayName });
}
onProgress?.(records.length);
if (!result.paging?.hasMore) break;
from += PAGE_SIZE;
}
return records;
}
function extractDisplayName(
entity: Record<string, unknown>,
displayField: string,
): string | undefined {
const objectFields = entity.objectFields as Record<string, unknown> | undefined;
const value = entity[displayField] ?? objectFields?.[displayField];
if (value === null || value === undefined) return undefined;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (typeof value === "object" && value !== null && "name" in value) {
const name = (value as { name?: unknown }).name;
if (name !== null && name !== undefined) return String(name);
}
return undefined;
}
+37
View File
@@ -0,0 +1,37 @@
import type { Condition } from "@arco/clarizen-lib";
export type EntityFilter =
| { kind: "all" }
| {
kind: "state";
fieldName: string;
stateValue: string;
stateLabel: string;
}
| { kind: "czql"; whereClause: string };
export function formatFilterSummary(filter: EntityFilter): string {
switch (filter.kind) {
case "all":
return "Todos";
case "state":
return `${filter.fieldName} = ${filter.stateLabel}`;
case "czql":
return `WHERE ${filter.whereClause}`;
}
}
export function buildWhereCondition(filter: EntityFilter): Condition | undefined {
switch (filter.kind) {
case "all":
return undefined;
case "state":
return {
leftExpression: { fieldName: filter.fieldName },
operator: "Equal",
rightExpression: { value: filter.stateValue },
};
case "czql":
return { text: filter.whereClause };
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { Clarizen, EntityDescription } from "@arco/clarizen-lib";
import { ClarizenApiError } from "@arco/clarizen-lib";
import { isCancel, select, text } from "@clack/prompts";
import type { EntityFilter } from "./filter.js";
import { buildWhereCondition } from "./filter.js";
import { detectStateFilterCapability } from "./state-field.js";
import { resolveDisplayField } from "./resolve-display-field.js";
const BLOCKED_CZQL = /\b(DROP|DELETE|UPDATE|INSERT|ALTER|TRUNCATE)\b/i;
function validateWhereSyntax(whereClause: string): string | undefined {
const trimmed = whereClause.trim();
if (!trimmed) return "A cláusula WHERE não pode ser vazia.";
if (trimmed.includes(";")) return "Não use ponto e vírgula na cláusula WHERE.";
if (BLOCKED_CZQL.test(trimmed)) {
return "A cláusula WHERE não pode conter comandos de modificação.";
}
return undefined;
}
async function validateWhereAgainstApi(
clarizen: Clarizen,
typeName: string,
entityMeta: EntityDescription,
whereClause: string,
): Promise<string | undefined> {
const where = buildWhereCondition({ kind: "czql", whereClause });
const displayField = resolveDisplayField(entityMeta);
try {
await clarizen.data.entityQuery({
typeName,
fields: [displayField],
where,
paging: { limit: 1, from: 0 },
});
return undefined;
} catch (err) {
if (err instanceof ClarizenApiError) {
return err.message;
}
return err instanceof Error ? err.message : "Cláusula WHERE inválida.";
}
}
async function promptCzqlWhere(
clarizen: Clarizen,
typeName: string,
entityMeta: EntityDescription,
): Promise<EntityFilter | symbol> {
const displayField = resolveDisplayField(entityMeta);
console.log(`
Exemplo: State = 'Active' AND Name LIKE 'Projeto%'
O app aplicará: SELECT ${displayField} FROM ${typeName} WHERE <sua cláusula>
`);
while (true) {
const whereClause = await text({
message: "Informe a cláusula WHERE (CZQL)",
validate: validateWhereSyntax,
});
if (isCancel(whereClause)) return whereClause;
const apiError = await validateWhereAgainstApi(
clarizen,
typeName,
entityMeta,
whereClause.trim(),
);
if (apiError) {
console.error(`\n Erro: ${apiError}\n`);
continue;
}
return { kind: "czql", whereClause: whereClause.trim() };
}
}
async function promptStateFilter(
capability: NonNullable<ReturnType<typeof detectStateFilterCapability>>,
): Promise<EntityFilter | symbol> {
const selected = await select({
message: `Escolha o State (${capability.fieldName})`,
options: capability.states.map((s) => ({
value: s.value,
label: s.label,
})),
});
if (isCancel(selected)) return selected;
const match = capability.states.find((s) => s.value === selected);
return {
kind: "state",
fieldName: capability.fieldName,
stateValue: selected,
stateLabel: match?.label ?? selected,
};
}
export async function promptEntityFilter(
clarizen: Clarizen,
typeName: string,
entityMeta: EntityDescription,
): Promise<EntityFilter | symbol> {
const stateCapability = detectStateFilterCapability(entityMeta);
const options: Array<{ value: string; label: string }> = [
{ value: "all", label: "Todos os objetos" },
];
if (stateCapability) {
options.push({
value: "state",
label: `Filtrar por State (${stateCapability.fieldName})`,
});
}
options.push({
value: "czql",
label: "Filtro customizado (CZQL WHERE)",
});
const mode = await select({
message: "Como deseja filtrar os objetos?",
options,
});
if (isCancel(mode)) return mode;
switch (mode) {
case "all":
return { kind: "all" };
case "state":
if (!stateCapability) {
throw new Error("Este tipo não possui filtro por State.");
}
return promptStateFilter(stateCapability);
case "czql":
return promptCzqlWhere(clarizen, typeName, entityMeta);
default:
return { kind: "all" };
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { EntityDescription } from "@arco/clarizen-lib";
const FALLBACK_FIELDS = ["Name", "Title", "Subject", "Description"] as const;
const NON_QUERYABLE = new Set(["id", "Id", "ID"]);
export function resolveDisplayField(entity: EntityDescription): string {
const fieldNames = new Set(
(entity.fields ?? [])
.map((f) => f.name)
.filter((name): name is string => Boolean(name?.trim())),
);
const candidates = [
entity.displayField,
...FALLBACK_FIELDS,
].filter((name): name is string => Boolean(name?.trim()));
for (const candidate of candidates) {
if (NON_QUERYABLE.has(candidate)) continue;
if (fieldNames.size === 0 || fieldNames.has(candidate)) {
return candidate;
}
}
return "Name";
}
+54
View File
@@ -0,0 +1,54 @@
import type { EntityDescription } from "@arco/clarizen-lib";
export interface StateFilterCapability {
fieldName: string;
states: Array<{ value: string; label: string }>;
}
function stateLabel(value: string): string {
if (value.includes("/")) {
return value.split("/").pop() ?? value;
}
return value;
}
function resolveStateFieldName(entity: EntityDescription): string | undefined {
const typeName = entity.typeName ?? "";
const fields = entity.fields ?? [];
const byName = new Map(
fields
.filter((f) => f.name?.trim())
.map((f) => [f.name!, f] as const),
);
if (byName.has("State")) return "State";
const typeState = `${typeName}State`;
if (byName.has(typeState)) return typeState;
const stateField = fields.find(
(f) =>
f.name?.endsWith("State") &&
f.type === "Entity" &&
f.filterable !== false,
);
return stateField?.name;
}
export function detectStateFilterCapability(
entity: EntityDescription,
): StateFilterCapability | null {
const validStates = entity.validStates?.filter((s) => s.trim()) ?? [];
if (validStates.length === 0) return null;
const fieldName = resolveStateFieldName(entity);
if (!fieldName) return null;
return {
fieldName,
states: validStates.map((value) => ({
value,
label: stateLabel(value),
})),
};
}
+194
View File
@@ -0,0 +1,194 @@
import {
ClarizenApiError,
entityObjectPath,
type Clarizen,
type EntityId,
type JsonValue,
} from "@arco/clarizen-lib";
import type { EntityRecord } from "../query/fetch-all.js";
import { ProgressReporter } from "./progress.js";
const BATCH_SIZE = 100;
export interface UpdateError {
id: EntityId;
displayName?: string;
message: string;
errorCode?: string;
}
export interface BulkUpdateResult {
updated: number;
errors: UpdateError[];
}
interface BulkResponse {
responses?: Array<{
statusCode?: number;
body?: unknown;
}>;
}
function shortId(id: EntityId): string {
const str = String(id);
return str.includes("/") ? str.split("/").pop()! : str;
}
function buildUpdateBody(fieldName: string, value: JsonValue): string {
return JSON.stringify({ [fieldName]: value });
}
function extractErrorMessage(body: unknown): { message: string; errorCode?: string } {
if (body && typeof body === "object") {
const obj = body as Record<string, unknown>;
const message =
typeof obj.message === "string" ? obj.message : JSON.stringify(body);
const errorCode =
typeof obj.errorCode === "string" ? obj.errorCode : undefined;
return { message, errorCode };
}
return { message: String(body) };
}
async function updateSingle(
clarizen: Clarizen,
typeName: string,
id: EntityId,
fieldName: string,
value: JsonValue,
): Promise<void> {
await clarizen.objects.update(typeName, id, { [fieldName]: value });
}
async function updateBatchBulk(
clarizen: Clarizen,
typeName: string,
batch: EntityRecord[],
fieldName: string,
value: JsonValue,
): Promise<Array<{ record: EntityRecord; error?: UpdateError }>> {
const requests = batch.map((record) => ({
url: entityObjectPath(typeName, shortId(record.id)).replace(
/^\/V2\.0\/services/,
"",
),
method: "POST" as const,
body: buildUpdateBody(fieldName, value),
}));
try {
const result = (await clarizen.bulk.execute({
requests,
})) as BulkResponse;
const responses = result.responses ?? [];
return batch.map((record, index) => {
const response = responses[index];
if (!response || response.statusCode === 200) {
return { record };
}
const { message, errorCode } = extractErrorMessage(response.body);
return {
record,
error: {
id: record.id,
displayName: record.displayName,
message,
errorCode,
},
};
});
} catch (err) {
const message =
err instanceof ClarizenApiError
? err.message
: err instanceof Error
? err.message
: "Falha no lote bulk";
return batch.map((record) => ({
record,
error: {
id: record.id,
displayName: record.displayName,
message,
errorCode:
err instanceof ClarizenApiError ? String(err.errorCode) : undefined,
},
}));
}
}
async function retryIndividually(
clarizen: Clarizen,
typeName: string,
record: EntityRecord,
fieldName: string,
value: JsonValue,
): Promise<UpdateError | undefined> {
try {
await updateSingle(clarizen, typeName, record.id, fieldName, value);
return undefined;
} catch (err) {
if (err instanceof ClarizenApiError) {
return {
id: record.id,
displayName: record.displayName,
message: err.message,
errorCode: String(err.errorCode),
};
}
return {
id: record.id,
displayName: record.displayName,
message: err instanceof Error ? err.message : "Erro desconhecido",
};
}
}
export async function bulkUpdateField(
clarizen: Clarizen,
typeName: string,
records: EntityRecord[],
fieldName: string,
value: JsonValue,
): Promise<BulkUpdateResult> {
const progress = new ProgressReporter(records.length);
let updated = 0;
const errors: UpdateError[] = [];
for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, i + BATCH_SIZE);
const results = await updateBatchBulk(
clarizen,
typeName,
batch,
fieldName,
value,
);
for (const { record, error } of results) {
if (!error) {
updated++;
progress.update(updated + errors.length);
continue;
}
const retryError = await retryIndividually(
clarizen,
typeName,
record,
fieldName,
value,
);
if (retryError) {
errors.push(retryError);
} else {
updated++;
}
progress.update(updated + errors.length);
}
}
progress.finish();
return { updated, errors };
}
+56
View File
@@ -0,0 +1,56 @@
const BAR_WIDTH = 30;
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "?";
const totalSeconds = Math.ceil(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return `${minutes}m ${seconds}s`;
const hours = Math.floor(minutes / 60);
const remMinutes = minutes % 60;
return `${hours}h ${remMinutes}m`;
}
function renderBar(percent: number): string {
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.round((clamped / 100) * BAR_WIDTH);
const empty = BAR_WIDTH - filled;
return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
}
export class ProgressReporter {
private readonly startTime = Date.now();
private lastLineLength = 0;
constructor(private readonly total: number) {}
update(processed: number): void {
const percent = this.total > 0 ? (processed / this.total) * 100 : 100;
const elapsed = Date.now() - this.startTime;
const avgMs = processed > 0 ? elapsed / processed : 0;
const remaining = this.total - processed;
const etaMs = processed > 0 ? remaining * avgMs : 0;
const line = [
renderBar(percent),
`${percent.toFixed(1)}%`,
`(${processed}/${this.total})`,
processed > 0 && remaining > 0 ? `~${formatDuration(etaMs)} restantes` : "",
]
.filter(Boolean)
.join(" ");
const padded =
line.length < this.lastLineLength
? line + " ".repeat(this.lastLineLength - line.length)
: line;
this.lastLineLength = padded.length;
process.stdout.write(`\r${padded}`);
}
finish(): void {
process.stdout.write("\n");
}
}