@fjell/core 4.4.63 → 4.4.65

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/README.md CHANGED
@@ -134,10 +134,51 @@ import {
134
134
  validateLocations,
135
135
  validateKey,
136
136
  validatePK,
137
- validateKeys
137
+ validateKeys,
138
+ validateSchema,
139
+ SchemaValidator
138
140
  } from '@fjell/core/validation';
139
141
  ```
140
142
 
143
+ #### Schema Validation (Zod, Yup, etc.)
144
+
145
+ Universal schema validation that works with any validator matching the `SchemaValidator` interface. Zod is supported out of the box via duck typing:
146
+
147
+ ```typescript
148
+ import { validateSchema } from '@fjell/core/validation';
149
+ import { z } from 'zod';
150
+
151
+ // Define a Zod schema
152
+ const userSchema = z.object({
153
+ name: z.string().min(3),
154
+ age: z.number().min(18),
155
+ email: z.string().email().optional()
156
+ });
157
+
158
+ // Validate data before caching, sending to API, or persisting
159
+ const validatedUser = await validateSchema(userData, userSchema);
160
+
161
+ // Validation throws ValidationError with FieldError[] on failure
162
+ try {
163
+ await validateSchema({ name: 'Al', age: 10 }, userSchema);
164
+ } catch (error) {
165
+ if (error instanceof ValidationError) {
166
+ console.log(error.fieldErrors);
167
+ // [
168
+ // { path: ['name'], message: 'String must contain at least 3 character(s)', code: 'too_small' },
169
+ // { path: ['age'], message: 'Number must be greater than or equal to 18', code: 'too_small' }
170
+ // ]
171
+ }
172
+ }
173
+ ```
174
+
175
+ **Schema Validation Features:**
176
+ - **Universal**: Works with Zod, Yup, Joi, or any validator matching the interface
177
+ - **Type-Safe**: Full TypeScript support with type inference
178
+ - **Error Transformation**: Automatically converts validator errors to Fjell `ValidationError`
179
+ - **Async Support**: Handles both sync and async validation
180
+ - **Available Everywhere**: Use in cache layers, APIs, persistence, or any system boundary
181
+
141
182
  #### Location Validation
142
183
 
143
184
  Validates that location key arrays match the expected coordinate hierarchy:
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export * from './validation';
15
15
  export * from './errors';
16
16
  export * from './operations';
17
17
  export * from './event';
18
- export type { Operations, OperationParams, AffectedKeys, CreateOptions, UpdateOptions } from './operations/Operations';
18
+ export type { Operations, OperationParams, AffectedKeys, CreateOptions, UpdateOptions, PaginationOptions, AllOptions, FindOptions, PaginationMetadata, OperationResult, AllOperationResult, FindOperationResult } from './operations/Operations';
19
19
  export { isPriKey as isOperationPriKey, isComKey as isOperationComKey } from './operations/Operations';
20
20
  export type { GetMethod, CreateMethod, UpdateMethod, RemoveMethod, UpsertMethod, AllMethod, OneMethod, FindMethod, FindOneMethod, FinderMethod, ActionMethod, ActionOperationMethod, AllActionMethod, AllActionOperationMethod, FacetMethod, FacetOperationMethod, AllFacetMethod, AllFacetOperationMethod, OperationsExtensions } from './operations/methods';
21
21
  export * from './operations/wrappers';
package/dist/index.js CHANGED
@@ -1438,6 +1438,47 @@ var DuplicateError = class extends ActionError {
1438
1438
  }
1439
1439
  };
1440
1440
 
1441
+ // src/validation/schema.ts
1442
+ async function validateSchema(data, schema) {
1443
+ if (!schema) {
1444
+ return data;
1445
+ }
1446
+ try {
1447
+ if (schema.parseAsync) {
1448
+ return await schema.parseAsync(data);
1449
+ }
1450
+ const result = schema.safeParse(data);
1451
+ if (result.success) {
1452
+ return result.data;
1453
+ } else {
1454
+ throw result.error;
1455
+ }
1456
+ } catch (error) {
1457
+ if (error && Array.isArray(error.issues)) {
1458
+ const fieldErrors = error.issues.map((issue) => ({
1459
+ path: issue.path,
1460
+ message: issue.message,
1461
+ code: issue.code
1462
+ }));
1463
+ const validationError = new ValidationError(
1464
+ "Schema validation failed",
1465
+ // eslint-disable-next-line no-undefined
1466
+ void 0,
1467
+ // eslint-disable-next-line no-undefined
1468
+ void 0,
1469
+ // eslint-disable-next-line no-undefined
1470
+ void 0,
1471
+ fieldErrors
1472
+ );
1473
+ throw validationError;
1474
+ }
1475
+ if (error instanceof ValidationError) {
1476
+ throw error;
1477
+ }
1478
+ throw new ValidationError(`Validation failed: ${error.message || "Unknown error"}`);
1479
+ }
1480
+ }
1481
+
1441
1482
  // src/operations/errorEnhancer.ts
1442
1483
  async function executeWithContext(operation, context) {
1443
1484
  try {
@@ -1826,35 +1867,49 @@ function createOneWrapper(coordinate, implementation, options = {}) {
1826
1867
 
1827
1868
  // src/operations/wrappers/createAllWrapper.ts
1828
1869
  var logger10 = logger_default.get("operations", "wrappers", "all");
1829
- function createAllWrapper(coordinate, implementation, options = {}) {
1830
- const operationName = options.operationName || "all";
1831
- return async (query, locations) => {
1832
- if (options.debug) {
1833
- logger10.debug(`[${operationName}] Called with:`, { query, locations });
1870
+ function createAllWrapper(coordinate, implementation, wrapperOptions = {}) {
1871
+ const operationName = wrapperOptions.operationName || "all";
1872
+ return async (query, locations, allOptions) => {
1873
+ if (wrapperOptions.debug) {
1874
+ logger10.debug(`[${operationName}] Called with:`, { query, locations, allOptions });
1834
1875
  }
1835
- if (!options.skipValidation) {
1876
+ if (!wrapperOptions.skipValidation) {
1836
1877
  validateQuery(query, operationName);
1837
1878
  validateLocations(locations, coordinate, operationName);
1879
+ if (allOptions && "limit" in allOptions && allOptions.limit != null) {
1880
+ if (!Number.isInteger(allOptions.limit) || allOptions.limit < 1) {
1881
+ throw new Error(`[${operationName}] limit must be a positive integer, got: ${allOptions.limit}`);
1882
+ }
1883
+ }
1884
+ if (allOptions && "offset" in allOptions && allOptions.offset != null) {
1885
+ if (!Number.isInteger(allOptions.offset) || allOptions.offset < 0) {
1886
+ throw new Error(`[${operationName}] offset must be a non-negative integer, got: ${allOptions.offset}`);
1887
+ }
1888
+ }
1838
1889
  }
1839
1890
  const normalizedQuery = query ?? {};
1840
1891
  const normalizedLocations = locations ?? [];
1841
1892
  try {
1842
- const result = await implementation(normalizedQuery, normalizedLocations);
1843
- if (options.debug) {
1844
- logger10.debug(`[${operationName}] Result: ${result.length} items`);
1893
+ const result = await implementation(normalizedQuery, normalizedLocations, allOptions);
1894
+ if (wrapperOptions.debug) {
1895
+ logger10.debug(`[${operationName}] Result: ${result.items.length} items, total: ${result.metadata.total}`);
1845
1896
  }
1846
- if (!options.skipValidation) {
1847
- return validatePK(result, coordinate.kta[0]);
1897
+ if (!wrapperOptions.skipValidation) {
1898
+ const validatedItems = validatePK(result.items, coordinate.kta[0]);
1899
+ return {
1900
+ items: validatedItems,
1901
+ metadata: result.metadata
1902
+ };
1848
1903
  }
1849
1904
  return result;
1850
1905
  } catch (error) {
1851
- if (options.onError) {
1906
+ if (wrapperOptions.onError) {
1852
1907
  const context = {
1853
1908
  operationName,
1854
- params: [query, locations],
1909
+ params: [query, locations, allOptions],
1855
1910
  coordinate
1856
1911
  };
1857
- throw options.onError(error, context);
1912
+ throw wrapperOptions.onError(error, context);
1858
1913
  }
1859
1914
  throw new Error(
1860
1915
  `[${operationName}] Operation failed: ${error.message}`,
@@ -2082,11 +2137,38 @@ function createRemoveWrapper(coordinate, implementation, options = {}) {
2082
2137
 
2083
2138
  // src/operations/wrappers/createFindWrapper.ts
2084
2139
  var logger16 = logger_default.get("operations", "wrappers", "find");
2140
+ function isFindOperationResult(value) {
2141
+ return value && typeof value === "object" && "items" in value && "metadata" in value && Array.isArray(value.items) && value.metadata && typeof value.metadata === "object" && "total" in value.metadata;
2142
+ }
2143
+ function applyPagination(items, options) {
2144
+ const total = items.length;
2145
+ const offset = options?.offset ?? 0;
2146
+ const limit = options?.limit;
2147
+ let paginatedItems = items;
2148
+ if (offset > 0) {
2149
+ paginatedItems = paginatedItems.slice(offset);
2150
+ }
2151
+ if (limit != null && limit >= 0) {
2152
+ paginatedItems = paginatedItems.slice(0, limit);
2153
+ }
2154
+ const returned = paginatedItems.length;
2155
+ const hasMore = limit != null && offset + returned < total;
2156
+ return {
2157
+ items: paginatedItems,
2158
+ metadata: {
2159
+ total,
2160
+ returned,
2161
+ offset,
2162
+ limit,
2163
+ hasMore
2164
+ }
2165
+ };
2166
+ }
2085
2167
  function createFindWrapper(coordinate, implementation, options = {}) {
2086
2168
  const operationName = options.operationName || "find";
2087
- return async (finder, params, locations) => {
2169
+ return async (finder, params, locations, findOptions) => {
2088
2170
  if (options.debug) {
2089
- logger16.debug(`[${operationName}] Called:`, { finder, params, locations });
2171
+ logger16.debug(`[${operationName}] Called:`, { finder, params, locations, findOptions });
2090
2172
  }
2091
2173
  if (!options.skipValidation) {
2092
2174
  validateFinderName(finder, operationName);
@@ -2096,19 +2178,36 @@ function createFindWrapper(coordinate, implementation, options = {}) {
2096
2178
  const normalizedParams = params ?? {};
2097
2179
  const normalizedLocations = locations ?? [];
2098
2180
  try {
2099
- const result = await implementation(finder, normalizedParams, normalizedLocations);
2100
- if (options.debug) {
2101
- logger16.debug(`[${operationName}] Found ${result.length} items for finder "${finder}"`);
2102
- }
2103
- if (!options.skipValidation) {
2104
- return validatePK(result, coordinate.kta[0]);
2181
+ const result = await implementation(finder, normalizedParams, normalizedLocations, findOptions);
2182
+ if (isFindOperationResult(result)) {
2183
+ if (options.debug) {
2184
+ logger16.debug(`[${operationName}] Finder "${finder}" opted-in to pagination, found ${result.items.length} items (total: ${result.metadata.total})`);
2185
+ }
2186
+ if (!options.skipValidation) {
2187
+ const validatedItems = validatePK(result.items, coordinate.kta[0]);
2188
+ return {
2189
+ items: validatedItems,
2190
+ metadata: result.metadata
2191
+ };
2192
+ }
2193
+ return result;
2194
+ } else {
2195
+ if (options.debug) {
2196
+ logger16.debug(`[${operationName}] Finder "${finder}" using legacy signature, applying post-processing pagination`);
2197
+ }
2198
+ let validatedItems;
2199
+ if (!options.skipValidation) {
2200
+ validatedItems = validatePK(result, coordinate.kta[0]);
2201
+ } else {
2202
+ validatedItems = result;
2203
+ }
2204
+ return applyPagination(validatedItems, findOptions);
2105
2205
  }
2106
- return result;
2107
2206
  } catch (error) {
2108
2207
  if (options.onError) {
2109
2208
  const context = {
2110
2209
  operationName,
2111
- params: [finder, params, locations],
2210
+ params: [finder, params, locations, findOptions],
2112
2211
  coordinate
2113
2212
  };
2114
2213
  throw options.onError(error, context);
@@ -2440,5 +2539,6 @@ export {
2440
2539
  validateOperationParams,
2441
2540
  validatePK,
2442
2541
  validatePriKey,
2443
- validateQuery
2542
+ validateQuery,
2543
+ validateSchema
2444
2544
  };
@@ -19,6 +19,185 @@ export type CreateOptions<S extends string, L1 extends string = never, L2 extend
19
19
  key?: never;
20
20
  locations: LocKeyArray<L1, L2, L3, L4, L5>;
21
21
  };
22
+ /**
23
+ * Base options for pagination operations (all() and find()).
24
+ *
25
+ * Contains the common pagination parameters shared by both operations.
26
+ *
27
+ * @public
28
+ */
29
+ export interface PaginationOptions {
30
+ /**
31
+ * Maximum number of items to return.
32
+ *
33
+ * - Must be a positive integer (>= 1)
34
+ * - When not provided, returns all matching items
35
+ * - Takes precedence over query.limit when both are specified
36
+ */
37
+ limit?: number;
38
+ /**
39
+ * Number of items to skip before returning results.
40
+ *
41
+ * - Must be a non-negative integer (>= 0)
42
+ * - Defaults to 0 when not provided
43
+ * - Takes precedence over query.offset when both are specified
44
+ */
45
+ offset?: number;
46
+ }
47
+ /**
48
+ * Options for the all() operation with pagination support.
49
+ *
50
+ * When provided, these options take precedence over any limit/offset
51
+ * specified in the ItemQuery.
52
+ *
53
+ * @public
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * // Fetch first 50 items
58
+ * const result = await operations.all(query, [], { limit: 50, offset: 0 });
59
+ *
60
+ * // Fetch next page
61
+ * const nextPage = await operations.all(query, [], { limit: 50, offset: 50 });
62
+ * ```
63
+ */
64
+ export interface AllOptions extends PaginationOptions {
65
+ }
66
+ /**
67
+ * Options for the find() operation with pagination support.
68
+ *
69
+ * When provided, these options take precedence over any limit/offset
70
+ * passed to finder functions.
71
+ *
72
+ * @public
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * // Find with pagination
77
+ * const findResult = await operations.find('byEmail', { email: 'test@example.com' }, [], { limit: 10 });
78
+ * ```
79
+ */
80
+ export interface FindOptions extends PaginationOptions {
81
+ }
82
+ /**
83
+ * Metadata about the pagination state for an all() operation.
84
+ *
85
+ * This metadata enables proper pagination UI and logic by providing
86
+ * the total count of matching items before limit/offset are applied.
87
+ *
88
+ * @public
89
+ */
90
+ export interface PaginationMetadata {
91
+ /**
92
+ * Total count of items matching the query BEFORE limit/offset applied.
93
+ *
94
+ * This represents the complete result set size and is used to:
95
+ * - Display "Showing X of Y results"
96
+ * - Calculate total pages
97
+ * - Determine if more items exist
98
+ */
99
+ total: number;
100
+ /**
101
+ * Number of items actually returned in this response.
102
+ *
103
+ * This equals `items.length` and is provided for convenience.
104
+ * When offset + returned < total, more items exist.
105
+ */
106
+ returned: number;
107
+ /**
108
+ * The limit that was applied, if any.
109
+ *
110
+ * - Undefined when no limit was applied
111
+ * - Reflects the effective limit (options.limit ?? query.limit)
112
+ */
113
+ limit?: number;
114
+ /**
115
+ * The offset that was applied.
116
+ *
117
+ * - 0 when no offset was applied
118
+ * - Reflects the effective offset (options.offset ?? query.offset ?? 0)
119
+ */
120
+ offset: number;
121
+ /**
122
+ * Convenience field indicating whether more items exist beyond this page.
123
+ *
124
+ * Calculated as: `offset + returned < total`
125
+ *
126
+ * Useful for:
127
+ * - "Load More" buttons
128
+ * - Infinite scroll implementations
129
+ * - "Next Page" button state
130
+ */
131
+ hasMore: boolean;
132
+ }
133
+ /**
134
+ * Base result structure for paginated operations.
135
+ *
136
+ * This structure provides both the items and metadata needed for
137
+ * implementing proper pagination in applications.
138
+ *
139
+ * @template T - The item type being returned
140
+ *
141
+ * @public
142
+ */
143
+ export interface OperationResult<T> {
144
+ /**
145
+ * Array of items matching the query, with limit/offset applied.
146
+ */
147
+ items: T[];
148
+ /**
149
+ * Pagination metadata for the result set.
150
+ */
151
+ metadata: PaginationMetadata;
152
+ }
153
+ /**
154
+ * Result structure for the all() operation with pagination support.
155
+ *
156
+ * This structure provides both the items and metadata needed for
157
+ * implementing proper pagination in applications.
158
+ *
159
+ * @template T - The item type being returned
160
+ *
161
+ * @public
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * const result = await operations.all(query, [], { limit: 50, offset: 0 });
166
+ *
167
+ * console.log(`Showing ${result.metadata.returned} of ${result.metadata.total}`);
168
+ * // "Showing 50 of 1234"
169
+ *
170
+ * if (result.metadata.hasMore) {
171
+ * // Load next page
172
+ * }
173
+ * ```
174
+ */
175
+ export interface AllOperationResult<T> extends OperationResult<T> {
176
+ }
177
+ /**
178
+ * Result structure for the find() operation with pagination support.
179
+ *
180
+ * This structure provides both the items and metadata needed for
181
+ * implementing proper pagination in find operations.
182
+ *
183
+ * @template T - The item type being returned
184
+ *
185
+ * @public
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * const result = await operations.find('byEmail', { email: 'test@example.com' }, [], { limit: 10 });
190
+ *
191
+ * console.log(`Found ${result.metadata.returned} of ${result.metadata.total} results`);
192
+ * // "Found 10 of 25 results"
193
+ *
194
+ * if (result.metadata.hasMore) {
195
+ * // Load next page
196
+ * }
197
+ * ```
198
+ */
199
+ export interface FindOperationResult<T> extends OperationResult<T> {
200
+ }
22
201
  /**
23
202
  * Options for update operations across all Fjell libraries.
24
203
  *
@@ -97,25 +276,38 @@ export interface UpdateOptions {
97
276
  */
98
277
  export interface Operations<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string = never, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never> {
99
278
  /**
100
- * Retrieves all items matching the query.
279
+ * Retrieves all items matching the query with optional pagination.
101
280
  *
102
- * @param query - Optional query to filter items
281
+ * @param query - Optional query to filter items (may include limit/offset for backwards compatibility)
103
282
  * @param locations - Optional location hierarchy to scope the query
104
- * @returns Array of items matching the query
283
+ * @param options - Optional pagination options (takes precedence over query limit/offset)
284
+ * @returns Result containing items and pagination metadata
105
285
  *
106
- * @example
286
+ * @example Get all items
107
287
  * ```typescript
108
- * // Get all users
109
- * const users = await operations.all();
288
+ * const result = await operations.all();
289
+ * // result.items = all items
290
+ * // result.metadata.total = items.length
291
+ * ```
110
292
  *
111
- * // Get users with filter
112
- * const activeUsers = await operations.all({ filter: { status: 'active' } });
293
+ * @example Get paginated items
294
+ * ```typescript
295
+ * const result = await operations.all({}, [], { limit: 50, offset: 0 });
296
+ * // result.items = first 50 items
297
+ * // result.metadata.total = total matching count
298
+ * // result.metadata.hasMore = true if more exist
299
+ * ```
113
300
  *
114
- * // Get items in specific location
115
- * const comments = await operations.all({}, [{kt: 'post', lk: 'post-123'}]);
301
+ * @example Get items in specific location with pagination
302
+ * ```typescript
303
+ * const result = await operations.all(
304
+ * {},
305
+ * [{kt: 'post', lk: 'post-123'}],
306
+ * { limit: 20, offset: 0 }
307
+ * );
116
308
  * ```
117
309
  */
118
- all(query?: ItemQuery, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
310
+ all(query?: ItemQuery, locations?: LocKeyArray<L1, L2, L3, L4, L5> | [], options?: AllOptions): Promise<AllOperationResult<V>>;
119
311
  /**
120
312
  * Retrieves the first item matching the query.
121
313
  *
@@ -209,27 +401,36 @@ export interface Operations<V extends Item<S, L1, L2, L3, L4, L5>, S extends str
209
401
  */
210
402
  remove(key: PriKey<S> | ComKey<S, L1, L2, L3, L4, L5>): Promise<V | void>;
211
403
  /**
212
- * Executes a finder method by name.
404
+ * Executes a finder method by name with optional pagination support.
405
+ *
406
+ * Supports hybrid pagination approach:
407
+ * - If finder returns FindOperationResult<V>, uses it directly (opt-in)
408
+ * - If finder returns V[], framework applies post-processing pagination
213
409
  *
214
410
  * @param finder - Name of the finder method
215
411
  * @param params - Parameters for the finder
216
412
  * @param locations - Optional location hierarchy to scope the query
217
- * @returns Array of items found
413
+ * @param options - Optional pagination options (limit, offset)
414
+ * @returns Result containing items and pagination metadata
218
415
  *
219
416
  * @example
220
417
  * ```typescript
221
- * // Find users by email
222
- * const users = await operations.find('byEmail', { email: 'alice@example.com' });
418
+ * // Find users by email (no pagination)
419
+ * const result = await operations.find('byEmail', { email: 'alice@example.com' });
420
+ * const users = result.items;
223
421
  *
224
- * // Find in specific location
225
- * const comments = await operations.find(
422
+ * // Find with pagination
423
+ * const result = await operations.find(
226
424
  * 'byAuthor',
227
425
  * { author: 'alice' },
228
- * [{kt: 'post', lk: 'post-123'}]
426
+ * [{kt: 'post', lk: 'post-123'}],
427
+ * { limit: 10, offset: 0 }
229
428
  * );
429
+ * const comments = result.items;
430
+ * const total = result.metadata.total;
230
431
  * ```
231
432
  */
232
- find(finder: string, params?: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
433
+ find(finder: string, params?: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | [], options?: FindOptions): Promise<FindOperationResult<V>>;
233
434
  /**
234
435
  * Executes a finder method and returns the first result.
235
436
  *
@@ -1,7 +1,7 @@
1
1
  import { Item } from "../items";
2
2
  import { ComKey, LocKeyArray } from "../keys";
3
3
  import { ItemQuery } from "../item/ItemQuery";
4
- import { AffectedKeys, OperationParams, Operations } from "./Operations";
4
+ import { AffectedKeys, AllOperationResult, AllOptions, FindOperationResult, FindOptions, OperationParams, Operations } from "./Operations";
5
5
  /**
6
6
  * Contained Operations interface - specialized for contained (hierarchical) items only.
7
7
  *
@@ -47,9 +47,9 @@ import { AffectedKeys, OperationParams, Operations } from "./Operations";
47
47
  * ```
48
48
  */
49
49
  export interface ContainedOperations<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never> extends Omit<Operations<V, S, L1, L2, L3, L4, L5>, 'get' | 'update' | 'remove' | 'upsert' | 'create' | 'action' | 'facet' | 'allAction' | 'allFacet' | 'all' | 'one' | 'find' | 'findOne'> {
50
- all(query: ItemQuery | undefined, locations: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
50
+ all(query: ItemQuery | undefined, locations: LocKeyArray<L1, L2, L3, L4, L5> | [], allOptions?: AllOptions): Promise<AllOperationResult<V>>;
51
51
  one(query: ItemQuery | undefined, locations: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V | null>;
52
- find(finder: string, params: OperationParams | undefined, locations: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
52
+ find(finder: string, params: OperationParams | undefined, locations: LocKeyArray<L1, L2, L3, L4, L5> | [], options?: FindOptions): Promise<FindOperationResult<V>>;
53
53
  findOne(finder: string, params: OperationParams | undefined, locations: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V | null>;
54
54
  create(item: Partial<Item<S, L1, L2, L3, L4, L5>>, options?: {
55
55
  locations?: LocKeyArray<L1, L2, L3, L4, L5>;
@@ -1,7 +1,7 @@
1
1
  import { Item } from "../items";
2
2
  import { ComKey, LocKeyArray, PriKey } from "../keys";
3
3
  import { ItemQuery } from "../item/ItemQuery";
4
- import { AffectedKeys, CreateOptions, OperationParams, UpdateOptions } from "./Operations";
4
+ import { AffectedKeys, AllOperationResult, AllOptions, CreateOptions, FindOperationResult, FindOptions, OperationParams, UpdateOptions } from "./Operations";
5
5
  /**
6
6
  * Get method signature - retrieves single item by key
7
7
  */
@@ -33,10 +33,34 @@ export interface UpsertMethod<V extends Item<S, L1, L2, L3, L4, L5>, S extends s
33
33
  (key: PriKey<S> | ComKey<S, L1, L2, L3, L4, L5>, item: Partial<Item<S, L1, L2, L3, L4, L5>>, locations?: LocKeyArray<L1, L2, L3, L4, L5>, options?: UpdateOptions): Promise<V>;
34
34
  }
35
35
  /**
36
- * All method signature - retrieves all items matching query
36
+ * All method signature - retrieves all items matching query with optional pagination.
37
+ *
38
+ * @param query - Optional query to filter items (may include limit/offset for backwards compatibility)
39
+ * @param locations - Optional location hierarchy to scope the query
40
+ * @param options - Optional pagination options (takes precedence over query limit/offset)
41
+ * @returns Result containing items and pagination metadata
42
+ *
43
+ * @example Without options (backwards compatible)
44
+ * ```typescript
45
+ * const result = await operations.all({ compoundCondition: {...} });
46
+ * // result.items = [...all matching items...]
47
+ * // result.metadata.total = result.items.length
48
+ * ```
49
+ *
50
+ * @example With options (new pattern)
51
+ * ```typescript
52
+ * const result = await operations.all(
53
+ * { compoundCondition: {...} },
54
+ * [],
55
+ * { limit: 50, offset: 0 }
56
+ * );
57
+ * // result.items = [...first 50 items...]
58
+ * // result.metadata.total = total matching count
59
+ * // result.metadata.hasMore = true if more items exist
60
+ * ```
37
61
  */
38
62
  export interface AllMethod<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string = never, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never> {
39
- (query?: ItemQuery, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
63
+ (query?: ItemQuery, locations?: LocKeyArray<L1, L2, L3, L4, L5> | [], options?: AllOptions): Promise<AllOperationResult<V>>;
40
64
  }
41
65
  /**
42
66
  * One method signature - retrieves first item matching query
@@ -45,10 +69,29 @@ export interface OneMethod<V extends Item<S, L1, L2, L3, L4, L5>, S extends stri
45
69
  (query?: ItemQuery, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V | null>;
46
70
  }
47
71
  /**
48
- * Find method signature - finds multiple items using finder
72
+ * Find method signature - finds multiple items using finder with optional pagination.
73
+ *
74
+ * Supports hybrid approach:
75
+ * - If finder returns FindOperationResult<V>, uses it directly (opt-in)
76
+ * - If finder returns V[], framework applies post-processing pagination
77
+ *
78
+ * @param finder - Name of the finder method
79
+ * @param params - Parameters for the finder
80
+ * @param locations - Optional location hierarchy to scope the query
81
+ * @param options - Optional pagination options (limit, offset)
82
+ * @returns Result containing items and pagination metadata
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * // Without pagination (returns all results)
87
+ * const result = await operations.find('byEmail', { email: 'test@example.com' });
88
+ *
89
+ * // With pagination
90
+ * const result = await operations.find('byEmail', { email: 'test@example.com' }, [], { limit: 10, offset: 0 });
91
+ * ```
49
92
  */
50
93
  export interface FindMethod<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string = never, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never> {
51
- (finder: string, params: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
94
+ (finder: string, params: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | [], options?: FindOptions): Promise<FindOperationResult<V>>;
52
95
  }
53
96
  /**
54
97
  * FindOne method signature - finds single item using finder
@@ -57,17 +100,44 @@ export interface FindOneMethod<V extends Item<S, L1, L2, L3, L4, L5>, S extends
57
100
  (finder: string, params: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V | null>;
58
101
  }
59
102
  /**
60
- * Finder method signature - finds multiple items
103
+ * Finder method signature - finds multiple items.
61
104
  *
62
- * @example
105
+ * Supports hybrid approach for pagination:
106
+ * - **Legacy signature**: Return `Promise<V[]>` - framework applies post-processing pagination
107
+ * - **Opt-in signature**: Return `Promise<FindOperationResult<V>>` - finder handles pagination at source
108
+ *
109
+ * @example Legacy finder (framework handles pagination)
63
110
  * ```typescript
64
111
  * const byEmailFinder: FinderMethod<User, 'user'> = async (params) => {
65
112
  * return await database.findUsers({ email: params.email });
66
113
  * };
67
114
  * ```
115
+ *
116
+ * @example Opt-in finder (finder handles pagination)
117
+ * ```typescript
118
+ * const byEmailFinder: FinderMethod<User, 'user'> = async (params, locations, options) => {
119
+ * const query = buildQuery({ email: params.email });
120
+ * const total = await database.count(query);
121
+ *
122
+ * if (options?.offset) query.offset(options.offset);
123
+ * if (options?.limit) query.limit(options.limit);
124
+ *
125
+ * const items = await database.find(query);
126
+ * return {
127
+ * items,
128
+ * metadata: {
129
+ * total,
130
+ * returned: items.length,
131
+ * offset: options?.offset ?? 0,
132
+ * limit: options?.limit,
133
+ * hasMore: (options?.offset ?? 0) + items.length < total
134
+ * }
135
+ * };
136
+ * };
137
+ * ```
68
138
  */
69
139
  export interface FinderMethod<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string = never, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never> {
70
- (params: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | []): Promise<V[]>;
140
+ (params: OperationParams, locations?: LocKeyArray<L1, L2, L3, L4, L5> | [], options?: FindOptions): Promise<V[] | FindOperationResult<V>>;
71
141
  }
72
142
  /**
73
143
  * Action operation method signature - executes action on specific item by key
@@ -1,7 +1,7 @@
1
1
  import { Item } from "../items";
2
2
  import { PriKey } from "../keys";
3
3
  import { ItemQuery } from "../item/ItemQuery";
4
- import { AffectedKeys, OperationParams, Operations } from "./Operations";
4
+ import { AffectedKeys, AllOperationResult, AllOptions, FindOperationResult, FindOptions, OperationParams, Operations } from "./Operations";
5
5
  /**
6
6
  * Primary Operations interface - specialized for primary (top-level) items only.
7
7
  *
@@ -39,9 +39,9 @@ import { AffectedKeys, OperationParams, Operations } from "./Operations";
39
39
  * ```
40
40
  */
41
41
  export interface PrimaryOperations<V extends Item<S>, S extends string> extends Omit<Operations<V, S>, 'all' | 'one' | 'create' | 'get' | 'update' | 'upsert' | 'remove' | 'find' | 'findOne' | 'action' | 'allAction' | 'facet' | 'allFacet'> {
42
- all(query?: ItemQuery): Promise<V[]>;
42
+ all(query?: ItemQuery, locations?: [], allOptions?: AllOptions): Promise<AllOperationResult<V>>;
43
43
  one(query?: ItemQuery): Promise<V | null>;
44
- find(finder: string, params?: OperationParams): Promise<V[]>;
44
+ find(finder: string, params?: OperationParams, locations?: [], options?: FindOptions): Promise<FindOperationResult<V>>;
45
45
  findOne(finder: string, params?: OperationParams): Promise<V | null>;
46
46
  create(item: Partial<Item<S>>, options?: {
47
47
  key?: PriKey<S>;
@@ -19,10 +19,10 @@ import type { WrapperOptions } from "./types";
19
19
  * ```typescript
20
20
  * const all = createAllWrapper(
21
21
  * coordinate,
22
- * async (query, locations) => {
23
- * return await database.findAll(query, locations);
22
+ * async (query, locations, options) => {
23
+ * return await database.findAll(query, locations, options);
24
24
  * }
25
25
  * );
26
26
  * ```
27
27
  */
28
- export declare function createAllWrapper<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string = never, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never>(coordinate: Coordinate<S, L1, L2, L3, L4, L5>, implementation: AllMethod<V, S, L1, L2, L3, L4, L5>, options?: WrapperOptions): AllMethod<V, S, L1, L2, L3, L4, L5>;
28
+ export declare function createAllWrapper<V extends Item<S, L1, L2, L3, L4, L5>, S extends string, L1 extends string = never, L2 extends string = never, L3 extends string = never, L4 extends string = never, L5 extends string = never>(coordinate: Coordinate<S, L1, L2, L3, L4, L5>, implementation: AllMethod<V, S, L1, L2, L3, L4, L5>, wrapperOptions?: WrapperOptions): AllMethod<V, S, L1, L2, L3, L4, L5>;
@@ -1,26 +1,32 @@
1
1
  /**
2
2
  * Wrapper for find() operation
3
3
  *
4
- * Provides automatic validation for find() operation parameters.
4
+ * Provides automatic validation for find() operation parameters and supports
5
+ * hybrid pagination approach:
6
+ * - If finder returns FindOperationResult<V>, uses it directly (opt-in)
7
+ * - If finder returns V[], applies post-processing pagination
5
8
  */
6
9
  import type { Item } from "../../items";
7
10
  import type { Coordinate } from "../../Coordinate";
8
11
  import type { FindMethod } from "../methods";
9
12
  import type { WrapperOptions } from "./types";
10
13
  /**
11
- * Creates a wrapped find() method with automatic parameter validation.
14
+ * Creates a wrapped find() method with automatic parameter validation and hybrid pagination support.
12
15
  *
13
16
  * @param coordinate - The coordinate defining the item hierarchy
14
17
  * @param implementation - The core logic for the operation
15
18
  * @param options - Optional configuration
16
- * @returns A fully validated find() method
19
+ * @returns A fully validated find() method that returns FindOperationResult<V>
17
20
  *
18
21
  * @example
19
22
  * ```typescript
20
23
  * const find = createFindWrapper(
21
24
  * coordinate,
22
- * async (finder, params, locations) => {
23
- * return await database.executeFinder(finder, params, locations);
25
+ * async (finder, params, locations, findOptions) => {
26
+ * const finderMethod = finders[finder];
27
+ * const result = await finderMethod(params, locations, findOptions);
28
+ * // Framework handles hybrid detection and post-processing if needed
29
+ * return result;
24
30
  * }
25
31
  * );
26
32
  * ```
@@ -7,9 +7,11 @@
7
7
  * - Items (Item key type validation)
8
8
  * - Queries (ItemQuery validation)
9
9
  * - Operation parameters (OperationParams validation)
10
+ * - Schema validation (Zod, Yup, etc.)
10
11
  */
11
- export type { ValidationOptions, ValidationResult } from './types';
12
+ export type { ValidationOptions, ValidationResult, SchemaValidator } from './types';
12
13
  export { validateLocations, isValidLocations } from './LocationValidator';
13
14
  export { validateKey, validatePriKey, validateComKey } from './KeyValidator';
14
15
  export { validatePK, validateKeys } from './ItemValidator';
15
16
  export { validateQuery, validateOperationParams, validateFinderName, validateActionName, validateFacetName } from './QueryValidator';
17
+ export { validateSchema } from './schema';
@@ -485,6 +485,96 @@ Received: "${facet}"`
485
485
  }
486
486
  logger5.debug(`Facet name validation passed for ${operation}`, { facet });
487
487
  };
488
+
489
+ // src/errors/ActionError.ts
490
+ var ActionError = class extends Error {
491
+ constructor(errorInfo, cause) {
492
+ super(errorInfo.message);
493
+ this.errorInfo = errorInfo;
494
+ this.name = "ActionError";
495
+ this.cause = cause;
496
+ if (!this.errorInfo.technical) {
497
+ this.errorInfo.technical = { timestamp: (/* @__PURE__ */ new Date()).toISOString() };
498
+ }
499
+ if (!this.errorInfo.technical.timestamp) {
500
+ this.errorInfo.technical.timestamp = (/* @__PURE__ */ new Date()).toISOString();
501
+ }
502
+ }
503
+ toJSON() {
504
+ return this.errorInfo;
505
+ }
506
+ };
507
+
508
+ // src/errors/ValidationError.ts
509
+ var ValidationError = class extends ActionError {
510
+ fieldErrors;
511
+ constructor(message, validOptions, suggestedAction, conflictingValue, fieldErrors) {
512
+ super({
513
+ code: "VALIDATION_ERROR",
514
+ message,
515
+ operation: { type: "create", name: "", params: {} },
516
+ // Will be filled by wrapper
517
+ context: { itemType: "" },
518
+ // Will be filled by wrapper
519
+ details: {
520
+ validOptions,
521
+ suggestedAction,
522
+ retryable: true,
523
+ conflictingValue,
524
+ fieldErrors
525
+ },
526
+ technical: {
527
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
528
+ }
529
+ });
530
+ this.fieldErrors = fieldErrors;
531
+ if (fieldErrors) {
532
+ if (!this.errorInfo.details) this.errorInfo.details = {};
533
+ this.errorInfo.details.fieldErrors = fieldErrors;
534
+ }
535
+ }
536
+ };
537
+
538
+ // src/validation/schema.ts
539
+ async function validateSchema(data, schema) {
540
+ if (!schema) {
541
+ return data;
542
+ }
543
+ try {
544
+ if (schema.parseAsync) {
545
+ return await schema.parseAsync(data);
546
+ }
547
+ const result = schema.safeParse(data);
548
+ if (result.success) {
549
+ return result.data;
550
+ } else {
551
+ throw result.error;
552
+ }
553
+ } catch (error) {
554
+ if (error && Array.isArray(error.issues)) {
555
+ const fieldErrors = error.issues.map((issue) => ({
556
+ path: issue.path,
557
+ message: issue.message,
558
+ code: issue.code
559
+ }));
560
+ const validationError = new ValidationError(
561
+ "Schema validation failed",
562
+ // eslint-disable-next-line no-undefined
563
+ void 0,
564
+ // eslint-disable-next-line no-undefined
565
+ void 0,
566
+ // eslint-disable-next-line no-undefined
567
+ void 0,
568
+ fieldErrors
569
+ );
570
+ throw validationError;
571
+ }
572
+ if (error instanceof ValidationError) {
573
+ throw error;
574
+ }
575
+ throw new ValidationError(`Validation failed: ${error.message || "Unknown error"}`);
576
+ }
577
+ }
488
578
  export {
489
579
  isValidLocations,
490
580
  validateActionName,
@@ -497,5 +587,6 @@ export {
497
587
  validateOperationParams,
498
588
  validatePK,
499
589
  validatePriKey,
500
- validateQuery
590
+ validateQuery,
591
+ validateSchema
501
592
  };
@@ -0,0 +1,23 @@
1
+ import { SchemaValidator } from "./types";
2
+ /**
3
+ * Validates data against a schema validator (Zod, Yup, etc.)
4
+ *
5
+ * Supports both synchronous and asynchronous validation.
6
+ * Transforms validator errors into Fjell ValidationError with FieldError[].
7
+ *
8
+ * @template T - The validated/parsed type
9
+ * @param data - The data to validate
10
+ * @param schema - Optional schema validator
11
+ * @returns Promise resolving to validated/parsed data
12
+ * @throws ValidationError if validation fails
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { validateSchema } from '@fjell/core/validation';
17
+ * import { z } from 'zod';
18
+ *
19
+ * const schema = z.object({ name: z.string() });
20
+ * const validated = await validateSchema({ name: 'Alice' }, schema);
21
+ * ```
22
+ */
23
+ export declare function validateSchema<T>(data: unknown, schema?: SchemaValidator<T>): Promise<T>;
@@ -36,3 +36,34 @@ export interface ValidationResult {
36
36
  */
37
37
  context?: Record<string, unknown>;
38
38
  }
39
+ /**
40
+ * Generic interface for schema validators (Zod, Yup, Joi, etc.)
41
+ *
42
+ * This interface enables duck typing - any validator that implements
43
+ * these methods will work with validateSchema.
44
+ *
45
+ * @template T - The validated/parsed type
46
+ */
47
+ export interface SchemaValidator<T> {
48
+ /**
49
+ * Synchronous parse method.
50
+ * Should throw an error if invalid, or return the parsed data.
51
+ */
52
+ parse: (data: unknown) => T;
53
+ /**
54
+ * Safe parse method that returns a result object instead of throwing.
55
+ * Used as fallback if parseAsync is not available.
56
+ */
57
+ safeParse: (data: unknown) => {
58
+ success: true;
59
+ data: T;
60
+ } | {
61
+ success: false;
62
+ error: any;
63
+ };
64
+ /**
65
+ * Optional asynchronous parse method.
66
+ * Preferred over parse/safeParse if available.
67
+ */
68
+ parseAsync?: (data: unknown) => Promise<T>;
69
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fjell/core",
3
3
  "description": "Core Item and Key Framework for Fjell",
4
- "version": "4.4.63",
4
+ "version": "4.4.65",
5
5
  "keywords": [
6
6
  "core",
7
7
  "fjell"
@@ -41,14 +41,14 @@
41
41
  "docs:test": "cd docs && npm run test"
42
42
  },
43
43
  "dependencies": {
44
- "@fjell/logging": "^4.4.58",
44
+ "@fjell/logging": "^4.4.59",
45
45
  "deepmerge": "^4.3.1",
46
46
  "luxon": "^3.7.2"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@eslint/eslintrc": "^3.3.1",
50
50
  "@eslint/js": "^9.39.1",
51
- "@fjell/common-config": "^1.1.30",
51
+ "@fjell/common-config": "^1.1.31",
52
52
  "@swc/core": "^1.15.2",
53
53
  "@tsconfig/recommended": "^1.0.13",
54
54
  "@types/luxon": "^3.7.1",