@markwharton/liquidplanner 1.4.1 → 1.6.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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Simple in-memory TTL cache
3
+ *
4
+ * Provides per-instance memoization for LPClient API responses.
5
+ * In serverless environments (Azure Functions, Static Web Apps),
6
+ * module-level state persists across warm invocations within the
7
+ * same instance — this cache leverages that behavior.
8
+ *
9
+ * Not a distributed cache: each instance has its own cache.
10
+ * Cold starts and instance recycling naturally clear stale data.
11
+ */
12
+ export declare class TTLCache {
13
+ private store;
14
+ /**
15
+ * Get a cached value, or call the factory to populate it.
16
+ *
17
+ * @param key - Cache key
18
+ * @param ttlMs - Time-to-live in milliseconds
19
+ * @param factory - Async function to produce the value on cache miss
20
+ */
21
+ get<T>(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T>;
22
+ /**
23
+ * Invalidate cache entries matching a key prefix.
24
+ *
25
+ * Example: invalidate('timesheet:') clears all timesheet entries.
26
+ */
27
+ invalidate(prefix: string): void;
28
+ /**
29
+ * Clear all cached data.
30
+ */
31
+ clear(): void;
32
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Simple in-memory TTL cache
3
+ *
4
+ * Provides per-instance memoization for LPClient API responses.
5
+ * In serverless environments (Azure Functions, Static Web Apps),
6
+ * module-level state persists across warm invocations within the
7
+ * same instance — this cache leverages that behavior.
8
+ *
9
+ * Not a distributed cache: each instance has its own cache.
10
+ * Cold starts and instance recycling naturally clear stale data.
11
+ */
12
+ export class TTLCache {
13
+ constructor() {
14
+ this.store = new Map();
15
+ }
16
+ /**
17
+ * Get a cached value, or call the factory to populate it.
18
+ *
19
+ * @param key - Cache key
20
+ * @param ttlMs - Time-to-live in milliseconds
21
+ * @param factory - Async function to produce the value on cache miss
22
+ */
23
+ async get(key, ttlMs, factory) {
24
+ const existing = this.store.get(key);
25
+ if (existing && existing.expiresAt > Date.now()) {
26
+ return existing.data;
27
+ }
28
+ const data = await factory();
29
+ this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
30
+ return data;
31
+ }
32
+ /**
33
+ * Invalidate cache entries matching a key prefix.
34
+ *
35
+ * Example: invalidate('timesheet:') clears all timesheet entries.
36
+ */
37
+ invalidate(prefix) {
38
+ for (const key of this.store.keys()) {
39
+ if (key.startsWith(prefix)) {
40
+ this.store.delete(key);
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Clear all cached data.
46
+ */
47
+ clear() {
48
+ this.store.clear();
49
+ }
50
+ }
package/dist/client.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @see https://api-docs.liquidplanner.com/
8
8
  */
9
- import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor } from './types.js';
9
+ import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPErrorInfo } from './types.js';
10
10
  /**
11
11
  * LiquidPlanner API Client
12
12
  *
@@ -34,7 +34,18 @@ export declare class LPClient {
34
34
  private readonly workspaceId;
35
35
  private readonly baseUrl;
36
36
  private readonly onRequest?;
37
+ private readonly cache?;
38
+ private readonly cacheTtl;
37
39
  constructor(config: LPConfig);
40
+ /**
41
+ * Route through cache if enabled, otherwise call factory directly.
42
+ */
43
+ private cached;
44
+ /**
45
+ * Clear all cached API responses.
46
+ * Useful after external changes that the cache wouldn't know about.
47
+ */
48
+ clearCache(): void;
38
49
  /**
39
50
  * Make an authenticated request to the LP API
40
51
  */
@@ -51,21 +62,21 @@ export declare class LPClient {
51
62
  */
52
63
  getWorkspaces(): Promise<{
53
64
  workspaces?: LPWorkspace[];
54
- error?: string;
65
+ error?: LPErrorInfo;
55
66
  }>;
56
67
  /**
57
68
  * Get all members in the workspace (with pagination)
58
69
  */
59
70
  getWorkspaceMembers(): Promise<{
60
71
  members?: LPMember[];
61
- error?: string;
72
+ error?: LPErrorInfo;
62
73
  }>;
63
74
  /**
64
75
  * Get a single item by ID
65
76
  */
66
77
  getItem(itemId: number): Promise<{
67
78
  item?: LPItem;
68
- error?: string;
79
+ error?: LPErrorInfo;
69
80
  }>;
70
81
  /**
71
82
  * Get multiple items by ID in a single request (batch fetch)
@@ -77,7 +88,7 @@ export declare class LPClient {
77
88
  */
78
89
  getItems(itemIds: number[]): Promise<{
79
90
  items?: LPItem[];
80
- error?: string;
91
+ error?: LPErrorInfo;
81
92
  }>;
82
93
  /**
83
94
  * Get the ancestry chain for an item
@@ -89,14 +100,14 @@ export declare class LPClient {
89
100
  */
90
101
  getItemAncestors(itemId: number): Promise<{
91
102
  ancestors?: LPAncestor[];
92
- error?: string;
103
+ error?: LPErrorInfo;
93
104
  }>;
94
105
  /**
95
106
  * Find all assignments under a task (with pagination)
96
107
  */
97
108
  findAssignments(taskId: number): Promise<{
98
109
  assignments?: LPItem[];
99
- error?: string;
110
+ error?: LPErrorInfo;
100
111
  }>;
101
112
  /**
102
113
  * Get all assignments for a specific member
@@ -106,7 +117,7 @@ export declare class LPClient {
106
117
  */
107
118
  getMyAssignments(memberId: number): Promise<{
108
119
  assignments?: LPItem[];
109
- error?: string;
120
+ error?: LPErrorInfo;
110
121
  }>;
111
122
  /**
112
123
  * Get assignments for a member with parent task names resolved
@@ -129,14 +140,14 @@ export declare class LPClient {
129
140
  includeHierarchy?: boolean;
130
141
  }): Promise<{
131
142
  assignments?: LPAssignmentWithContext[];
132
- error?: string;
143
+ error?: LPErrorInfo;
133
144
  }>;
134
145
  /**
135
146
  * Get all cost codes in the workspace (with pagination)
136
147
  */
137
148
  getCostCodes(): Promise<{
138
149
  costCodes?: LPCostCode[];
139
- error?: string;
150
+ error?: LPErrorInfo;
140
151
  }>;
141
152
  /**
142
153
  * Create a timesheet entry (log time)
@@ -146,19 +157,22 @@ export declare class LPClient {
146
157
  */
147
158
  createTimesheetEntry(entry: LPTimesheetEntry): Promise<LPSyncResult>;
148
159
  /**
149
- * Get timesheet entries for a specific date
160
+ * Get timesheet entries for one or more dates
150
161
  *
151
162
  * Uses the Logged Time Entries API to query existing entries.
152
- * GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
163
+ * Supports both single date and multi-date queries using LP's date[in] filter.
164
+ *
165
+ * Multi-date queries reduce API calls — e.g., fetching a full week's entries
166
+ * in a single request instead of 7 separate calls.
153
167
  *
154
168
  * @see https://api-docs.liquidplanner.com/docs/task-status-1
155
169
  *
156
- * @param date - Date in YYYY-MM-DD format
170
+ * @param date - Date(s) in YYYY-MM-DD format (string or array)
157
171
  * @param itemId - Optional item ID to filter by
158
172
  */
159
- getTimesheetEntries(date: string, itemId?: number): Promise<{
173
+ getTimesheetEntries(date: string | string[], itemId?: number): Promise<{
160
174
  entries?: LPTimesheetEntryWithId[];
161
- error?: string;
175
+ error?: LPErrorInfo;
162
176
  }>;
163
177
  /**
164
178
  * Update an existing timesheet entry
package/dist/client.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIn, paginatedFetch, } from './utils.js';
10
10
  import { parseLPErrorResponse, getErrorMessage } from './errors.js';
11
11
  import { LP_API_BASE } from './constants.js';
12
+ import { TTLCache } from './cache.js';
12
13
  /** Transform raw API item to LPItem */
13
14
  function transformItem(raw) {
14
15
  return {
@@ -48,6 +49,33 @@ export class LPClient {
48
49
  this.workspaceId = config.workspaceId;
49
50
  this.baseUrl = config.baseUrl ?? LP_API_BASE;
50
51
  this.onRequest = config.onRequest;
52
+ // Initialize cache if configured
53
+ if (config.cache) {
54
+ this.cache = new TTLCache();
55
+ }
56
+ this.cacheTtl = {
57
+ membersTtl: config.cache?.membersTtl ?? 300000,
58
+ costCodesTtl: config.cache?.costCodesTtl ?? 300000,
59
+ timesheetTtl: config.cache?.timesheetTtl ?? 60000,
60
+ assignmentsTtl: config.cache?.assignmentsTtl ?? 120000,
61
+ itemsTtl: config.cache?.itemsTtl ?? 300000,
62
+ };
63
+ }
64
+ /**
65
+ * Route through cache if enabled, otherwise call factory directly.
66
+ */
67
+ async cached(key, ttlMs, factory) {
68
+ if (this.cache) {
69
+ return this.cache.get(key, ttlMs, factory);
70
+ }
71
+ return factory();
72
+ }
73
+ /**
74
+ * Clear all cached API responses.
75
+ * Useful after external changes that the cache wouldn't know about.
76
+ */
77
+ clearCache() {
78
+ this.cache?.clear();
51
79
  }
52
80
  /**
53
81
  * Make an authenticated request to the LP API
@@ -96,8 +124,8 @@ export class LPClient {
96
124
  const response = await this.fetch(url);
97
125
  if (!response.ok) {
98
126
  const errorText = await response.text();
99
- const { message } = parseLPErrorResponse(errorText, response.status);
100
- return { error: message };
127
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
128
+ return { error: { message, statusCode: response.status, isDuplicate } };
101
129
  }
102
130
  const result = await response.json();
103
131
  const workspaces = (result.data || []).map(ws => ({
@@ -107,7 +135,7 @@ export class LPClient {
107
135
  return { workspaces };
108
136
  }
109
137
  catch (error) {
110
- return { error: getErrorMessage(error) };
138
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
111
139
  }
112
140
  }
113
141
  // ============================================================================
@@ -117,20 +145,22 @@ export class LPClient {
117
145
  * Get all members in the workspace (with pagination)
118
146
  */
119
147
  async getWorkspaceMembers() {
120
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
121
- const { results, error } = await paginatedFetch({
122
- fetchFn: (url) => this.fetch(url),
123
- baseUrl,
124
- transform: (data) => data.map(m => ({
125
- id: m.id,
126
- username: m.username,
127
- email: m.email,
128
- firstName: m.firstName,
129
- lastName: m.lastName,
130
- userType: m.userType,
131
- })),
148
+ return this.cached('members', this.cacheTtl.membersTtl, async () => {
149
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/users/v1`;
150
+ const { results, error } = await paginatedFetch({
151
+ fetchFn: (url) => this.fetch(url),
152
+ baseUrl,
153
+ transform: (data) => data.map(m => ({
154
+ id: m.id,
155
+ username: m.username,
156
+ email: m.email,
157
+ firstName: m.firstName,
158
+ lastName: m.lastName,
159
+ userType: m.userType,
160
+ })),
161
+ });
162
+ return error ? { error } : { members: results };
132
163
  });
133
- return error ? { error } : { members: results };
134
164
  }
135
165
  // ============================================================================
136
166
  // Items
@@ -139,23 +169,25 @@ export class LPClient {
139
169
  * Get a single item by ID
140
170
  */
141
171
  async getItem(itemId) {
142
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
143
- try {
144
- const response = await this.fetch(url);
145
- if (!response.ok) {
146
- const errorText = await response.text();
147
- const { message } = parseLPErrorResponse(errorText, response.status);
148
- return { error: message };
172
+ return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
173
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('id', itemId)}`;
174
+ try {
175
+ const response = await this.fetch(url);
176
+ if (!response.ok) {
177
+ const errorText = await response.text();
178
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
179
+ return { error: { message, statusCode: response.status, isDuplicate } };
180
+ }
181
+ const result = await response.json();
182
+ if (!result.data || result.data.length === 0) {
183
+ return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
184
+ }
185
+ return { item: transformItem(result.data[0]) };
149
186
  }
150
- const result = await response.json();
151
- if (!result.data || result.data.length === 0) {
152
- return { error: `Item ${itemId} not found` };
187
+ catch (error) {
188
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
153
189
  }
154
- return { item: transformItem(result.data[0]) };
155
- }
156
- catch (error) {
157
- return { error: getErrorMessage(error) };
158
- }
190
+ });
159
191
  }
160
192
  /**
161
193
  * Get multiple items by ID in a single request (batch fetch)
@@ -185,29 +217,31 @@ export class LPClient {
185
217
  * @param itemId - The item ID to get ancestors for
186
218
  */
187
219
  async getItemAncestors(itemId) {
188
- const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
189
- try {
190
- const response = await this.fetch(url, {
191
- description: `Get ancestors for item ${itemId}`,
192
- });
193
- if (!response.ok) {
194
- const errorText = await response.text();
195
- const { message } = parseLPErrorResponse(errorText, response.status);
196
- return { error: message };
220
+ return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
221
+ const url = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1/${itemId}/ancestors`;
222
+ try {
223
+ const response = await this.fetch(url, {
224
+ description: `Get ancestors for item ${itemId}`,
225
+ });
226
+ if (!response.ok) {
227
+ const errorText = await response.text();
228
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
229
+ return { error: { message, statusCode: response.status, isDuplicate } };
230
+ }
231
+ const json = (await response.json());
232
+ // Handle both { data: [...] } and direct array responses
233
+ const rawData = Array.isArray(json) ? json : (json.data || []);
234
+ const ancestors = rawData.map((a) => ({
235
+ id: a.id,
236
+ name: a.name || null,
237
+ itemType: normalizeItemType(a.itemType),
238
+ }));
239
+ return { ancestors };
197
240
  }
198
- const json = (await response.json());
199
- // Handle both { data: [...] } and direct array responses
200
- const rawData = Array.isArray(json) ? json : (json.data || []);
201
- const ancestors = rawData.map((a) => ({
202
- id: a.id,
203
- name: a.name || null,
204
- itemType: normalizeItemType(a.itemType),
205
- }));
206
- return { ancestors };
207
- }
208
- catch (error) {
209
- return { error: getErrorMessage(error) };
210
- }
241
+ catch (error) {
242
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
243
+ }
244
+ });
211
245
  }
212
246
  /**
213
247
  * Find all assignments under a task (with pagination)
@@ -229,14 +263,16 @@ export class LPClient {
229
263
  * Note: userId is not a supported filter field in the LP API, so we filter client-side.
230
264
  */
231
265
  async getMyAssignments(memberId) {
232
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
233
- const { results, error } = await paginatedFetch({
234
- fetchFn: (url) => this.fetch(url),
235
- baseUrl,
236
- filter: (data) => data.filter(item => item.userId === memberId),
237
- transform: (data) => data.map(transformItem),
266
+ return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
267
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/items/v1?${filterIs('itemType', 'assignments')}`;
268
+ const { results, error } = await paginatedFetch({
269
+ fetchFn: (url) => this.fetch(url),
270
+ baseUrl,
271
+ filter: (data) => data.filter(item => item.userId === memberId),
272
+ transform: (data) => data.map(transformItem),
273
+ });
274
+ return error ? { error } : { assignments: results };
238
275
  });
239
- return error ? { error } : { assignments: results };
240
276
  }
241
277
  /**
242
278
  * Get assignments for a member with parent task names resolved
@@ -365,17 +401,19 @@ export class LPClient {
365
401
  * Get all cost codes in the workspace (with pagination)
366
402
  */
367
403
  async getCostCodes() {
368
- const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
369
- const { results, error } = await paginatedFetch({
370
- fetchFn: (url) => this.fetch(url),
371
- baseUrl,
372
- transform: (data) => data.map(cc => ({
373
- id: cc.id,
374
- name: cc.name,
375
- billable: cc.billable,
376
- })),
404
+ return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
405
+ const baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/cost-codes/v1`;
406
+ const { results, error } = await paginatedFetch({
407
+ fetchFn: (url) => this.fetch(url),
408
+ baseUrl,
409
+ transform: (data) => data.map(cc => ({
410
+ id: cc.id,
411
+ name: cc.name,
412
+ billable: cc.billable,
413
+ })),
414
+ });
415
+ return error ? { error } : { costCodes: results };
377
416
  });
378
- return error ? { error } : { costCodes: results };
379
417
  }
380
418
  // ============================================================================
381
419
  // Timesheet
@@ -408,47 +446,57 @@ export class LPClient {
408
446
  if (!response.ok) {
409
447
  const errorText = await response.text();
410
448
  const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
411
- return { success: false, error: message, isDuplicate };
449
+ return { success: false, error: message, statusCode: response.status, isDuplicate };
412
450
  }
451
+ // Invalidate cached timesheet entries for this date
452
+ this.cache?.invalidate(`timesheet:`);
413
453
  const result = await response.json();
414
454
  return { success: true, entryId: result.id };
415
455
  }
416
456
  catch (error) {
417
- return { success: false, error: getErrorMessage(error) };
457
+ return { success: false, error: getErrorMessage(error), statusCode: 0 };
418
458
  }
419
459
  }
420
460
  /**
421
- * Get timesheet entries for a specific date
461
+ * Get timesheet entries for one or more dates
422
462
  *
423
463
  * Uses the Logged Time Entries API to query existing entries.
424
- * GET /api/workspaces/{workspaceId}/logged-time-entries/v1?date[in]=["{date}"]&itemId[is]="{itemId}"
464
+ * Supports both single date and multi-date queries using LP's date[in] filter.
465
+ *
466
+ * Multi-date queries reduce API calls — e.g., fetching a full week's entries
467
+ * in a single request instead of 7 separate calls.
425
468
  *
426
469
  * @see https://api-docs.liquidplanner.com/docs/task-status-1
427
470
  *
428
- * @param date - Date in YYYY-MM-DD format
471
+ * @param date - Date(s) in YYYY-MM-DD format (string or array)
429
472
  * @param itemId - Optional item ID to filter by
430
473
  */
431
474
  async getTimesheetEntries(date, itemId) {
432
- // Build query with date[in] filter (date field uses [in] operator with array)
433
- let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', [date])}`;
434
- // Optional filter by itemId
435
- if (itemId) {
436
- baseUrl += `&${filterIs('itemId', itemId)}`;
437
- }
438
- const { results, error } = await paginatedFetch({
439
- fetchFn: (url) => this.fetch(url),
440
- baseUrl,
441
- transform: (data) => data.map(entry => ({
442
- id: entry.id,
443
- date: entry.date,
444
- itemId: entry.itemId,
445
- hours: entry.loggedEntriesInMinutes / 60,
446
- costCodeId: entry.costCodeId,
447
- note: entry.note,
448
- userId: entry.userId,
449
- })),
475
+ const dates = Array.isArray(date) ? date : [date];
476
+ const sortedKey = [...dates].sort().join(',');
477
+ const cacheKey = itemId ? `timesheet:${sortedKey}:${itemId}` : `timesheet:${sortedKey}`;
478
+ return this.cached(cacheKey, this.cacheTtl.timesheetTtl, async () => {
479
+ // Build query with date[in] filter (supports multiple dates)
480
+ let baseUrl = `${this.baseUrl}/workspaces/${this.workspaceId}/logged-time-entries/v1?${filterIn('date', dates)}`;
481
+ // Optional filter by itemId
482
+ if (itemId) {
483
+ baseUrl += `&${filterIs('itemId', itemId)}`;
484
+ }
485
+ const { results, error } = await paginatedFetch({
486
+ fetchFn: (url) => this.fetch(url),
487
+ baseUrl,
488
+ transform: (data) => data.map(entry => ({
489
+ id: entry.id,
490
+ date: entry.date,
491
+ itemId: entry.itemId,
492
+ hours: entry.loggedEntriesInMinutes / 60,
493
+ costCodeId: entry.costCodeId,
494
+ note: entry.note,
495
+ userId: entry.userId,
496
+ })),
497
+ });
498
+ return error ? { error } : { entries: results };
450
499
  });
451
- return error ? { error } : { entries: results };
452
500
  }
453
501
  /**
454
502
  * Update an existing timesheet entry
@@ -488,12 +536,14 @@ export class LPClient {
488
536
  if (!response.ok) {
489
537
  const errorText = await response.text();
490
538
  const { message } = parseLPErrorResponse(errorText, response.status);
491
- return { success: false, error: message };
539
+ return { success: false, error: message, statusCode: response.status };
492
540
  }
541
+ // Invalidate cached timesheet entries
542
+ this.cache?.invalidate(`timesheet:`);
493
543
  return { success: true, entryId };
494
544
  }
495
545
  catch (error) {
496
- return { success: false, error: getErrorMessage(error) };
546
+ return { success: false, error: getErrorMessage(error), statusCode: 0 };
497
547
  }
498
548
  }
499
549
  /**
@@ -535,7 +585,7 @@ export class LPClient {
535
585
  // Fetch existing entries for this date/item first
536
586
  const { entries, error: fetchError } = await this.getTimesheetEntries(entry.date, entry.itemId);
537
587
  if (fetchError) {
538
- return { success: false, error: fetchError };
588
+ return { success: false, error: fetchError.message, statusCode: fetchError.statusCode };
539
589
  }
540
590
  // Find matching entry
541
591
  // If no costCodeId specified, match any entry (LP uses assignment's default)
package/dist/index.d.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  */
29
29
  export { LPClient } from './client.js';
30
30
  export { resolveTaskToAssignment } from './workflows.js';
31
- export type { LPConfig, LPItemType, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, } from './types.js';
31
+ export type { LPConfig, LPCacheConfig, LPItemType, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, } from './types.js';
32
32
  export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIn, paginatedFetch, } from './utils.js';
33
33
  export type { PaginateOptions } from './utils.js';
34
34
  export { LP_API_BASE } from './constants.js';
package/dist/types.d.ts CHANGED
@@ -92,6 +92,29 @@ export interface LPTaskResolution {
92
92
  /** Error message if resolution failed */
93
93
  error?: string;
94
94
  }
95
+ /**
96
+ * Cache configuration for LPClient
97
+ *
98
+ * When provided to LPConfig, enables in-memory TTL caching of API responses.
99
+ * All TTL values are in milliseconds. Omit individual TTLs to use defaults.
100
+ *
101
+ * Caching uses a simple in-memory Map that persists across warm invocations
102
+ * in serverless environments (Azure Functions, Static Web Apps). Each instance
103
+ * has its own independent cache — this is per-instance memoization, not a
104
+ * distributed cache. Cold starts and instance recycling naturally clear data.
105
+ */
106
+ export interface LPCacheConfig {
107
+ /** TTL for workspace members (default: 300000 = 5 min) */
108
+ membersTtl?: number;
109
+ /** TTL for cost codes (default: 300000 = 5 min) */
110
+ costCodesTtl?: number;
111
+ /** TTL for timesheet entries (default: 60000 = 60s) */
112
+ timesheetTtl?: number;
113
+ /** TTL for user assignments (default: 120000 = 2 min) */
114
+ assignmentsTtl?: number;
115
+ /** TTL for items and ancestors (default: 300000 = 5 min) */
116
+ itemsTtl?: number;
117
+ }
95
118
  /**
96
119
  * LiquidPlanner configuration for API access
97
120
  */
@@ -108,6 +131,8 @@ export interface LPConfig {
108
131
  url: string;
109
132
  description?: string;
110
133
  }) => void;
134
+ /** Enable caching with optional TTL overrides. Omit to disable caching. */
135
+ cache?: LPCacheConfig;
111
136
  }
112
137
  /**
113
138
  * Result of a timesheet sync operation
@@ -119,6 +144,8 @@ export interface LPSyncResult {
119
144
  entryId?: number;
120
145
  /** Error message (if failed) */
121
146
  error?: string;
147
+ /** HTTP status code (if failed) - useful for detecting rate limits (429) */
148
+ statusCode?: number;
122
149
  /** Whether the error was due to a duplicate entry */
123
150
  isDuplicate?: boolean;
124
151
  }
@@ -184,3 +211,16 @@ export interface LPAssignmentWithContext extends LPItem {
184
211
  /** Formatted hierarchy path like "Project A › Subfolder B" (undefined if not requested) */
185
212
  hierarchyPath?: string;
186
213
  }
214
+ /**
215
+ * Structured error information from LP API
216
+ *
217
+ * Preserves HTTP status code for proper error handling (e.g., 429 rate limits).
218
+ */
219
+ export interface LPErrorInfo {
220
+ /** Human-readable error message */
221
+ message: string;
222
+ /** HTTP status code from the response */
223
+ statusCode: number;
224
+ /** Whether this error indicates a duplicate entry */
225
+ isDuplicate?: boolean;
226
+ }
package/dist/utils.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * LiquidPlanner Utility Functions
3
3
  */
4
- import type { LPItemType } from './types.js';
4
+ import type { LPItemType, LPErrorInfo } from './types.js';
5
5
  /**
6
6
  * Build a URL-encoded filter for LP API: field[is]="value"
7
7
  */
@@ -30,7 +30,7 @@ export interface PaginateOptions<TRaw, TResult> {
30
30
  */
31
31
  export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<{
32
32
  results?: TResult[];
33
- error?: string;
33
+ error?: LPErrorInfo;
34
34
  }>;
35
35
  /**
36
36
  * Convert decimal hours to minutes
package/dist/utils.js CHANGED
@@ -36,8 +36,8 @@ export async function paginatedFetch(options) {
36
36
  const response = await fetchFn(url);
37
37
  if (!response.ok) {
38
38
  const errorText = await response.text();
39
- const { message } = parseLPErrorResponse(errorText, response.status);
40
- return { error: message };
39
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
40
+ return { error: { message, statusCode: response.status, isDuplicate } };
41
41
  }
42
42
  const result = await response.json();
43
43
  const rawData = result.data || [];
@@ -49,7 +49,8 @@ export async function paginatedFetch(options) {
49
49
  return { results: allResults };
50
50
  }
51
51
  catch (error) {
52
- return { error: getErrorMessage(error) };
52
+ // Network errors or JSON parse errors don't have HTTP status codes
53
+ return { error: { message: getErrorMessage(error), statusCode: 0 } };
53
54
  }
54
55
  }
55
56
  /**
package/dist/workflows.js CHANGED
@@ -42,7 +42,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
42
42
  return {
43
43
  inputItem: { id: itemId, name: null, itemType: 'Task' },
44
44
  assignmentId: 0,
45
- error: fetchError || 'Item not found',
45
+ error: fetchError?.message || 'Item not found',
46
46
  };
47
47
  }
48
48
  // Step 2: Check item type and resolve accordingly
@@ -62,7 +62,7 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
62
62
  return {
63
63
  inputItem: item,
64
64
  assignmentId: 0,
65
- error: `Failed to find assignments: ${assignmentError}`,
65
+ error: `Failed to find assignments: ${assignmentError.message}`,
66
66
  };
67
67
  }
68
68
  if (!assignments || assignments.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",