     1	/**
     2	 * Calculates pagination metadata from query results
     3	 *
     4	 * @param {number} total - Total number of records in the dataset
     5	 * @param {number} page - Current page number (1-based)
     6	 * @param {number} pageSize - Number of records per page
     7	 * @returns {Object} Pagination metadata including page count and hasMore flag
     8	 * @throws {Error} If pageSize <= 0 or page < 1
     9	 *
    10	 * @example
    11	 * // Input: 100 total records, viewing page 2 of 20 records each
    12	 * const meta = calculatePaginationMeta(100, 2, 20);
    13	 * // Output:
    14	 * // {
    15	 * //   page: 2,         // current page
    16	 * //   pageSize: 20,    // records per page
    17	 * //   pageCount: 5,    // total pages (100/20)
    18	 * //   total: 100,      // total records
    19	 * //   hasMore: true    // page 2 < 5, so more pages exist
    20	 * // }
    21	 *
    22	 * @example
    23	 * // Input: Last page scenario
    24	 * const meta = calculatePaginationMeta(45, 5, 10);
    25	 * // Output:
    26	 * // {
    27	 * //   page: 5,
    28	 * //   pageSize: 10,
    29	 * //   pageCount: 5,    // 45/10 = 4.5, rounds up to 5
    30	 * //   total: 45,
    31	 * //   hasMore: false   // on last page
    32	 * // }
    33	 *
    34	 * @description
    35	 * Used by:
    36	 * - rest-api-knex-plugin's dataQuery method after counting total records
    37	 * - generatePaginationLinks to build navigation links
    38	 * - Applied when offset-based pagination is enabled
    39	 *
    40	 * Purpose:
    41	 * - Provides consistent pagination metadata across all collection responses
    42	 * - Calculates derived values like pageCount and hasMore flag
    43	 * - Validates pagination parameters to prevent invalid states
    44	 *
    45	 * Data flow:
    46	 * 1. Query method counts total records when pagination.counts is enabled
    47	 * 2. calculatePaginationMeta processes the count with current page/size
    48	 * 3. Returns metadata that goes into response.meta.pagination
    49	 * 4. Used by generatePaginationLinks to determine which links to include
    50	 */
    51	export const calculatePaginationMeta = (total, page, pageSize) => {
    52	  if (pageSize <= 0) {
    53	    throw new Error('Page size must be greater than 0')
    54	  }
    55	  if (page < 1) {
    56	    throw new Error('Page number must be greater than 0')
    57	  }
    58	
    59	  const pageCount = Math.ceil(total / pageSize)
    60	  const currentPage = page || 1
    61	  const hasMore = currentPage < pageCount
    62	
    63	  return {
    64	    page: currentPage,
    65	    pageSize,
    66	    pageCount,
    67	    total,
    68	    hasMore
    69	  }
    70	}
    71	
    72	/**
    73	 * Generates JSON:API compliant pagination links for offset-based pagination
    74	 *
    75	 * @param {string} urlPrefix - Base URL prefix for the API
    76	 * @param {string} scopeName - Resource type name
    77	 * @param {Object} queryParams - Current query parameters
    78	 * @param {Object} paginationMeta - Pagination metadata from calculatePaginationMeta
    79	 * @returns {Object|null} Links object with self, first, last, prev, next
    80	 *
    81	 * @example
    82	 * // Input data:
    83	 * const urlPrefix = '/api/v1';
    84	 * const scopeName = 'articles';
    85	 * const queryParams = {
    86	 *   filter: { status: 'published' },
    87	 *   sort: ['-created_at'],
    88	 *   page: { number: 2, size: 20 }
    89	 * };
    90	 * const paginationMeta = {
    91	 *   page: 2,
    92	 *   pageSize: 20,
    93	 *   pageCount: 5,
    94	 *   total: 100,
    95	 *   hasMore: true
    96	 * };
    97	 *
    98	 * const links = generatePaginationLinks(urlPrefix, scopeName, queryParams, paginationMeta);
    99	 *
   100	 * // Output - complete URLs with all parameters preserved:
   101	 * // {
   102	 * //   self: '/api/articles?filter[status]=published&sort=-created_at&page[number]=2&page[size]=20',
   103	 * //   first: '/api/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
   104	 * //   last: '/api/articles?filter[status]=published&sort=-created_at&page[number]=5&page[size]=20',
   105	 * //   prev: '/api/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
   106	 * //   next: '/api/articles?filter[status]=published&sort=-created_at&page[number]=3&page[size]=20'
   107	 * // }
   108	 *
   109	 * @example
   110	 * // Input: First page (no prev link)
   111	 * const paginationMeta = { page: 1, pageSize: 10, pageCount: 3 };
   112	 * const links = generatePaginationLinks('/api', 'users', {}, paginationMeta);
   113	 *
   114	 * // Output - notice no 'prev' property:
   115	 * // {
   116	 * //   self: '/api/users?page[number]=1&page[size]=10',
   117	 * //   first: '/api/users?page[number]=1&page[size]=10',
   118	 * //   last: '/api/users?page[number]=3&page[size]=10',
   119	 * //   next: '/api/users?page[number]=2&page[size]=10'
   120	 * // }
   121	 *
   122	 * @description
   123	 * Used by:
   124	 * - rest-api-knex-plugin's dataQuery adds these links to response.links
   125	 * - Called when offset-based pagination is used (not cursor-based)
   126	 *
   127	 * Purpose:
   128	 * - JSON:API spec requires pagination links for easy navigation
   129	 * - Preserves all other query parameters (filters, sorts, includes)
   130	 * - Handles complex nested parameters like filter[author][name]=John
   131	 * - Only includes prev/next when applicable
   132	 *
   133	 * Data flow:
   134	 * 1. After records are fetched and pagination calculated
   135	 * 2. Builds complete URLs preserving all query parameters
   136	 * 3. Conditionally includes prev (not on page 1) and next (not on last page)
   137	 * 4. Added to response.links for client navigation
   138	 */
   139	export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, paginationMeta) => {
   140	  // Allow empty urlPrefix to generate relative links
   141	  // if (!urlPrefix) return null;
   142	
   143	  const { page, pageCount, pageSize } = paginationMeta
   144	  const links = {}
   145	
   146	  const otherParams = Object.entries(queryParams)
   147	    .filter(([key]) => key !== 'page')
   148	    .flatMap(([key, value]) => {
   149	      if (Array.isArray(value)) {
   150	        return value.length === 0
   151	          ? []
   152	          : value.map((v) => `${key}=${encodeURIComponent(v)}`)
   153	      }
   154	      if (typeof value === 'object' && value !== null) {
   155	        const parts = []
   156	        const processObject = (obj, prefix) => {
   157	          Object.entries(obj).forEach(([k, v]) => {
   158	            const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
   159	            if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
   160	              processObject(v, newKey)
   161	            } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
   162	              parts.push(`${newKey}=${encodeURIComponent(v)}`)
   163	            }
   164	          })
   165	        }
   166	        processObject(value, key)
   167	        return parts
   168	      }
   169	      if (value === undefined || value === null) {
   170	        return []
   171	      }
   172	      return [`${key}=${encodeURIComponent(value)}`]
   173	    })
   174	    .filter(Boolean)
   175	    .join('&')
   176	
   177	  const baseUrl = `${urlPrefix}/${scopeName}`
   178	  const queryPrefix = otherParams ? `?${otherParams}&` : '?'
   179	
   180	  links.self = `${baseUrl}${queryPrefix}page[number]=${page}&page[size]=${pageSize}`
   181	
   182	  if (pageCount !== undefined) {
   183	    links.first = `${baseUrl}${queryPrefix}page[number]=1&page[size]=${pageSize}`
   184	    links.last = `${baseUrl}${queryPrefix}page[number]=${pageCount}&page[size]=${pageSize}`
   185	
   186	    if (page > 1) {
   187	      links.prev = `${baseUrl}${queryPrefix}page[number]=${page - 1}&page[size]=${pageSize}`
   188	    }
   189	
   190	    if (page < pageCount) {
   191	      links.next = `${baseUrl}${queryPrefix}page[number]=${page + 1}&page[size]=${pageSize}`
   192	    }
   193	  }
   194	
   195	  return links
   196	}
   197	
   198	/**
   199	 * Creates an opaque cursor string from record data for cursor-based pagination
   200	 *
   201	 * @param {Object} record - Database record to create cursor from
   202	 * @param {Array<string>} sortFields - Fields to include in cursor (default: ['id'])
   203	 * @returns {string} URL-safe cursor string
   204	 *
   205	 * @example
   206	 * // Input: Simple ID-based cursor
   207	 * const record = {
   208	 *   id: 123,
   209	 *   title: 'My Article',
   210	 *   created_at: '2024-01-01T10:00:00Z'
   211	 * };
   212	 * const cursor = createCursor(record, ['id']);
   213	 *
   214	 * // Output: "id:123"
   215	 * // This cursor marks position at record with id=123
   216	 *
   217	 * @example
   218	 * // Input: Multi-field cursor for complex sorting
   219	 * const record = {
   220	 *   id: 5,
   221	 *   created_at: new Date('2024-01-15T08:30:00Z'),
   222	 *   title: 'Article: Part 2'
   223	 * };
   224	 * const cursor = createCursor(record, ['created_at', 'id']);
   225	 *
   226	 * // Output: "created_at:2024-01-15T08%3A30%3A00.000Z,id:5"
   227	 * // URL-encoded to handle the colons in timestamp
   228	 * // Used for queries like: WHERE (created_at, id) > ('2024-01-15T08:30:00.000Z', 5)
   229	 *
   230	 * @example
   231	 * // Input: Handling special characters
   232	 * const record = {
   233	 *   category: 'Tech & Science',
   234	 *   title: 'AI: The Future?',
   235	 *   id: 42
   236	 * };
   237	 * const cursor = createCursor(record, ['category', 'title', 'id']);
   238	 *
   239	 * // Output: "category:Tech%20%26%20Science,title:AI%3A%20The%20Future%3F,id:42"
   240	 * // Spaces encoded as %20, & as %26, : as %3A, ? as %3F
   241	 *
   242	 * @description
   243	 * Used by:
   244	 * - generateCursorPaginationLinks to create next/prev cursors
   245	 * - buildCursorMeta to provide cursor in response metadata
   246	 *
   247	 * Purpose:
   248	 * - Cursor-based pagination is more stable than offset for changing data
   249	 * - Encodes multiple sort fields to maintain stable ordering
   250	 * - URL-encodes values to handle special characters safely
   251	 * - Simple format that's easy to parse back
   252	 *
   253	 * Data flow:
   254	 * 1. After fetching records, takes the last record
   255	 * 2. Extracts values for all sort fields
   256	 * 3. Creates cursor encoding those values
   257	 * 4. Cursor used in 'next' link for fetching subsequent pages
   258	 * 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
   259	 */
   260	export const createCursor = (record, sortFields = ['id']) => {
   261	  const parts = []
   262	  sortFields.forEach(field => {
   263	    if (record[field] !== undefined) {
   264	      const value = record[field]
   265	      const stringValue = value instanceof Date ? value.toISOString() : String(value)
   266	      parts.push(`${field}:${encodeURIComponent(stringValue)}`)
   267	    }
   268	  })
   269	  return parts.join(',')
   270	}
   271	
   272	/**
   273	 * Parses a cursor string back into field/value pairs
   274	 *
   275	 * @param {string} cursor - Cursor string to parse
   276	 * @returns {Object} Object with field names as keys and decoded values
   277	 * @throws {Error} If cursor format is invalid
   278	 *
   279	 * @example
   280	 * // Input: Simple cursor
   281	 * const cursor = "id:123";
   282	 * const data = parseCursor(cursor);
   283	 *
   284	 * // Output: { id: "123" }
   285	 * // Ready to use in SQL: WHERE id > '123'
   286	 *
   287	 * @example
   288	 * // Input: Multi-field cursor with URL-encoded values
   289	 * const cursor = "created_at:2024-01-15T08%3A30%3A00.000Z,id:5";
   290	 * const data = parseCursor(cursor);
   291	 *
   292	 * // Output:
   293	 * // {
   294	 * //   created_at: "2024-01-15T08:30:00.000Z",  // Decoded
   295	 * //   id: "5"
   296	 * // }
   297	 * // Used for: WHERE (created_at, id) > ('2024-01-15T08:30:00.000Z', '5')
   298	 *
   299	 * @example
   300	 * // Input: Invalid cursor (throws error)
   301	 * try {
   302	 *   const data = parseCursor("invalid-no-colon");
   303	 * } catch (e) {
   304	 *   console.log(e.message);
   305	 *   // "Invalid cursor format: Invalid cursor format: missing colon separator"
   306	 * }
   307	 *
   308	 * @description
   309	 * Used by:
   310	 * - rest-api-knex-plugin's dataQuery when processing page[after] parameter
   311	 * - Used to build WHERE clause for cursor-based queries
   312	 *
   313	 * Purpose:
   314	 * - Decodes cursor back to usable values for SQL queries
   315	 * - Handles URL-encoded special characters
   316	 * - Validates cursor format to prevent injection attacks
   317	 *
   318	 * Data flow:
   319	 * 1. Client sends page[after]=cursor parameter
   320	 * 2. parseCursor extracts field values from cursor
   321	 * 3. Values used to build WHERE clause like "WHERE id > 123"
   322	 * 4. Ensures pagination continues from exact position
   323	 */
   324	export const parseCursor = (cursor) => {
   325	  try {
   326	    const data = {}
   327	    if (!cursor || cursor.trim() === '') {
   328	      throw new Error('Empty cursor')
   329	    }
   330	
   331	    const pairs = cursor.split(',')
   332	
   333	    for (const pair of pairs) {
   334	      const colonIndex = pair.indexOf(':')
   335	      if (colonIndex === -1) {
   336	        throw new Error('Invalid cursor format: missing colon separator')
   337	      }
   338	
   339	      const field = pair.substring(0, colonIndex)
   340	      const encodedValue = pair.substring(colonIndex + 1)
   341	
   342	      if (!field) {
   343	        throw new Error('Invalid cursor format: empty field name')
   344	      }
   345	
   346	      data[field] = decodeURIComponent(encodedValue)
   347	    }
   348	
   349	    return data
   350	  } catch (e) {
   351	    throw new Error(`Invalid cursor format: ${e.message}`)
   352	  }
   353	}
   354	
   355	/**
   356	 * Generates pagination links for cursor-based pagination
   357	 *
   358	 * @param {string} urlPrefix - Base URL prefix
   359	 * @param {string} scopeName - Resource type name
   360	 * @param {Object} queryParams - Current query parameters
   361	 * @param {Array<Object>} records - Current page records
   362	 * @param {number} pageSize - Records per page
   363	 * @param {boolean} hasMore - Whether more records exist
   364	 * @param {Array<string>} sortFields - Fields used for cursor
   365	 * @returns {Object|null} Links object with self, first, next
   366	 *
   367	 * @example
   368	 * // Input: First page of results
   369	 * const urlPrefix = '/api/v1';
   370	 * const scopeName = 'articles';
   371	 * const queryParams = {
   372	 *   filter: { status: 'published' },
   373	 *   page: { size: 20 }
   374	 * };
   375	 * const records = [
   376	 *   { id: 1, created_at: '2024-01-01', title: 'First' },
   377	 *   { id: 2, created_at: '2024-01-02', title: 'Second' },
   378	 *   { id: 3, created_at: '2024-01-03', title: 'Third' }
   379	 * ];
   380	 * const hasMore = true;  // More records exist
   381	 *
   382	 * const links = generateCursorPaginationLinks(
   383	 *   urlPrefix, scopeName, queryParams, records, 20, hasMore, ['created_at', 'id']
   384	 * );
   385	 *
   386	 * // Output:
   387	 * // {
   388	 * //   self: '/api/v1/articles?filter[status]=published&page[size]=20',
   389	 * //   first: '/api/v1/articles?filter[status]=published&page[size]=20',
   390	 * //   next: '/api/v1/articles?filter[status]=published&page[size]=20&page[after]=created_at:2024-01-03,id:3'
   391	 * // }
   392	 * // The 'next' cursor points after the last record (id:3)
   393	 *
   394	 * @example
   395	 * // Input: Last page (no more records)
   396	 * const records = [
   397	 *   { id: 98, created_at: '2024-03-01' },
   398	 *   { id: 99, created_at: '2024-03-02' }
   399	 * ];
   400	 * const hasMore = false;  // No more records
   401	 *
   402	 * const links = generateCursorPaginationLinks(
   403	 *   '/api', 'articles', {}, records, 20, hasMore, ['id']
   404	 * );
   405	 *
   406	 * // Output - no 'next' link:
   407	 * // {
   408	 * //   self: '/api/articles?page[size]=20',
   409	 * //   first: '/api/articles?page[size]=20'
   410	 * // }
   411	 *
   412	 * @description
   413	 * Used by:
   414	 * - rest-api-knex-plugin when cursor pagination is enabled
   415	 * - Called after fetching records with cursor-based query
   416	 *
   417	 * Purpose:
   418	 * - Cursor pagination is more efficient for large datasets
   419	 * - Stable pagination when data is being added/removed
   420	 * - Only includes 'next' link when more data exists
   421	 * - Preserves all other query parameters
   422	 *
   423	 * Data flow:
   424	 * 1. After cursor-based query fetches records
   425	 * 2. If hasMore is true, creates cursor from last record
   426	 * 3. Builds next link with page[after] parameter
   427	 * 4. Added to response.links for navigation
   428	 * 5. Client uses next link to fetch subsequent pages
   429	 */
   430	export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams, records, pageSize, hasMore, sortFields = ['id']) => {
   431	  // Allow empty urlPrefix to generate relative links
   432	  if (!records.length) return null
   433	
   434	  const links = {}
   435	  const baseUrl = `${urlPrefix}/${scopeName}`
   436	
   437	  const otherParams = Object.entries(queryParams)
   438	    .filter(([key]) => key !== 'page')
   439	    .flatMap(([key, value]) => {
   440	      if (Array.isArray(value)) {
   441	        return value.length === 0
   442	          ? []
   443	          : value.map((v) => `${key}=${encodeURIComponent(v)}`)
   444	      }
   445	      if (typeof value === 'object' && value !== null) {
   446	        const parts = []
   447	        const processObject = (obj, prefix) => {
   448	          Object.entries(obj).forEach(([k, v]) => {
   449	            const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
   450	            if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
   451	              processObject(v, newKey)
   452	            } else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
   453	              parts.push(`${newKey}=${encodeURIComponent(v)}`)
   454	            }
   455	          })
   456	        }
   457	        processObject(value, key)
   458	        return parts
   459	      }
   460	      if (value === undefined || value === null) {
   461	        return []
   462	      }
   463	      return [`${key}=${encodeURIComponent(value)}`]
   464	    })
   465	    .filter(Boolean)
   466	    .join('&')
   467	
   468	  const queryPrefix = otherParams ? `?${otherParams}&` : '?'
   469	
   470	  if (queryParams.page?.after) {
   471	    links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${queryParams.page.after}`
   472	  } else if (queryParams.page?.before) {
   473	    links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[before]=${queryParams.page.before}`
   474	  } else {
   475	    links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
   476	  }
   477	
   478	  links.first = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
   479	
   480	  if (hasMore && records.length > 0) {
   481	    const lastRecord = records[records.length - 1]
   482	    const nextCursor = createCursor(lastRecord, sortFields)
   483	    links.next = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${nextCursor}`
   484	  }
   485	
   486	  return links
   487	}
   488	
   489	/**
   490	 * Builds cursor pagination metadata for the response
   491	 *
   492	 * @param {Array<Object>} records - Current page records
   493	 * @param {number} pageSize - Records per page
   494	 * @param {boolean} hasMore - Whether more records exist
   495	 * @param {Array<string>} sortFields - Fields used for cursor
   496	 * @returns {Object} Metadata with pageSize, hasMore, and optional cursor
   497	 *
   498	 * @example
   499	 * // Input: Page with more records available
   500	 * const records = [
   501	 *   { id: 10, created_at: '2024-01-10', title: 'Article 10' },
   502	 *   { id: 11, created_at: '2024-01-11', title: 'Article 11' },
   503	 *   { id: 12, created_at: '2024-01-12', title: 'Article 12' }
   504	 * ];
   505	 * const meta = buildCursorMeta(records, 20, true, ['created_at', 'id']);
   506	 *
   507	 * // Output:
   508	 * // {
   509	 * //   pageSize: 20,
   510	 * //   hasMore: true,
   511	 * //   cursor: {
   512	 * //     next: 'created_at:2024-01-12,id:12'  // Cursor from last record
   513	 * //   }
   514	 * // }
   515	 *
   516	 * @example
   517	 * // Input: Last page (no more records)
   518	 * const records = [
   519	 *   { id: 98, name: 'Last Item' }
   520	 * ];
   521	 * const meta = buildCursorMeta(records, 20, false, ['id']);
   522	 *
   523	 * // Output:
   524	 * // {
   525	 * //   pageSize: 20,
   526	 * //   hasMore: false
   527	 * //   // No cursor property since hasMore is false
   528	 * // }
   529	 *
   530	 * @description
   531	 * Used by:
   532	 * - rest-api-knex-plugin adds this to response.meta for cursor pagination
   533	 * - Provides cursor that client can use directly if needed
   534	 *
   535	 * Purpose:
   536	 * - Gives clients direct access to cursor for custom pagination
   537	 * - Indicates whether more pages exist without counting
   538	 * - Simpler than offset pagination metadata (no total count)
   539	 *
   540	 * Data flow:
   541	 * 1. Called after fetching records with cursor query
   542	 * 2. Creates cursor from last record if more exist
   543	 * 3. Added to response.meta.pagination
   544	 * 4. Clients can use cursor directly or use the next link
   545	 */
   546	export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id']) => {
   547	  const meta = {
   548	    pageSize,
   549	    hasMore
   550	  }
   551	
   552	  if (hasMore && records.length > 0) {
   553	    const lastRecord = records[records.length - 1]
   554	    meta.cursor = {
   555	      next: createCursor(lastRecord, sortFields)
   556	    }
   557	  }
   558	
   559	  return meta
   560	}
