first commit
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user