Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
# safePushData
# pushDataWithSchemaRepair

TypeScript library wrapper that survives Apify dataset schema-validation
failures. When an upstream data source produces an item that doesn't match
the dataset's JSON schema, the platform rejects the **entire batch** with
a 400 error — losing every other valid item in that push. `safePushData`
parses the validation error, strips the offending fields, and retries.
a 400 error — losing every other valid item in that push.
`pushDataWithSchemaRepair` parses the validation error, strips the
offending fields, and retries.

## Usage

```ts
import { Actor } from 'apify';
import { safePushData } from 'apify-actor-utils';
import { pushDataWithSchemaRepair } from 'apify-actor-utils';

await Actor.init();

const result = await safePushData((batch) => Actor.pushData(batch), items);
const result = await pushDataWithSchemaRepair((batch) => Actor.pushData(batch), items);

console.log(result);
// { pushedCount: 2, droppedItems: [...], attemptCount: 2, pushResult: undefined }
Expand All @@ -28,7 +29,7 @@ Whatever `pushFn` resolves to comes back as `pushResult`, so a push
function with a meaningful return value stays usable:

```ts
const { pushResult } = await safePushData((batch) => client.dataset(id).pushItems(batch), items);
const { pushResult } = await pushDataWithSchemaRepair((batch) => client.dataset(id).pushItems(batch), items);
```

## Performance notes
Expand Down Expand Up @@ -96,8 +97,8 @@ Every failed round logs which fields went wrong, so you can fix the schema
(or the scraper) without digging through the returned `droppedItems`:

```
safePushData: schema validation failed on attempt 1: 12 invalid item(s); repaired fields: /age (type), /name (required), /tags/[] (type); dropped 2 item(s) on unfixable fields: /email (format); retrying with 10 item(s).
safePushData: gave up after 5 attempts; dropped 3 item(s) still failing on fields: /sku (pattern); pushing the 9 valid item(s) left.
pushDataWithSchemaRepair: schema validation failed on attempt 1: 12 invalid item(s); repaired fields: /age (type), /name (required), /tags/[] (type); dropped 2 item(s) on unfixable fields: /email (format); retrying with 10 item(s).
pushDataWithSchemaRepair: gave up after 5 attempts; dropped 3 item(s) still failing on fields: /sku (pattern); pushing the 9 valid item(s) left.
```

The field list is a **set**, not a per-item breakdown — one bad field
Expand All @@ -118,7 +119,7 @@ Names say what they hold: `*Count` is a number, `*Items` is an array of
objects.

```ts
interface SafePushDataResult<T, R = unknown> {
interface PushDataWithSchemaRepairResult<T, R = unknown> {
/** How many of the caller's items made it into the dataset. */
pushedCount: number;
/** The items we couldn't repair, each with the errors that doomed it. */
Expand All @@ -144,12 +145,12 @@ Highlights:

```
.
├── index.ts # package entry point, re-exports src/
├── src/safePushData.ts # the library
├── test/safePushData.test.ts # node:test suite
├── index.ts # package entry point, re-exports src/
├── src/pushDataWithSchemaRepair.ts # the library
├── test/pushDataWithSchemaRepair.test.ts # node:test suite
├── scripts/
│ ├── check-pushdata.mjs # CI guard against direct .pushData() calls
│ └── probe-errors.mjs # reference: re-derive the API error shape
│ ├── check-pushdata.mjs # CI guard against direct .pushData() calls
│ └── probe-errors.mjs # reference: re-derive the API error shape
├── tsconfig.json
└── package.json
```
8 changes: 4 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export { safePushData, isSchemaValidationError } from './src/safePushData.js';
export { pushDataWithSchemaRepair, isSchemaValidationError } from './src/pushDataWithSchemaRepair.js';
export type {
ValidationError,
DroppedItem,
SafePushDataResult,
SafePushDataOptions,
PushDataWithSchemaRepairResult,
PushDataWithSchemaRepairOptions,
PushFn,
} from './src/safePushData.js';
} from './src/pushDataWithSchemaRepair.js';
10 changes: 5 additions & 5 deletions scripts/check-pushdata.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// CI check: forbid direct calls to `.pushData(...)` anywhere in the repo.
//
// safePushData is a library wrapper; callers pass `Actor.pushData` (or any
// dataset's push function) as `pushFn`. The whole point is to ensure nothing
// bypasses the wrapper, so a stray `Actor.pushData(items)` inside this repo
// would defeat that goal.
// pushDataWithSchemaRepair is a library wrapper; callers pass `Actor.pushData`
// (or any dataset's push function) as `pushFn`. The whole point is to ensure
// nothing bypasses the wrapper, so a stray `Actor.pushData(items)` inside this
// repo would defeat that goal.
//
// Exits non-zero (and prints the offending lines) if any file under src/
// or test/ contains a `.pushData(` call. This script itself, and the
Expand Down Expand Up @@ -72,7 +72,7 @@ if (offenders.length === 0) {
}

console.error('check-pushdata: FAIL — direct .pushData() calls are forbidden.');
console.error('Wrap every push through safePushData and pass the push function as pushFn.\n');
console.error('Wrap every push through pushDataWithSchemaRepair and pass the push function as pushFn.\n');
for (const o of offenders) {
console.error(` ${o.file}:${o.line} ${o.text}`);
}
Expand Down
32 changes: 18 additions & 14 deletions src/safePushData.ts → src/pushDataWithSchemaRepair.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// safePushData: parse the Apify dataset schema-validation error, repair the
// offending items (strip bad fields, placeholder missing required ones), and
// retry the push.
// pushDataWithSchemaRepair: parse the Apify dataset schema-validation error,
// repair the offending items (strip bad fields, placeholder missing required
// ones), and retry the push.
//
// NOTE: instead of recursively healing the data one error-round at a time, we
// could parse the Actor's `dataset_schema.json` up front and fix every item in
Expand Down Expand Up @@ -61,7 +61,7 @@ export interface DroppedItem<T> {

// Field names say what they hold: `*Count` is a number, `*Items` is an array
// of objects. `R` is whatever the caller's push function resolves to.
export interface SafePushDataResult<T, R = unknown> {
export interface PushDataWithSchemaRepairResult<T, R = unknown> {
/** How many of the caller's items made it into the dataset. */
pushedCount: number;
/** The items we couldn't repair, each with the errors that doomed it. */
Expand All @@ -75,7 +75,7 @@ export interface SafePushDataResult<T, R = unknown> {
pushResult?: R;
}

export interface SafePushDataOptions {
export interface PushDataWithSchemaRepairOptions {
maxAttempts?: number;
}

Expand All @@ -92,11 +92,11 @@ export type PushFn<T, R = unknown> = (items: T[]) => Promise<R>;
*
* Whatever `pushFn` resolves to is handed back untouched as `pushResult`.
*/
export async function safePushData<T, R = unknown>(
export async function pushDataWithSchemaRepair<T, R = unknown>(
pushFn: PushFn<T, R>,
input: T | T[],
options: SafePushDataOptions = {},
): Promise<SafePushDataResult<T, R>> {
options: PushDataWithSchemaRepairOptions = {},
): Promise<PushDataWithSchemaRepairResult<T, R>> {
const items = Array.isArray(input) ? input : [input];

// Happy path: assume validation will succeed (the overwhelmingly common
Expand All @@ -118,7 +118,7 @@ async function cleanAndRetry<T, R>(
originalItems: readonly T[],
initialError: SchemaValidationError,
maxAttempts: number,
): Promise<SafePushDataResult<T, R>> {
): Promise<PushDataWithSchemaRepairResult<T, R>> {
// working[i] is what we'll send on the next push. We mutate this array
// in place (splicing drops, replacing cleaned entries); the caller's
// `originalItems` is never touched.
Expand Down Expand Up @@ -155,7 +155,7 @@ async function cleanAndRetry<T, R>(
// everything that isn't in it, so "original minus dropped" is exactly what
// landed. (A rejected push stores nothing at all — not even the items the
// API found no fault with.)
const result = (attemptCount: number, pushResult?: R): SafePushDataResult<T, R> => ({
const result = (attemptCount: number, pushResult?: R): PushDataWithSchemaRepairResult<T, R> => ({
pushedCount: originalItems.length - dropped.length,
droppedItems: dropped,
attemptCount,
Expand All @@ -182,7 +182,9 @@ async function cleanAndRetry<T, R>(
// position outside the batch we actually sent) instead of
// crashing on `working[i]` being undefined.
if (i < 0 || i >= working.length) {
console.log(`safePushData: ignoring out-of-range itemPosition ${i} in validation error response.`);
console.log(
`pushDataWithSchemaRepair: ignoring out-of-range itemPosition ${i} in validation error response.`,
);
continue;
}
const cleaned = cleanItemFields(working[i], invalid.validationErrors, placeholderPaths[i]);
Expand All @@ -198,7 +200,7 @@ async function cleanAndRetry<T, R>(
}

const report = [
`safePushData: schema validation failed on attempt ${attempts}: ${lastError.data.invalidItems.length} invalid item(s)`,
`pushDataWithSchemaRepair: schema validation failed on attempt ${attempts}: ${lastError.data.invalidItems.length} invalid item(s)`,
];
if (repairedFields.size > 0) report.push(`repaired fields: ${formatFields(repairedFields)}`);
if (droppedThisRound > 0) {
Expand Down Expand Up @@ -227,7 +229,7 @@ async function cleanAndRetry<T, R>(
unresolved++;
dropAt(i, roundErrors[i]);
}
const giveUp = [`safePushData: gave up after ${maxAttempts} attempts`];
const giveUp = [`pushDataWithSchemaRepair: gave up after ${maxAttempts} attempts`];
if (unresolved > 0) {
giveUp.push(`dropped ${unresolved} item(s) still failing on fields: ${formatFields(unresolvedFields)}`);
}
Expand All @@ -249,7 +251,9 @@ async function cleanAndRetry<T, R>(
for (const invalid of err.data.invalidItems) {
errorsAt.set(invalid.itemPosition, invalid.validationErrors);
}
console.log(`safePushData: final push of ${working.length} item(s) was rejected too; dropping them.`);
console.log(
`pushDataWithSchemaRepair: final push of ${working.length} item(s) was rejected too; dropping them.`,
);
for (let i = working.length - 1; i >= 0; i--) dropAt(i, errorsAt.get(i) ?? NO_ERRORS);
return result(attempts);
}
Expand Down
Loading
Loading