Skip to content

feat(syncs): Expose listRecords for use in syncs#5676

Merged
rbwest merged 24 commits into
masterfrom
ryan/nan-5073-expose-listrecords-for-use-in-syncs
Mar 25, 2026
Merged

feat(syncs): Expose listRecords for use in syncs#5676
rbwest merged 24 commits into
masterfrom
ryan/nan-5073-expose-listrecords-for-use-in-syncs

Conversation

@rbwest
Copy link
Copy Markdown
Contributor

@rbwest rbwest commented Mar 19, 2026

listRecords needs to be exposed in sync code to allow for two-layer sync for MobileFirst to reduce sync load

NAN-5073: Expose listRecords for use in syncs

In a sample integrations repo:

  • Point towards local copy of CLI/runner SDK
  • Create sync that uses newly exposed nango.listRecords function
  • Compile and dry run sync

Expose listRecords async iterator in sync runners

This PR introduces a new listRecords async iterator to sync execution paths, enabling scripts to iterate persisted records page-by-page. The change is wired through the runner SDK, CLI sync runner, type definitions, telemetry allowlists, and persist client request handling, with abort checks during iteration and batching.

Supporting updates include a shared fetchRecordsPage helper for getRecordsByIds, optional limit propagation to the persist API, parser/compile validations, mock updates, and expanded unit tests covering abort behavior and multi-page cursor flows.


This summary was automatically generated by @propel-code-bot

@linear
Copy link
Copy Markdown

linear Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

@propel-code-bot propel-code-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change to correct model-name validation for listRecords argument handling.

Status: Changes Suggested | Risk: Medium

Issues Identified & Suggestions
  • Fix model-name validation to check first argument for listRecords: packages/cli/lib/services/parser.service.ts
Review Details

📁 9 files reviewed | 💬 1 comments

Instruction Files
└── .claude/
    ├── agents/
    │   └── nango-docs-migrator.md
    └── skills/
        ├── agent-builder-skill/
        │   ├── EXAMPLES.md
        │   └── SKILL.md
        ├── creating-integration-docs/
        │   └── SKILL.md
        └── creating-skills-skill/
            └── SKILL.md

👍 / 👎 individual comments to help improve reviews for you

'deleteRecordsFromPreviousExecutions',
'trackDeletesStart',
'getRecordsByIds',
'listRecords',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

[Logic] The model-name validation below only runs when args.length > 1 and always checks the last argument. For listRecords(model, options?), the model is the first arg, so invalid model names will never be flagged (both when options is omitted and when it’s present).

Fix: handle listRecords explicitly (or map method → model-arg index) so the model name check uses args[0] and runs when args.length >= 1.

Context for Agents
The model-name validation below only runs when `args.length > 1` and always checks the **last** argument. For `listRecords(model, options?)`, the model is the **first** arg, so invalid model names will never be flagged (both when `options` is omitted and when it’s present).

Fix: handle `listRecords` explicitly (or map method → model-arg index) so the model name check uses `args[0]` and runs when `args.length >= 1`.

File: packages/cli/lib/services/parser.service.ts
Line: 95

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the call signature to have model be the final argument like all other functions. Will update validation if the team requests it instead. Assuming the model is the final argument does feel a bit hacky 🤷

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must admit it's a weird convention. Also, it's the only required argument out of the 3 which feels weird to have to potentially have to call with (undefined, undefined, 'FooModel'). I'd prefer for it to be the first argument

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per @kaposke, this class is actually deprecated and only used for the zero yaml migration script. I've reverted the call signature back to the orginal (model: string, options?: {...}), added an @deprecated comment to this file, and skipped updating the model arg validation.

@rbwest rbwest changed the title feat(syncs) - Expose listRecords for use in syncs feat(syncs): Expose listRecords for use in syncs Mar 19, 2026
propel-code-bot[bot]

This comment was marked as outdated.

propel-code-bot[bot]

This comment was marked as outdated.

@marcindobry marcindobry requested a review from a team March 20, 2026 11:56
propel-code-bot[bot]

This comment was marked as outdated.

listRecords<T extends object = any>(
cursor: string | undefined,
limit: number | undefined,
model: string
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd pass an object like the node client equivalent function.
limit should have a default value when not provided. Maybe quite a small number to discourage any heavy processing

Copy link
Copy Markdown
Contributor Author

@rbwest rbwest Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally had it that way, but there was a check in parser.service.ts that looks for the model arg in the last position per this AI note:

const modelArg = args[args.length - 1];

Want me to revert the signature to:

public async listRecords<T = any>( model: string, options?: { cursor?: string; limit?: number; } )

And update the parser to allow this?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional cursor/limit before the model looks weird. I think we should modify the parser to allow optional params after the model.

Comment thread packages/runner/lib/sdk/sdk.ts Outdated
throw res.error;
}

const raw = res.unwrap();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unwrap is not to be used outside of tests.
since you have an isErr() guard you can get the value with res.value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, check now!

Comment thread packages/runner/lib/sdk/sdk.ts Outdated
};
if (cursor !== undefined) {
pageOptions.cursor = cursor;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can do

const pageOptions = {
   externalIds: Array.from(externalIdMap.keys()),
   ...(cursor ? { cursor } : {} )
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, check now!

Comment thread packages/runner/lib/sdk/sdk.ts Outdated

for (const record of records) {
const stringId = String(record.id);
const stringId = String((record as Record<string, unknown>)['id']);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need the casting?

Copy link
Copy Markdown
Contributor Author

@rbwest rbwest Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was because records were getting type T from the cast in fetchRecordsPage instead of carrying the type NangoRecord from the call to the persist client.

I've adjusted the types to be more explicit (from T[] to NangoRecord<T>[] and T = any to T=Record<string, any>) so it's no longer required. Let me know if I'm missing some issue with the new types though.

}
if (limit !== undefined) {
pageOptions.limit = limit;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment about using the spread operator.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, check now!

'setMetadata',
'proxy',
'get',
'listRecords', // 1. Allow await nango.listRecords() at compile time
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think listRecords needs to be added to disallowedActionCalls. below

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, check now!

cursor: string | undefined,
limit: number | undefined,
model: string
): Promise<{ records: T[]; next_cursor: string | null }>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slightly unrelated question. Should we deprecate getRecordsByIds and make ids and optional field of listRecords. Since they are basically the same thing under the hood.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think that made sense, but wasn't sure what the process for deprecating looks like. Is there any action we want to take now, like an @deprecated tag on the function?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes a @deprecated tag to start with. don't worry in this PR you can do it later

mockPersistClient.getRecords = vi.fn().mockResolvedValueOnce(Ok({ records, nextCursor: null }));

const nango = new NangoSyncRunner({ ...nangoProps }, { persistClient: mockPersistClient, locks });
const result = await nango.listRecords(undefined, undefined, 'SomeModel');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'll have to agree with the other comments, this signature doesn't look good

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

propel-code-bot[bot]

This comment was marked as outdated.

propel-code-bot[bot]

This comment was marked as outdated.

): Promise<{ records: T[]; next_cursor: string | null }>;
listRecords<T extends Record<string, any> = Record<string, any>>(
model: string,
options?: { cursor?: string; }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's keep the limit options

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I was going back and forth on this yesterday and removed it after discussing with Marcin and Agustin.

Can you explain how you would expect the limit to apply when returning an iterator? If there is some limit on the number of rows the iterator will yield, then we need to provide a nextCursor for the user to know to pickup the remaining records. If we return nextCursor, the user will have to paginate via cursor to get all records. At that point, what's the point having an iterator vs just paginating like it was originally?

Comment thread packages/runner/lib/sdk/sdk.ts Outdated
...(cursor ? { cursor } : {}),
...(limit ? { limit } : {})
};
let cursor: string | undefined = options?.cursor ?? undefined;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand what this line is achieving?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just storing cursor from the param. ?? undefined was a rogue autocomplete I didn't catch I think. Removed, thanks!


for (const record of records) {
yield record;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the function generate records or page of records? wdyt?
how is listRecords going to be used in the webhook debouncing use case for instance?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought returning a single record at a time made it easier to use the iterator. The webhook debouncing use case will want to get all records, de-dupe ids into a map, then fetch the data for every unique id in the map. I think this could work fine returning individual records or pages, but individual records felt cleaner.

Comment thread packages/runner/lib/sdk/sdk.ts Outdated

if (!next_cursor) {
break;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this break necessary. isn't the while condition be enough to exit the loop?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry. It's a relic from the original implementation of the loop. Changed it from while(true)...break to a do...while and forgot to remove. Fixed now.

propel-code-bot[bot]

This comment was marked as outdated.

propel-code-bot[bot]

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

@propel-code-bot propel-code-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found; the changes appear solid and aligned with the intended functionality.

Status: No Issues Found | Risk: Low

Review Details

📁 10 files reviewed | 💬 0 comments

Instruction Files
└── .claude/
    ├── agents/
    │   └── nango-docs-migrator.md
    └── skills

@rbwest rbwest added this pull request to the merge queue Mar 25, 2026
Merged via the queue into master with commit cd8294b Mar 25, 2026
25 checks passed
@rbwest rbwest deleted the ryan/nan-5073-expose-listrecords-for-use-in-syncs branch March 25, 2026 22:17
@rbwest rbwest self-assigned this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants