@zapier/kitcore 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @zapier/kitcore
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 615fda9: The resolution controller now paginates dynamic selects as a page window: each `select` ask carries ONE page of choices plus a `page: { generation, index }` descriptor, so payloads stay proportional to a page.
8
+ - `next_page` and `previous_page` actions replace `more`.
9
+ - State carries coordinates only: `ControllerPagination` holds the cursor trail, never items, so the host carries cursors back between steps instead of the items it was just sent. An accumulating host appends pages client-side keyed on the question's `path` + `generation`, which increments whenever a `search` restarts the listing.
10
+ - Every question carries `path` (which field it is for), so hosts never read the state.
11
+ - Resolver `validate` is now a top-level callback (`validate({ imports, value, input, context })`), async and able to verify against the source; a thrown validate surfaces as a `failed` result with retry, not a rejection. A `validate` inside the prompt config is a type error (`ResolverPromptConfig` omits it), so it can't silently never run.
12
+ - The `exhausted` flag is removed; end-of-list is the absence of `next_page`.
13
+ - A failed fetch records its position so `retry` replays exactly the page that failed.
14
+
15
+ - e6b22fb: `concatPaginated` now paginates with stateless cursors: each source is called with its own cursor per page (`({ cursor }) => ...`), outgoing cursors encode the source index plus that source's cursor, and a new `cursor` option resumes the stream from a previous page's cursor with no shared in-memory state. The `dedupe` option is removed; sources must be disjoint by construction.
16
+
17
+ ## 0.6.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 197a125: Breaking for plugin authors: an `output: "item"` method's `run` now returns the `{ data: item }` envelope itself (strictly `data`, no extra keys), symmetric with list's `SdkPage`, instead of returning the bare item for the framework to wrap. The public call surface is unchanged: callers still get `Promise<{ data: item }>`.
22
+
3
23
  ## 0.5.1
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -183,8 +183,13 @@ const transport = defineMethod({
183
183
  run: ({ input }) => fetch(input.url),
184
184
  });
185
185
 
186
- // item: run returns the value; the framework wraps it in { data }
187
- const getApp = defineMethod({ name: "getApp", output: "item", run: () => app });
186
+ // item: run returns the { data } envelope itself (data and nothing else),
187
+ // symmetric with list's page shape
188
+ const getApp = defineMethod({
189
+ name: "getApp",
190
+ output: "item",
191
+ run: () => ({ data: app }),
192
+ });
188
193
  const { data } = await sdk.getApp({ app: "slack" });
189
194
 
190
195
  // list: run returns one page; the surface is a paginated iterable
package/dist/index.cjs CHANGED
@@ -301,7 +301,8 @@ function getCoreErrorCause(value) {
301
301
  var CURSOR_VERSION = 1;
302
302
  var CURSOR_SOURCE = {
303
303
  API: "api",
304
- SDK: "sdk"
304
+ SDK: "sdk",
305
+ CONCAT: "concat"
305
306
  };
306
307
  function encodeBase64(str) {
307
308
  return btoa(
@@ -521,10 +522,32 @@ async function* paginateBuffered(pageFunction, pageOptions) {
521
522
  }
522
523
  }
523
524
  var paginate = paginateBuffered;
525
+ function encodeConcatCursor(index, cursor) {
526
+ const envelope = {
527
+ v: CURSOR_VERSION,
528
+ source: CURSOR_SOURCE.CONCAT,
529
+ index,
530
+ cursor
531
+ };
532
+ return encodeBase64(JSON.stringify(envelope));
533
+ }
534
+ function decodeConcatCursor(incoming) {
535
+ if (!incoming) {
536
+ return { index: 0, cursor: void 0 };
537
+ }
538
+ try {
539
+ const envelope = JSON.parse(decodeBase64(incoming));
540
+ if (envelope.v === CURSOR_VERSION && envelope.source === CURSOR_SOURCE.CONCAT && typeof envelope.index === "number") {
541
+ return { index: envelope.index, cursor: envelope.cursor };
542
+ }
543
+ } catch {
544
+ }
545
+ return { index: 0, cursor: incoming };
546
+ }
524
547
  function concatPaginated({
525
548
  sources,
526
- dedupe,
527
- pageSize = 100
549
+ pageSize = 100,
550
+ cursor
528
551
  }) {
529
552
  if (sources.length === 0) {
530
553
  const empty = { data: [] };
@@ -534,40 +557,24 @@ function concatPaginated({
534
557
  }
535
558
  });
536
559
  }
537
- let sourceIndex = 0;
538
- let currentIterator = null;
539
- const seen = /* @__PURE__ */ new Set();
540
- const pageFunction = async (_options) => {
541
- while (sourceIndex < sources.length) {
542
- if (!currentIterator) {
543
- const result = sources[sourceIndex]();
544
- currentIterator = result[Symbol.asyncIterator]();
545
- }
546
- const next = await currentIterator.next();
547
- if (next.done) {
548
- sourceIndex++;
549
- currentIterator = null;
560
+ const pageFunction = async (options) => {
561
+ let { index, cursor: sourceCursor } = decodeConcatCursor(options.cursor);
562
+ while (index < sources.length) {
563
+ const page = await sources[index]({ cursor: sourceCursor });
564
+ const hasMoreInSource = page.nextCursor != null;
565
+ if (page.data.length === 0 && !hasMoreInSource) {
566
+ index++;
567
+ sourceCursor = void 0;
550
568
  continue;
551
569
  }
552
- let items = next.value.data;
553
- if (dedupe) {
554
- if (sourceIndex > 0) {
555
- items = items.filter((item) => !seen.has(dedupe(item)));
556
- }
557
- for (const item of items) {
558
- seen.add(dedupe(item));
559
- }
560
- }
561
- const hasMoreInSource = next.value.nextCursor != null;
562
- const hasMoreSources = sourceIndex < sources.length - 1;
563
570
  return {
564
- data: items,
565
- nextCursor: hasMoreInSource || hasMoreSources ? "__has_more__" : void 0
571
+ data: page.data,
572
+ nextCursor: hasMoreInSource ? encodeConcatCursor(index, page.nextCursor) : index < sources.length - 1 ? encodeConcatCursor(index + 1, void 0) : void 0
566
573
  };
567
574
  }
568
575
  return { data: [] };
569
576
  };
570
- const iterator = paginateBuffered(pageFunction, { pageSize });
577
+ const iterator = paginateBuffered(pageFunction, { pageSize, cursor });
571
578
  const firstPagePromise = iterator.next().then((result) => {
572
579
  if (result.done) {
573
580
  return { data: [] };
@@ -1448,6 +1455,7 @@ function defineResolver(config) {
1448
1455
  getContext: config.getContext,
1449
1456
  listItems: config.listItems,
1450
1457
  prompt: config.prompt,
1458
+ validate: config.validate,
1451
1459
  tryResolveWithoutPrompt: config.tryResolveWithoutPrompt,
1452
1460
  tryResolveFromSearch: config.tryResolveFromSearch
1453
1461
  };
@@ -2106,6 +2114,7 @@ function bindResolver(resolver, plugins) {
2106
2114
  const {
2107
2115
  getContext: getContext2,
2108
2116
  listItems,
2117
+ validate,
2109
2118
  tryResolveWithoutPrompt,
2110
2119
  tryResolveFromSearch
2111
2120
  } = resolver;
@@ -2119,6 +2128,9 @@ function bindResolver(resolver, plugins) {
2119
2128
  };
2120
2129
  if (getContext2)
2121
2130
  bound.getContext = ({ input }) => getContext2({ imports, input });
2131
+ if (validate) {
2132
+ bound.validate = ({ value, input, context }) => validate({ imports, value, input, context });
2133
+ }
2122
2134
  if (tryResolveWithoutPrompt) {
2123
2135
  bound.tryResolveWithoutPrompt = ({ input }) => tryResolveWithoutPrompt({ imports, input });
2124
2136
  }
@@ -2274,9 +2286,7 @@ function buildMethodEntries(descriptors, context, states) {
2274
2286
  }
2275
2287
  );
2276
2288
  } else if (out.type === "item") {
2277
- const itemCore = async (input) => ({
2278
- data: await callRun(input)
2279
- });
2289
+ const itemCore = async (input) => callRun(input);
2280
2290
  entry.value = createFunction(
2281
2291
  fold(itemCore),
2282
2292
  {
@@ -2800,14 +2810,14 @@ function coerce(leaf, raw) {
2800
2810
  }
2801
2811
  async function validationError(leaf, value, state) {
2802
2812
  if (leaf.resolver?.type !== "dynamic") return null;
2813
+ const validate = leaf.resolver.validate;
2814
+ if (!validate) return null;
2803
2815
  const context = await resolveContext(leaf, state.resolved);
2804
- const config = leaf.resolver.prompt?.({
2805
- items: state.listing?.items ?? [],
2816
+ const verdict = await validate({
2817
+ value,
2806
2818
  input: mergeInput(state.resolved, leaf.extraInput),
2807
2819
  context
2808
2820
  });
2809
- if (!config?.validate) return null;
2810
- const verdict = config.validate(value);
2811
2821
  if (verdict === true) return null;
2812
2822
  return typeof verdict === "string" ? verdict : `${leaf.name}: invalid value`;
2813
2823
  }
@@ -2933,20 +2943,36 @@ async function resolveContext(leaf, input) {
2933
2943
  input: mergeInput(input, leaf.extraInput)
2934
2944
  });
2935
2945
  }
2936
- async function fetchListing(leaf, input, opts = {}) {
2946
+ function firstPagePosition(opts = {}) {
2947
+ return {
2948
+ ...opts.search !== void 0 ? { search: opts.search } : {},
2949
+ pageCursor: null,
2950
+ previousCursors: [],
2951
+ generation: opts.generation ?? 0
2952
+ };
2953
+ }
2954
+ async function fetchListing(leaf, input, position, context) {
2955
+ if (leaf.resolver?.type === "dynamic" && leaf.resolver.inputType === "search" && position.search === void 0) {
2956
+ return { position, items: [] };
2957
+ }
2937
2958
  const page = await firstPage(
2938
2959
  leaf.resolver?.type === "dynamic" ? leaf.resolver.listItems({
2939
2960
  input: mergeInput(input, leaf.extraInput),
2940
- context: opts.context,
2941
- search: opts.search,
2942
- cursor: opts.cursor
2961
+ context,
2962
+ search: position.search,
2963
+ cursor: position.pageCursor ?? void 0
2943
2964
  }) : void 0
2944
2965
  );
2945
2966
  return {
2946
- items: [...opts.priorItems ?? [], ...page.data],
2947
- cursor: page.nextCursor,
2948
- search: opts.search,
2949
- exhausted: page.nextCursor == null
2967
+ position,
2968
+ items: page.data,
2969
+ ...page.nextCursor != null ? { nextCursor: page.nextCursor } : {}
2970
+ };
2971
+ }
2972
+ function toPagination(page) {
2973
+ return {
2974
+ position: page.position,
2975
+ ...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {}
2950
2976
  };
2951
2977
  }
2952
2978
 
@@ -2968,23 +2994,32 @@ var AFFORDANCE = {
2968
2994
  description: "Filter the options by a search term",
2969
2995
  supply: "term"
2970
2996
  },
2971
- more: { action: "more", description: "Load more options" },
2997
+ nextPage: {
2998
+ action: "next_page",
2999
+ description: "Fetch the next page of options"
3000
+ },
3001
+ previousPage: {
3002
+ action: "previous_page",
3003
+ description: "Return to the previous page of options"
3004
+ },
2972
3005
  skip: { action: "skip", description: "Omit this optional parameter" },
2973
3006
  add: { action: "add", description: "Add another item" },
2974
3007
  done: { action: "done", description: "Finish the list" },
2975
3008
  retry: { action: "retry", description: "Retry loading the options" },
2976
3009
  cancel: { action: "cancel", description: "Cancel resolution" }
2977
3010
  };
2978
- function selectActions(leaf, listing, multiple) {
3011
+ function selectActions(leaf, page, multiple) {
2979
3012
  const searchMode = leaf.resolver?.type === "dynamic" && leaf.resolver.inputType === "search";
2980
- if (searchMode && listing.search === void 0 && listing.items.length === 0) {
3013
+ if (searchMode && page.position.search === void 0 && page.items.length === 0) {
2981
3014
  const actions2 = multiple ? [AFFORDANCE.search] : [AFFORDANCE.search, AFFORDANCE.custom];
2982
3015
  if (!leaf.required) actions2.push(AFFORDANCE.skip);
2983
3016
  return actions2;
2984
3017
  }
2985
3018
  const actions = multiple ? [AFFORDANCE.choose] : [AFFORDANCE.choose, AFFORDANCE.custom];
2986
3019
  if (searchMode) actions.push(AFFORDANCE.search);
2987
- if (listing.cursor) actions.push(AFFORDANCE.more);
3020
+ if (page.nextCursor) actions.push(AFFORDANCE.nextPage);
3021
+ if (page.position.previousCursors.length > 0)
3022
+ actions.push(AFFORDANCE.previousPage);
2988
3023
  if (!leaf.required) actions.push(AFFORDANCE.skip);
2989
3024
  return actions;
2990
3025
  }
@@ -2992,16 +3027,17 @@ function labeledMessage(leaf) {
2992
3027
  if (!leaf.label) return void 0;
2993
3028
  return `${leaf.label} (${leaf.required ? "required" : "optional"}):`;
2994
3029
  }
2995
- function selectQuestion(leaf, input, listing, context) {
3030
+ function selectQuestion(leaf, path, input, page, context) {
2996
3031
  const resolver = leaf.resolver?.type === "dynamic" ? leaf.resolver : void 0;
2997
3032
  const config = resolver?.prompt?.({
2998
- items: listing.items,
3033
+ items: page.items,
2999
3034
  input: mergeInput(input, leaf.extraInput),
3000
3035
  context
3001
3036
  });
3002
3037
  const multiple = config?.type === "checkbox";
3003
3038
  return {
3004
3039
  type: "select",
3040
+ path,
3005
3041
  // A labeled field's title beats the resolver's message: per-field
3006
3042
  // resolvers are shared across fields (one choices-fetcher for every
3007
3043
  // field), so only the leaf knows which field is being asked.
@@ -3009,9 +3045,13 @@ function selectQuestion(leaf, input, listing, context) {
3009
3045
  choices: (config?.choices ?? []).map(toChoice),
3010
3046
  ...multiple ? { multiple: true } : {},
3011
3047
  ...config?.notes?.length ? { notes: config.notes } : {},
3012
- ...listing.search !== void 0 ? { search: listing.search } : {},
3048
+ ...page.position.search !== void 0 ? { search: page.position.search } : {},
3013
3049
  ...resolver?.placeholder ? { placeholder: resolver.placeholder } : {},
3014
- actions: selectActions(leaf, listing, multiple)
3050
+ page: {
3051
+ generation: page.position.generation,
3052
+ index: page.position.previousCursors.length
3053
+ },
3054
+ actions: selectActions(leaf, page, multiple)
3015
3055
  };
3016
3056
  }
3017
3057
  function toControllerError(error) {
@@ -3033,6 +3073,7 @@ function failedResult(state, name, error) {
3033
3073
  error: toControllerError(error),
3034
3074
  question: {
3035
3075
  type: "select",
3076
+ path: state.current ?? [],
3036
3077
  message: `Could not load options for ${name}.`,
3037
3078
  choices: [],
3038
3079
  actions: [AFFORDANCE.retry, AFFORDANCE.cancel]
@@ -3040,27 +3081,15 @@ function failedResult(state, name, error) {
3040
3081
  }
3041
3082
  };
3042
3083
  }
3043
- async function buildQuestion(leaf, input) {
3084
+ async function buildQuestion(leaf, path, input) {
3044
3085
  const optional = !leaf.required;
3045
3086
  const resolver = leaf.resolver;
3046
3087
  if (resolver?.type === "dynamic") {
3047
3088
  const context = await resolveContext(leaf, input);
3048
- if (resolver.inputType === "search") {
3049
- const listing2 = {
3050
- items: [],
3051
- cursor: void 0,
3052
- search: void 0,
3053
- exhausted: true
3054
- };
3055
- return {
3056
- question: selectQuestion(leaf, input, listing2, context),
3057
- listing: listing2
3058
- };
3059
- }
3060
- const listing = await fetchListing(leaf, input, { context });
3089
+ const page = await fetchListing(leaf, input, firstPagePosition(), context);
3061
3090
  return {
3062
- question: selectQuestion(leaf, input, listing, context),
3063
- listing
3091
+ question: selectQuestion(leaf, path, input, page, context),
3092
+ pagination: toPagination(page)
3064
3093
  };
3065
3094
  }
3066
3095
  if (leaf.staticChoices) {
@@ -3069,6 +3098,7 @@ async function buildQuestion(leaf, input) {
3069
3098
  return {
3070
3099
  question: {
3071
3100
  type: "select",
3101
+ path,
3072
3102
  message: `Select ${leaf.name}:`,
3073
3103
  choices: leaf.staticChoices,
3074
3104
  actions: actions2
@@ -3082,6 +3112,7 @@ async function buildQuestion(leaf, input) {
3082
3112
  return {
3083
3113
  question: {
3084
3114
  type: "input",
3115
+ path,
3085
3116
  // The optional marker makes Enter-to-pass discoverable on a bare
3086
3117
  // parameter; a labeled field carries its marker via labeledMessage.
3087
3118
  message: labeledMessage(leaf) ?? `Enter ${leaf.name}${optional ? " (optional)" : ""}:`,
@@ -3096,6 +3127,7 @@ function collectionQuestion(t) {
3096
3127
  if (t.count >= t.min) actions.push(AFFORDANCE.done);
3097
3128
  return {
3098
3129
  type: "collection",
3130
+ path: t.path,
3099
3131
  message: `Add ${t.path[t.path.length - 1]}[${t.count}]?`,
3100
3132
  container: "array",
3101
3133
  count: t.count,
@@ -3109,6 +3141,7 @@ function collectionQuestion(t) {
3109
3141
  function objectGateQuestion(path) {
3110
3142
  return {
3111
3143
  type: "collection",
3144
+ path,
3112
3145
  message: `Add ${path[path.length - 1]}?`,
3113
3146
  container: "object",
3114
3147
  actions: [
@@ -3120,9 +3153,10 @@ function objectGateQuestion(path) {
3120
3153
  ]
3121
3154
  };
3122
3155
  }
3123
- function optionalsGateQuestion(pending) {
3156
+ function optionalsGateQuestion(path, pending) {
3124
3157
  return {
3125
3158
  type: "collection",
3159
+ path,
3126
3160
  // The prompt and its context ride separately so a host renders the info
3127
3161
  // line above the confirm without composing any text of its own.
3128
3162
  message: "Would you like to configure optional fields?",
@@ -3272,8 +3306,12 @@ async function askLeaf(state, path, leaf, opts = {}) {
3272
3306
  state.current = path;
3273
3307
  delete state.gate;
3274
3308
  try {
3275
- const { question, listing } = await buildQuestion(leaf, state.resolved);
3276
- state.listing = listing;
3309
+ const { question, pagination } = await buildQuestion(
3310
+ leaf,
3311
+ path,
3312
+ state.resolved
3313
+ );
3314
+ state.pagination = pagination;
3277
3315
  return {
3278
3316
  state,
3279
3317
  result: {
@@ -3283,7 +3321,7 @@ async function askLeaf(state, path, leaf, opts = {}) {
3283
3321
  }
3284
3322
  };
3285
3323
  } catch (error) {
3286
- state.listing = { items: [], exhausted: false };
3324
+ state.pagination = failedPagination(void 0, firstPagePosition());
3287
3325
  return failedResult(state, leaf.name, error);
3288
3326
  }
3289
3327
  }
@@ -3293,13 +3331,13 @@ async function advance(ctx, state) {
3293
3331
  if (!target) {
3294
3332
  delete state.current;
3295
3333
  delete state.gate;
3296
- delete state.listing;
3334
+ delete state.pagination;
3297
3335
  return { state, result: finalize(ctx, state.resolved) };
3298
3336
  }
3299
3337
  if (target.kind === "array") {
3300
3338
  state.current = target.path;
3301
3339
  state.gate = "array";
3302
- delete state.listing;
3340
+ delete state.pagination;
3303
3341
  return {
3304
3342
  state,
3305
3343
  result: { status: "ask", question: collectionQuestion(target) }
@@ -3308,7 +3346,7 @@ async function advance(ctx, state) {
3308
3346
  if (target.kind === "object") {
3309
3347
  state.current = target.path;
3310
3348
  state.gate = "entry";
3311
- delete state.listing;
3349
+ delete state.pagination;
3312
3350
  return {
3313
3351
  state,
3314
3352
  result: { status: "ask", question: objectGateQuestion(target.path) }
@@ -3317,12 +3355,12 @@ async function advance(ctx, state) {
3317
3355
  if (target.kind === "optionals") {
3318
3356
  state.current = target.path;
3319
3357
  state.gate = "optionals";
3320
- delete state.listing;
3358
+ delete state.pagination;
3321
3359
  return {
3322
3360
  state,
3323
3361
  result: {
3324
3362
  status: "ask",
3325
- question: optionalsGateQuestion(target.pending)
3363
+ question: optionalsGateQuestion(target.path, target.pending)
3326
3364
  }
3327
3365
  };
3328
3366
  }
@@ -3373,13 +3411,13 @@ async function step(ctx, prior, action) {
3373
3411
  if (action.type === "cancel") {
3374
3412
  delete state.current;
3375
3413
  delete state.gate;
3376
- delete state.listing;
3414
+ delete state.pagination;
3377
3415
  return { state, result: { status: "cancelled" } };
3378
3416
  }
3379
3417
  const path = state.current;
3380
3418
  if (!path) throw new Error("step called with no outstanding question");
3381
3419
  const leaf = await leafAt(ctx, path, state.resolved);
3382
- if (leaf && (action.type === "search" || action.type === "more" || action.type === "retry")) {
3420
+ if (leaf && (action.type === "search" || action.type === "next_page" || action.type === "previous_page" || action.type === "retry")) {
3383
3421
  return refine(ctx, state, leaf, path, action);
3384
3422
  }
3385
3423
  if (action.type === "add" || action.type === "done") {
@@ -3391,7 +3429,7 @@ async function step(ctx, prior, action) {
3391
3429
  }
3392
3430
  delete state.current;
3393
3431
  delete state.gate;
3394
- delete state.listing;
3432
+ delete state.pagination;
3395
3433
  if (action.type === "done") {
3396
3434
  settle(state, path);
3397
3435
  return advance(ctx, state);
@@ -3420,23 +3458,44 @@ async function step(ctx, prior, action) {
3420
3458
  case "choose":
3421
3459
  case "custom": {
3422
3460
  if (leaf) {
3423
- const error = await validationError(leaf, action.value, state);
3461
+ let error;
3462
+ try {
3463
+ error = await validationError(leaf, action.value, state);
3464
+ } catch (thrown) {
3465
+ return failedResult(state, leaf.name, thrown);
3466
+ }
3424
3467
  if (error) {
3425
- if (state.listing && leaf.resolver?.type === "dynamic") {
3426
- const context = await resolveContext(leaf, state.resolved);
3427
- return {
3428
- state,
3429
- result: {
3430
- status: "ask",
3431
- question: selectQuestion(
3432
- leaf,
3433
- state.resolved,
3434
- state.listing,
3435
- context
3436
- ),
3437
- error
3438
- }
3439
- };
3468
+ if (state.pagination && leaf.resolver?.type === "dynamic") {
3469
+ try {
3470
+ const context = await resolveContext(leaf, state.resolved);
3471
+ const page = await fetchListing(
3472
+ leaf,
3473
+ state.resolved,
3474
+ state.pagination.position,
3475
+ context
3476
+ );
3477
+ state.pagination = toPagination(page);
3478
+ return {
3479
+ state,
3480
+ result: {
3481
+ status: "ask",
3482
+ question: selectQuestion(
3483
+ leaf,
3484
+ path,
3485
+ state.resolved,
3486
+ page,
3487
+ context
3488
+ ),
3489
+ error
3490
+ }
3491
+ };
3492
+ } catch (fetchError) {
3493
+ state.pagination = failedPagination(
3494
+ state.pagination,
3495
+ state.pagination.position
3496
+ );
3497
+ return failedResult(state, leaf.name, fetchError);
3498
+ }
3440
3499
  }
3441
3500
  return askLeaf(state, path, leaf, { error });
3442
3501
  }
@@ -3455,15 +3514,11 @@ async function step(ctx, prior, action) {
3455
3514
  throw new Error(`action "${action.type}" is not supported here`);
3456
3515
  }
3457
3516
  delete state.current;
3458
- delete state.listing;
3517
+ delete state.pagination;
3459
3518
  return advance(ctx, state);
3460
3519
  }
3461
3520
  async function refine(ctx, state, leaf, path, action) {
3462
- const attempt = action.type === "search" ? { search: action.term, cursor: void 0, priorItems: [] } : {
3463
- search: state.listing?.search,
3464
- cursor: state.listing?.cursor,
3465
- priorItems: state.listing?.items ?? []
3466
- };
3521
+ const position = positionAfter(state.pagination, action);
3467
3522
  try {
3468
3523
  if (action.type === "search") {
3469
3524
  const exact = leaf.resolver?.type === "dynamic" ? await leaf.resolver.tryResolveFromSearch?.({
@@ -3473,32 +3528,62 @@ async function refine(ctx, state, leaf, path, action) {
3473
3528
  if (exact) {
3474
3529
  setAtPath(state.resolved, path, coerce(leaf, exact.resolvedValue));
3475
3530
  delete state.current;
3476
- delete state.listing;
3531
+ delete state.pagination;
3477
3532
  return advance(ctx, state);
3478
3533
  }
3479
3534
  }
3480
3535
  const context = await resolveContext(leaf, state.resolved);
3481
- state.listing = await fetchListing(leaf, state.resolved, {
3482
- ...attempt,
3483
- context
3484
- });
3536
+ const page = await fetchListing(leaf, state.resolved, position, context);
3537
+ state.pagination = toPagination(page);
3485
3538
  return {
3486
3539
  state,
3487
3540
  result: {
3488
3541
  status: "ask",
3489
- question: selectQuestion(leaf, state.resolved, state.listing, context)
3542
+ question: selectQuestion(leaf, path, state.resolved, page, context)
3490
3543
  }
3491
3544
  };
3492
3545
  } catch (error) {
3493
- state.listing = {
3494
- items: attempt.priorItems,
3495
- search: attempt.search,
3496
- cursor: attempt.cursor,
3497
- exhausted: false
3498
- };
3546
+ state.pagination = failedPagination(state.pagination, position);
3499
3547
  return failedResult(state, leaf.name, error);
3500
3548
  }
3501
3549
  }
3550
+ function failedPagination(pagination, retryPosition) {
3551
+ return {
3552
+ position: pagination?.position ?? firstPagePosition(),
3553
+ ...pagination?.nextCursor !== void 0 ? { nextCursor: pagination.nextCursor } : {},
3554
+ retryPosition
3555
+ };
3556
+ }
3557
+ function positionAfter(pagination, action) {
3558
+ const current = pagination?.position ?? firstPagePosition();
3559
+ switch (action.type) {
3560
+ case "search":
3561
+ return firstPagePosition({
3562
+ search: action.term,
3563
+ generation: current.generation + 1
3564
+ });
3565
+ case "next_page": {
3566
+ if (pagination?.nextCursor == null) return current;
3567
+ return {
3568
+ ...current.search !== void 0 ? { search: current.search } : {},
3569
+ pageCursor: pagination.nextCursor,
3570
+ previousCursors: [...current.previousCursors, current.pageCursor],
3571
+ generation: current.generation
3572
+ };
3573
+ }
3574
+ case "previous_page": {
3575
+ if (current.previousCursors.length === 0) return current;
3576
+ return {
3577
+ ...current.search !== void 0 ? { search: current.search } : {},
3578
+ pageCursor: current.previousCursors[current.previousCursors.length - 1],
3579
+ previousCursors: current.previousCursors.slice(0, -1),
3580
+ generation: current.generation
3581
+ };
3582
+ }
3583
+ case "retry":
3584
+ return pagination?.retryPosition ?? current;
3585
+ }
3586
+ }
3502
3587
 
3503
3588
  // src/model/resolution/controller.ts
3504
3589
  function toJsonSchema(schema) {