@markwharton/liquidplanner 1.12.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -6,7 +6,8 @@
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, LPErrorInfo, LPFindItemsOptions, LPWorkspaceTree } from './types.js';
9
+ import type { Result } from '@markwharton/api-core';
10
+ import type { LPConfig, LPWorkspace, LPMember, LPItem, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPUpsertOptions, LPAssignmentWithContext, LPAncestor, LPFindItemsOptions, LPWorkspaceTree } from './types.js';
10
11
  /**
11
12
  * LiquidPlanner API Client
12
13
  *
@@ -80,34 +81,26 @@ export declare class LPClient {
80
81
  * Fetch a URL and parse the response, with standardized error handling.
81
82
  */
82
83
  private fetchAndParse;
84
+ /**
85
+ * Fetch and parse with isDuplicate support for mutation methods.
86
+ */
87
+ private fetchAndParseMutation;
83
88
  /**
84
89
  * Validate the API token by listing workspaces
85
90
  */
86
- validateToken(): Promise<{
87
- valid: boolean;
88
- error?: string;
89
- }>;
91
+ validateToken(): Promise<Result<void>>;
90
92
  /**
91
93
  * Get all workspaces accessible to the API token
92
94
  */
93
- getWorkspaces(): Promise<{
94
- workspaces?: LPWorkspace[];
95
- error?: LPErrorInfo;
96
- }>;
95
+ getWorkspaces(): Promise<Result<LPWorkspace[]>>;
97
96
  /**
98
97
  * Get all members in the workspace (with pagination)
99
98
  */
100
- getWorkspaceMembers(): Promise<{
101
- members?: LPMember[];
102
- error?: LPErrorInfo;
103
- }>;
99
+ getWorkspaceMembers(): Promise<Result<LPMember[]>>;
104
100
  /**
105
101
  * Get a single item by ID
106
102
  */
107
- getItem(itemId: number): Promise<{
108
- item?: LPItem;
109
- error?: LPErrorInfo;
110
- }>;
103
+ getItem(itemId: number): Promise<Result<LPItem>>;
111
104
  /**
112
105
  * Get multiple items by ID in a single request (batch fetch)
113
106
  *
@@ -116,10 +109,7 @@ export declare class LPClient {
116
109
  *
117
110
  * @param itemIds - Array of item IDs to fetch
118
111
  */
119
- getItems(itemIds: number[]): Promise<{
120
- items?: LPItem[];
121
- error?: LPErrorInfo;
122
- }>;
112
+ getItems(itemIds: number[]): Promise<Result<LPItem[]>>;
123
113
  /**
124
114
  * Get the ancestry chain for an item
125
115
  *
@@ -128,27 +118,18 @@ export declare class LPClient {
128
118
  *
129
119
  * @param itemId - The item ID to get ancestors for
130
120
  */
131
- getItemAncestors(itemId: number): Promise<{
132
- ancestors?: LPAncestor[];
133
- error?: LPErrorInfo;
134
- }>;
121
+ getItemAncestors(itemId: number): Promise<Result<LPAncestor[]>>;
135
122
  /**
136
123
  * Find all assignments under a task (with pagination)
137
124
  */
138
- findAssignments(taskId: number): Promise<{
139
- assignments?: LPItem[];
140
- error?: LPErrorInfo;
141
- }>;
125
+ findAssignments(taskId: number): Promise<Result<LPItem[]>>;
142
126
  /**
143
127
  * Get all assignments for a specific member
144
128
  *
145
129
  * This enables PWA apps to show a task picker populated from LP directly.
146
130
  * Note: userId is not a supported filter field in the LP API, so we filter client-side.
147
131
  */
148
- getMyAssignments(memberId: number): Promise<{
149
- assignments?: LPItem[];
150
- error?: LPErrorInfo;
151
- }>;
132
+ getMyAssignments(memberId: number): Promise<Result<LPItem[]>>;
152
133
  /**
153
134
  * Get assignments for a member with parent task names resolved
154
135
  *
@@ -168,10 +149,7 @@ export declare class LPClient {
168
149
  getMyAssignmentsWithContext(memberId: number, options?: {
169
150
  includeProject?: boolean;
170
151
  includeHierarchy?: boolean;
171
- }): Promise<{
172
- assignments?: LPAssignmentWithContext[];
173
- error?: LPErrorInfo;
174
- }>;
152
+ }): Promise<Result<LPAssignmentWithContext[]>>;
175
153
  /**
176
154
  * Query items with LP API filters
177
155
  *
@@ -183,10 +161,7 @@ export declare class LPClient {
183
161
  *
184
162
  * @param options - Filter options (all optional, combined with AND)
185
163
  */
186
- findItems(options: LPFindItemsOptions): Promise<{
187
- items?: LPItem[];
188
- error?: LPErrorInfo;
189
- }>;
164
+ findItems(options: LPFindItemsOptions): Promise<Result<LPItem[]>>;
190
165
  /**
191
166
  * Get direct children of an item
192
167
  *
@@ -198,10 +173,7 @@ export declare class LPClient {
198
173
  */
199
174
  getChildren(parentId: number, options?: {
200
175
  itemType?: string;
201
- }): Promise<{
202
- items?: LPItem[];
203
- error?: LPErrorInfo;
204
- }>;
176
+ }): Promise<Result<LPItem[]>>;
205
177
  /**
206
178
  * Fetch a snapshot of the active workspace tree
207
179
  *
@@ -214,10 +186,7 @@ export declare class LPClient {
214
186
  * After the initial fetch, all hierarchy queries (ancestors, paths, assignments
215
187
  * with context) can be answered from the cached tree with zero API calls.
216
188
  */
217
- getWorkspaceTree(): Promise<{
218
- tree?: LPWorkspaceTree;
219
- error?: LPErrorInfo;
220
- }>;
189
+ getWorkspaceTree(): Promise<Result<LPWorkspaceTree>>;
221
190
  /**
222
191
  * Get a member's active work with full context from the workspace tree
223
192
  *
@@ -229,18 +198,14 @@ export declare class LPClient {
229
198
  *
230
199
  * @param memberId - The member ID to get work for
231
200
  */
232
- getMyWork(memberId: number): Promise<{
233
- assignments?: LPAssignmentWithContext[];
234
- treeItemCount?: number;
235
- error?: LPErrorInfo;
236
- }>;
201
+ getMyWork(memberId: number): Promise<Result<{
202
+ assignments: LPAssignmentWithContext[];
203
+ treeItemCount: number;
204
+ }>>;
237
205
  /**
238
206
  * Get all cost codes in the workspace (with pagination)
239
207
  */
240
- getCostCodes(): Promise<{
241
- costCodes?: LPCostCode[];
242
- error?: LPErrorInfo;
243
- }>;
208
+ getCostCodes(): Promise<Result<LPCostCode[]>>;
244
209
  /**
245
210
  * Create a timesheet entry (log time)
246
211
  *
@@ -262,10 +227,7 @@ export declare class LPClient {
262
227
  * @param date - Date(s) in YYYY-MM-DD format (string or array)
263
228
  * @param itemId - Optional item ID to filter by
264
229
  */
265
- getTimesheetEntries(date: string | string[], itemId?: number): Promise<{
266
- entries?: LPTimesheetEntryWithId[];
267
- error?: LPErrorInfo;
268
- }>;
230
+ getTimesheetEntries(date: string | string[], itemId?: number): Promise<Result<LPTimesheetEntryWithId[]>>;
269
231
  /**
270
232
  * Update an existing timesheet entry
271
233
  *
package/dist/client.js CHANGED
@@ -10,7 +10,7 @@ import { buildAuthHeader, hoursToMinutes, normalizeItemType, filterIs, filterIsN
10
10
  import { buildTree, getTreeAncestors } from './tree.js';
11
11
  import { parseLPErrorResponse } from './errors.js';
12
12
  import { LP_API_BASE } from './constants.js';
13
- import { TTLCache, batchMap, getErrorMessage, fetchWithRetry } from '@markwharton/api-core';
13
+ import { TTLCache, batchMap, getErrorMessage, fetchWithRetry, ok, err } from '@markwharton/api-core';
14
14
  /** Transform raw API item to LPItem, preserving scheduling and effort fields */
15
15
  function transformItem(raw) {
16
16
  const item = {
@@ -217,12 +217,30 @@ export class LPClient {
217
217
  if (!response.ok) {
218
218
  const errorText = await response.text();
219
219
  const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
220
- return { error: { message, statusCode: response.status, isDuplicate } };
220
+ return { ok: false, error: message, status: response.status, ...(isDuplicate ? { isDuplicate } : {}) };
221
221
  }
222
- return { data: await parse(response) };
222
+ return ok(await parse(response));
223
223
  }
224
224
  catch (error) {
225
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
225
+ return err(getErrorMessage(error), 0);
226
+ }
227
+ }
228
+ /**
229
+ * Fetch and parse with isDuplicate support for mutation methods.
230
+ */
231
+ async fetchAndParseMutation(url, parse, fetchOptions) {
232
+ try {
233
+ const response = await this.fetch(url, fetchOptions);
234
+ if (!response.ok) {
235
+ const errorText = await response.text();
236
+ const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
237
+ return { ok: false, error: message, status: response.status, isDuplicate };
238
+ }
239
+ const data = await parse(response);
240
+ return { ok: true, data };
241
+ }
242
+ catch (error) {
243
+ return { ok: false, error: getErrorMessage(error), status: 0 };
226
244
  }
227
245
  }
228
246
  // ============================================================================
@@ -236,15 +254,15 @@ export class LPClient {
236
254
  try {
237
255
  const response = await this.fetch(url);
238
256
  if (response.ok) {
239
- return { valid: true };
257
+ return { ok: true };
240
258
  }
241
259
  if (response.status === 401 || response.status === 403) {
242
- return { valid: false, error: 'Invalid or expired API token' };
260
+ return err('Invalid or expired API token', response.status);
243
261
  }
244
- return { valid: false, error: `Unexpected response: HTTP ${response.status}` };
262
+ return err(`Unexpected response: HTTP ${response.status}`, response.status);
245
263
  }
246
264
  catch (error) {
247
- return { valid: false, error: getErrorMessage(error) || 'Connection failed' };
265
+ return err(getErrorMessage(error) || 'Connection failed');
248
266
  }
249
267
  }
250
268
  /**
@@ -252,11 +270,10 @@ export class LPClient {
252
270
  */
253
271
  async getWorkspaces() {
254
272
  const url = `${this.baseUrl}/workspaces/v1`;
255
- const { data, error } = await this.fetchAndParse(url, async (r) => {
273
+ return this.fetchAndParse(url, async (r) => {
256
274
  const result = await r.json();
257
275
  return (result.data || []).map(ws => ({ id: ws.id, name: ws.name }));
258
276
  });
259
- return error ? { error } : { workspaces: data };
260
277
  }
261
278
  // ============================================================================
262
279
  // Members
@@ -267,7 +284,7 @@ export class LPClient {
267
284
  async getWorkspaceMembers() {
268
285
  return this.cached('members', this.cacheTtl.membersTtl, async () => {
269
286
  const baseUrl = this.workspaceUrl('users/v1');
270
- const { results, error } = await paginatedFetch({
287
+ return paginatedFetch({
271
288
  fetchFn: (url) => this.fetch(url),
272
289
  baseUrl,
273
290
  transform: (data) => data.map(m => ({
@@ -279,7 +296,6 @@ export class LPClient {
279
296
  userType: m.userType,
280
297
  })),
281
298
  });
282
- return error ? { error } : { members: results };
283
299
  });
284
300
  }
285
301
  // ============================================================================
@@ -291,17 +307,17 @@ export class LPClient {
291
307
  async getItem(itemId) {
292
308
  return this.cached(`item:${itemId}`, this.cacheTtl.itemsTtl, async () => {
293
309
  const url = this.workspaceUrl(`items/v1?${filterIs('id', itemId)}`);
294
- const { data, error } = await this.fetchAndParse(url, async (r) => {
295
- const result = await r.json();
296
- if (!result.data || result.data.length === 0)
310
+ const result = await this.fetchAndParse(url, async (r) => {
311
+ const json = await r.json();
312
+ if (!json.data || json.data.length === 0)
297
313
  return null;
298
- return transformItem(result.data[0]);
314
+ return transformItem(json.data[0]);
299
315
  });
300
- if (error)
301
- return { error };
302
- if (!data)
303
- return { error: { message: `Item ${itemId} not found`, statusCode: 404 } };
304
- return { item: data };
316
+ if (!result.ok)
317
+ return result;
318
+ if (!result.data)
319
+ return err(`Item ${itemId} not found`, 404);
320
+ return ok(result.data);
305
321
  });
306
322
  }
307
323
  /**
@@ -314,14 +330,13 @@ export class LPClient {
314
330
  */
315
331
  async getItems(itemIds) {
316
332
  if (itemIds.length === 0)
317
- return { items: [] };
333
+ return ok([]);
318
334
  const baseUrl = this.workspaceUrl(`items/v1?${filterIn('id', itemIds)}`);
319
- const { results, error } = await paginatedFetch({
335
+ return paginatedFetch({
320
336
  fetchFn: (url) => this.fetch(url),
321
337
  baseUrl,
322
338
  transform: (data) => data.map(transformItem),
323
339
  });
324
- return error ? { error } : { items: results };
325
340
  }
326
341
  /**
327
342
  * Get the ancestry chain for an item
@@ -334,7 +349,7 @@ export class LPClient {
334
349
  async getItemAncestors(itemId) {
335
350
  return this.cached(`ancestors:${itemId}`, this.cacheTtl.itemsTtl, async () => {
336
351
  const url = this.workspaceUrl(`items/v1/${itemId}/ancestors`);
337
- const { data, error } = await this.fetchAndParse(url, async (r) => {
352
+ return this.fetchAndParse(url, async (r) => {
338
353
  const json = (await r.json());
339
354
  // Handle both { data: [...] } and direct array responses
340
355
  const rawData = Array.isArray(json) ? json : (json.data || []);
@@ -344,7 +359,6 @@ export class LPClient {
344
359
  itemType: normalizeItemType(a.itemType),
345
360
  })).reverse(); // LP API returns child→root, normalize to root→child
346
361
  }, { description: `Get ancestors for item ${itemId}` });
347
- return error ? { error } : { ancestors: data };
348
362
  });
349
363
  }
350
364
  /**
@@ -353,12 +367,11 @@ export class LPClient {
353
367
  async findAssignments(taskId) {
354
368
  // parentId[is]="{taskId}"&itemType[is]="assignments" (LP API uses lowercase plural)
355
369
  const baseUrl = this.workspaceUrl(`items/v1?${filterIs('parentId', taskId)}&${filterIs('itemType', 'assignments')}`);
356
- const { results, error } = await paginatedFetch({
370
+ return paginatedFetch({
357
371
  fetchFn: (url) => this.fetch(url),
358
372
  baseUrl,
359
373
  transform: (data) => data.map(transformItem),
360
374
  });
361
- return error ? { error } : { assignments: results };
362
375
  }
363
376
  /**
364
377
  * Get all assignments for a specific member
@@ -369,13 +382,12 @@ export class LPClient {
369
382
  async getMyAssignments(memberId) {
370
383
  return this.cached(`assignments:${memberId}`, this.cacheTtl.assignmentsTtl, async () => {
371
384
  const baseUrl = this.workspaceUrl(`items/v1?${filterIs('itemType', 'assignments')}`);
372
- const { results, error } = await paginatedFetch({
385
+ return paginatedFetch({
373
386
  fetchFn: (url) => this.fetch(url),
374
387
  baseUrl,
375
388
  filter: (data) => data.filter(item => item.userId === memberId),
376
389
  transform: (data) => data.map(transformItem),
377
390
  });
378
- return error ? { error } : { assignments: results };
379
391
  });
380
392
  }
381
393
  /**
@@ -396,11 +408,12 @@ export class LPClient {
396
408
  */
397
409
  async getMyAssignmentsWithContext(memberId, options) {
398
410
  // 1. Get raw assignments
399
- const { assignments, error } = await this.getMyAssignments(memberId);
400
- if (error || !assignments)
401
- return { error };
411
+ const assignResult = await this.getMyAssignments(memberId);
412
+ if (!assignResult.ok)
413
+ return assignResult;
414
+ const assignments = assignResult.data;
402
415
  if (assignments.length === 0)
403
- return { assignments: [] };
416
+ return ok([]);
404
417
  // 2. Handle based on options
405
418
  let taskMap = new Map();
406
419
  let projectMap = new Map();
@@ -417,12 +430,12 @@ export class LPClient {
417
430
  }
418
431
  const parentEntries = [...assignmentsByParent.entries()];
419
432
  const ancestorResults = await batchMap(parentEntries, 5, async ([parentId, assignment]) => {
420
- const { ancestors, error } = await this.getItemAncestors(assignment.id);
421
- return { parentId, ancestors, error };
433
+ const result = await this.getItemAncestors(assignment.id);
434
+ return { parentId, ancestors: result.data, error: result.ok ? undefined : result };
422
435
  });
423
436
  const firstError = ancestorResults.find(r => r.error);
424
437
  if (firstError) {
425
- return { error: firstError.error };
438
+ return firstError.error;
426
439
  }
427
440
  for (const { parentId, ancestors } of ancestorResults) {
428
441
  ancestorMap.set(parentId, ancestors);
@@ -431,72 +444,70 @@ export class LPClient {
431
444
  else {
432
445
  // Original path: batch fetch tasks first
433
446
  const taskIds = [...new Set(assignments.map(a => a.parentId).filter((id) => id !== undefined))];
434
- const { items: tasks, error: taskError } = await this.getItems(taskIds);
435
- if (taskError)
436
- return { error: taskError };
437
- for (const task of tasks || []) {
447
+ const taskResult = await this.getItems(taskIds);
448
+ if (!taskResult.ok)
449
+ return taskResult;
450
+ for (const task of taskResult.data || []) {
438
451
  taskMap.set(task.id, task);
439
452
  }
440
453
  if (options?.includeProject) {
441
454
  // Also fetch grandparent projects
442
455
  const projectIds = [...new Set([...taskMap.values()].map(t => t.parentId).filter((id) => id !== undefined))];
443
- const { items: projects, error: projectError } = await this.getItems(projectIds);
444
- if (projectError)
445
- return { error: projectError };
446
- for (const project of projects || []) {
456
+ const projectResult = await this.getItems(projectIds);
457
+ if (!projectResult.ok)
458
+ return projectResult;
459
+ for (const project of projectResult.data || []) {
447
460
  projectMap.set(project.id, project);
448
461
  }
449
462
  }
450
463
  }
451
464
  // 3. Merge context into assignments
452
- return {
453
- assignments: assignments.map(a => {
454
- const result = { ...a };
455
- if (options?.includeHierarchy && a.parentId) {
456
- // Full hierarchy mode - extract task name from ancestors
457
- const ancestors = ancestorMap.get(a.parentId);
458
- result.ancestors = ancestors;
459
- if (ancestors && ancestors.length > 0) {
460
- // Extract task name from first Task ancestor
461
- const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
462
- result.taskName = taskAncestor?.name ?? null;
463
- // Build hierarchyPath from Project and Folder ancestors
464
- // Exclude system containers (Package, WorkspaceRoot) and Tasks
465
- const hierarchyAncestors = ancestors
466
- .filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder');
467
- if (hierarchyAncestors.length > 0) {
468
- result.hierarchyPath = hierarchyAncestors
469
- .map(anc => anc.name ?? `[${anc.id}]`)
470
- .join(' ');
471
- // Set projectId/projectName from root (first in reversed array)
472
- result.projectId = hierarchyAncestors[0].id;
473
- result.projectName = hierarchyAncestors[0].name;
474
- }
475
- else {
476
- result.projectName = null;
477
- }
465
+ return ok(assignments.map(a => {
466
+ const result = { ...a };
467
+ if (options?.includeHierarchy && a.parentId) {
468
+ // Full hierarchy mode - extract task name from ancestors
469
+ const ancestors = ancestorMap.get(a.parentId);
470
+ result.ancestors = ancestors;
471
+ if (ancestors && ancestors.length > 0) {
472
+ // Extract task name from first Task ancestor
473
+ const taskAncestor = ancestors.find(anc => anc.itemType === 'Task');
474
+ result.taskName = taskAncestor?.name ?? null;
475
+ // Build hierarchyPath from Project and Folder ancestors
476
+ // Exclude system containers (Package, WorkspaceRoot) and Tasks
477
+ const hierarchyAncestors = ancestors
478
+ .filter(anc => anc.itemType === 'Project' || anc.itemType === 'Folder');
479
+ if (hierarchyAncestors.length > 0) {
480
+ result.hierarchyPath = hierarchyAncestors
481
+ .map(anc => anc.name ?? `[${anc.id}]`)
482
+ .join(' ');
483
+ // Set projectId/projectName from root (first in reversed array)
484
+ result.projectId = hierarchyAncestors[0].id;
485
+ result.projectName = hierarchyAncestors[0].name;
478
486
  }
479
487
  else {
480
- result.taskName = null;
481
488
  result.projectName = null;
482
489
  }
483
490
  }
484
491
  else {
485
- // Original path - use taskMap
486
- const task = a.parentId ? taskMap.get(a.parentId) : undefined;
487
- result.taskName = task?.name ?? null;
488
- if (options?.includeProject) {
489
- const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
490
- result.projectId = project?.id;
491
- result.projectName = project?.name ?? null;
492
- }
493
- else {
494
- result.projectName = null;
495
- }
492
+ result.taskName = null;
493
+ result.projectName = null;
496
494
  }
497
- return result;
498
- }),
499
- };
495
+ }
496
+ else {
497
+ // Original path - use taskMap
498
+ const task = a.parentId ? taskMap.get(a.parentId) : undefined;
499
+ result.taskName = task?.name ?? null;
500
+ if (options?.includeProject) {
501
+ const project = task?.parentId ? projectMap.get(task.parentId) : undefined;
502
+ result.projectId = project?.id;
503
+ result.projectName = project?.name ?? null;
504
+ }
505
+ else {
506
+ result.projectName = null;
507
+ }
508
+ }
509
+ return result;
510
+ }));
500
511
  }
501
512
  // ============================================================================
502
513
  // Item Queries (Rich Filtering)
@@ -561,12 +572,11 @@ export class LPClient {
561
572
  }
562
573
  const query = filters.length > 0 ? `?${joinFilters(...filters)}` : '';
563
574
  const baseUrl = this.workspaceUrl(`items/v1${query}`);
564
- const { results, error } = await paginatedFetch({
575
+ return paginatedFetch({
565
576
  fetchFn: (url) => this.fetch(url),
566
577
  baseUrl,
567
578
  transform: (data) => data.map(transformItem),
568
579
  });
569
- return error ? { error } : { items: results };
570
580
  }
571
581
  /**
572
582
  * Get direct children of an item
@@ -601,11 +611,11 @@ export class LPClient {
601
611
  async getWorkspaceTree() {
602
612
  return this.cached('tree', this.cacheTtl.treeTtl, async () => {
603
613
  // Fetch all workspace items in paginated calls
604
- const { items, error } = await this.findItems({});
605
- if (error || !items)
606
- return { error };
607
- const tree = buildTree(items);
608
- return { tree };
614
+ const result = await this.findItems({});
615
+ if (!result.ok)
616
+ return err(result.error, result.status);
617
+ const tree = buildTree(result.data);
618
+ return ok(tree);
609
619
  });
610
620
  }
611
621
  /**
@@ -620,9 +630,10 @@ export class LPClient {
620
630
  * @param memberId - The member ID to get work for
621
631
  */
622
632
  async getMyWork(memberId) {
623
- const { tree, error } = await this.getWorkspaceTree();
624
- if (error || !tree)
625
- return { error };
633
+ const treeResult = await this.getWorkspaceTree();
634
+ if (!treeResult.ok)
635
+ return err(treeResult.error, treeResult.status);
636
+ const tree = treeResult.data;
626
637
  const assignments = [];
627
638
  for (const node of tree.byId.values()) {
628
639
  if (node.itemType !== 'Assignment' || node.userId !== memberId)
@@ -646,7 +657,7 @@ export class LPClient {
646
657
  }
647
658
  assignments.push(result);
648
659
  }
649
- return { assignments, treeItemCount: tree.itemCount };
660
+ return ok({ assignments, treeItemCount: tree.itemCount });
650
661
  }
651
662
  // ============================================================================
652
663
  // Cost Codes
@@ -657,7 +668,7 @@ export class LPClient {
657
668
  async getCostCodes() {
658
669
  return this.cached('costcodes', this.cacheTtl.costCodesTtl, async () => {
659
670
  const baseUrl = this.workspaceUrl('cost-codes/v1');
660
- const { results, error } = await paginatedFetch({
671
+ return paginatedFetch({
661
672
  fetchFn: (url) => this.fetch(url),
662
673
  baseUrl,
663
674
  transform: (data) => data.map(cc => ({
@@ -666,7 +677,6 @@ export class LPClient {
666
677
  billable: cc.billable,
667
678
  })),
668
679
  });
669
- return error ? { error } : { costCodes: results };
670
680
  });
671
681
  }
672
682
  // ============================================================================
@@ -695,15 +705,11 @@ export class LPClient {
695
705
  if (note) {
696
706
  body.note = note;
697
707
  }
698
- const { data, error } = await this.fetchAndParse(url, async (r) => {
708
+ return this.fetchAndParseMutation(url, async (r) => {
699
709
  this.cache?.invalidate('timesheet:');
700
710
  const result = await r.json();
701
711
  return result.id;
702
712
  }, { method: 'POST', body });
703
- if (error) {
704
- return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
705
- }
706
- return { success: true, entryId: data };
707
713
  }
708
714
  /**
709
715
  * Get timesheet entries for one or more dates
@@ -730,7 +736,7 @@ export class LPClient {
730
736
  if (itemId) {
731
737
  baseUrl += `&${filterIs('itemId', itemId)}`;
732
738
  }
733
- const { results, error } = await paginatedFetch({
739
+ return paginatedFetch({
734
740
  fetchFn: (url) => this.fetch(url),
735
741
  baseUrl,
736
742
  transform: (data) => data.map(entry => ({
@@ -743,7 +749,6 @@ export class LPClient {
743
749
  userId: entry.userId,
744
750
  })),
745
751
  });
746
- return error ? { error } : { entries: results };
747
752
  });
748
753
  }
749
754
  /**
@@ -779,13 +784,10 @@ export class LPClient {
779
784
  note: updates.note ?? '',
780
785
  userId: existingEntry.userId,
781
786
  };
782
- const { error } = await this.fetchAndParse(url, async () => {
787
+ return this.fetchAndParseMutation(url, async () => {
783
788
  this.cache?.invalidate('timesheet:');
789
+ return entryId;
784
790
  }, { method: 'PUT', body });
785
- if (error) {
786
- return { success: false, error: error.message, statusCode: error.statusCode, isDuplicate: error.isDuplicate };
787
- }
788
- return { success: true, entryId };
789
791
  }
790
792
  /**
791
793
  * Create or update a timesheet entry (upsert)
@@ -824,14 +826,14 @@ export class LPClient {
824
826
  async upsertTimesheetEntry(entry, options = {}) {
825
827
  const { accumulate = true } = options;
826
828
  // Fetch existing entries for this date/item first
827
- const { entries, error: fetchError } = await this.getTimesheetEntries(entry.date, entry.itemId);
828
- if (fetchError) {
829
- return { success: false, error: fetchError.message, statusCode: fetchError.statusCode };
829
+ const fetchResult = await this.getTimesheetEntries(entry.date, entry.itemId);
830
+ if (!fetchResult.ok) {
831
+ return { ok: false, error: fetchResult.error, status: fetchResult.status };
830
832
  }
831
833
  // Find matching entry
832
834
  // If no costCodeId specified, match any entry (LP uses assignment's default)
833
835
  // If costCodeId specified, match exactly
834
- const existingEntry = entries?.find((e) => {
836
+ const existingEntry = fetchResult.data?.find((e) => {
835
837
  if (entry.costCodeId === undefined || entry.costCodeId === null) {
836
838
  return true;
837
839
  }
package/dist/errors.d.ts CHANGED
@@ -28,12 +28,12 @@ export declare function parseLPErrorResponse(errorText: string, statusCode: numb
28
28
  */
29
29
  export declare class LPError extends Error {
30
30
  /** HTTP status code */
31
- statusCode: number;
31
+ status: number;
32
32
  /** Whether this is a duplicate entry error */
33
33
  isDuplicate: boolean;
34
34
  /** Raw error response */
35
35
  rawResponse?: string;
36
- constructor(message: string, statusCode: number, options?: {
36
+ constructor(message: string, status: number, options?: {
37
37
  isDuplicate?: boolean;
38
38
  rawResponse?: string;
39
39
  });
package/dist/errors.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * - {"message":"..."} or {"error":"..."}
7
7
  * - Plain text
8
8
  */
9
+ import { parseJsonErrorResponse } from '@markwharton/api-core';
9
10
  /** Error code for duplicate entry errors */
10
11
  const LP_ERROR_CODE_DUPLICATE = 'duplicate_value';
11
12
  /**
@@ -25,10 +26,8 @@ export function parseLPErrorResponse(errorText, statusCode) {
25
26
  const message = firstError?.detail || firstError?.title || `HTTP ${statusCode}`;
26
27
  return { message, isDuplicate };
27
28
  }
28
- // Fallback to message/error fields
29
- return {
30
- message: errorJson.message || errorJson.error || `HTTP ${statusCode}`,
31
- };
29
+ // Fallback to common JSON error formats via api-core
30
+ return parseJsonErrorResponse(errorText, statusCode);
32
31
  }
33
32
  catch {
34
33
  // Not JSON, return as-is or fallback
@@ -39,10 +38,10 @@ export function parseLPErrorResponse(errorText, statusCode) {
39
38
  * Custom error class for LP API errors
40
39
  */
41
40
  export class LPError extends Error {
42
- constructor(message, statusCode, options) {
41
+ constructor(message, status, options) {
43
42
  super(message);
44
43
  this.name = 'LPError';
45
- this.statusCode = statusCode;
44
+ this.status = status;
46
45
  this.isDuplicate = options?.isDuplicate ?? false;
47
46
  this.rawResponse = options?.rawResponse;
48
47
  }
package/dist/index.d.ts CHANGED
@@ -10,10 +10,12 @@
10
10
  * const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
11
11
  *
12
12
  * // Validate credentials
13
- * await client.validateToken();
13
+ * const result = await client.validateToken();
14
+ * if (!result.ok) { console.error(result.error); }
14
15
  *
15
16
  * // Get workspaces
16
- * const { workspaces } = await client.getWorkspaces();
17
+ * const wsResult = await client.getWorkspaces();
18
+ * const workspaces = wsResult.data;
17
19
  *
18
20
  * // Resolve task to assignment
19
21
  * const resolution = await resolveTaskToAssignment(client, taskId, memberId);
@@ -28,11 +30,12 @@
28
30
  */
29
31
  export { LPClient } from './client.js';
30
32
  export { resolveTaskToAssignment } from './workflows.js';
31
- export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPResult, LPUpsertOptions, LPAssignmentWithContext, LPErrorInfo, LPFindItemsOptions, LPTreeNode, LPWorkspaceTree, } from './types.js';
33
+ export type { LPConfig, LPCacheConfig, LPRetryConfig, LPItemType, HierarchyItem, LPItem, LPAncestor, LPWorkspace, LPMember, LPCostCode, LPSyncResult, LPTimesheetEntry, LPTimesheetEntryWithId, LPTaskResolution, LPUpsertOptions, LPAssignmentWithContext, LPFindItemsOptions, LPTreeNode, LPWorkspaceTree, } from './types.js';
34
+ export { ok, err, getErrorMessage } from '@markwharton/api-core';
35
+ export type { Result, RetryConfig } from '@markwharton/api-core';
32
36
  export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIsNot, filterIn, filterGt, filterLt, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
33
37
  export type { PaginateOptions } from './utils.js';
34
38
  export { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree, } from './tree.js';
35
- export { getErrorMessage } from '@markwharton/api-core';
36
39
  export { LP_API_BASE } from './constants.js';
37
40
  export { LPError, parseLPErrorResponse } from './errors.js';
38
41
  export type { LPParsedError } from './errors.js';
package/dist/index.js CHANGED
@@ -10,10 +10,12 @@
10
10
  * const client = new LPClient({ apiToken: 'xxx', workspaceId: 123 });
11
11
  *
12
12
  * // Validate credentials
13
- * await client.validateToken();
13
+ * const result = await client.validateToken();
14
+ * if (!result.ok) { console.error(result.error); }
14
15
  *
15
16
  * // Get workspaces
16
- * const { workspaces } = await client.getWorkspaces();
17
+ * const wsResult = await client.getWorkspaces();
18
+ * const workspaces = wsResult.data;
17
19
  *
18
20
  * // Resolve task to assignment
19
21
  * const resolution = await resolveTaskToAssignment(client, taskId, memberId);
@@ -30,11 +32,12 @@
30
32
  export { LPClient } from './client.js';
31
33
  // Workflows
32
34
  export { resolveTaskToAssignment } from './workflows.js';
35
+ // Re-export Result from api-core
36
+ export { ok, err, getErrorMessage } from '@markwharton/api-core';
33
37
  // Utilities
34
38
  export { hoursToMinutes, normalizeItemType, buildAuthHeader, filterIs, filterIsNot, filterIn, filterGt, filterLt, filterAfter, filterBefore, joinFilters, paginatedFetch, } from './utils.js';
35
39
  // Tree utilities
36
40
  export { buildTree, getTreeAncestors, getTreeHierarchyPath, findInTree, } from './tree.js';
37
- export { getErrorMessage } from '@markwharton/api-core';
38
41
  // Constants
39
42
  export { LP_API_BASE } from './constants.js';
40
43
  // Errors
package/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * These types define the data structures used when interacting with
5
5
  * the LiquidPlanner API.
6
6
  */
7
+ import type { Result, RetryConfig } from '@markwharton/api-core';
7
8
  /**
8
9
  * LiquidPlanner item types in the hierarchy
9
10
  */
@@ -192,18 +193,9 @@ export interface LPCacheConfig {
192
193
  /**
193
194
  * Retry configuration for LPClient
194
195
  *
195
- * Controls automatic retry behavior for transient failures
196
- * (HTTP 429 Too Many Requests, 503 Service Unavailable).
197
- * Uses exponential backoff with optional Retry-After header support.
196
+ * @deprecated Use RetryConfig from @markwharton/api-core instead
198
197
  */
199
- export interface LPRetryConfig {
200
- /** Maximum number of retry attempts (default: 3) */
201
- maxRetries?: number;
202
- /** Initial delay in milliseconds before first retry (default: 1000) */
203
- initialDelayMs?: number;
204
- /** Maximum delay cap in milliseconds (default: 10000) */
205
- maxDelayMs?: number;
206
- }
198
+ export type LPRetryConfig = RetryConfig;
207
199
  /**
208
200
  * LiquidPlanner configuration for API access
209
201
  */
@@ -227,16 +219,10 @@ export interface LPConfig {
227
219
  }
228
220
  /**
229
221
  * Result of a timesheet sync operation
222
+ *
223
+ * Extends Result<number> where data is the entry ID.
230
224
  */
231
- export interface LPSyncResult {
232
- /** Whether the operation succeeded */
233
- success: boolean;
234
- /** ID of the created entry (if successful) */
235
- entryId?: number;
236
- /** Error message (if failed) */
237
- error?: string;
238
- /** HTTP status code (if failed) - useful for detecting rate limits (429) */
239
- statusCode?: number;
225
+ export interface LPSyncResult extends Result<number> {
240
226
  /** Whether the error was due to a duplicate entry */
241
227
  isDuplicate?: boolean;
242
228
  }
@@ -264,15 +250,6 @@ export interface LPTimesheetEntryWithId extends LPTimesheetEntry {
264
250
  /** User ID who logged the time */
265
251
  userId?: number;
266
252
  }
267
- /**
268
- * Generic result wrapper for LP operations
269
- */
270
- export interface LPResult<T> {
271
- /** The data if successful */
272
- data?: T;
273
- /** Error message if failed */
274
- error?: string;
275
- }
276
253
  /**
277
254
  * Options for upsert timesheet entry operation
278
255
  */
@@ -302,19 +279,6 @@ export interface LPAssignmentWithContext extends LPItem {
302
279
  /** Formatted hierarchy path like "Project A › Subfolder B" (undefined if not requested) */
303
280
  hierarchyPath?: string;
304
281
  }
305
- /**
306
- * Structured error information from LP API
307
- *
308
- * Preserves HTTP status code for proper error handling (e.g., 429 rate limits).
309
- */
310
- export interface LPErrorInfo {
311
- /** Human-readable error message */
312
- message: string;
313
- /** HTTP status code from the response */
314
- statusCode: number;
315
- /** Whether this error indicates a duplicate entry */
316
- isDuplicate?: boolean;
317
- }
318
282
  /**
319
283
  * Options for querying items with LP API filters
320
284
  *
package/dist/utils.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * LiquidPlanner Utility Functions
3
3
  */
4
- import type { LPItemType, LPErrorInfo } from './types.js';
4
+ import type { LPItemType } from './types.js';
5
+ import type { Result } from '@markwharton/api-core';
5
6
  /**
6
7
  * Build a URL-encoded filter for LP API: field[is]="value"
7
8
  */
@@ -56,10 +57,7 @@ export interface PaginateOptions<TRaw, TResult> {
56
57
  *
57
58
  * Handles the continuation token pattern used by LP API.
58
59
  */
59
- export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<{
60
- results?: TResult[];
61
- error?: LPErrorInfo;
62
- }>;
60
+ export declare function paginatedFetch<TRaw, TResult>(options: PaginateOptions<TRaw, TResult>): Promise<Result<TResult[]>>;
63
61
  /**
64
62
  * Convert decimal hours to minutes
65
63
  *
package/dist/utils.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * LiquidPlanner Utility Functions
3
3
  */
4
4
  import { parseLPErrorResponse } from './errors.js';
5
- import { getErrorMessage } from '@markwharton/api-core';
5
+ import { getErrorMessage, err } from '@markwharton/api-core';
6
6
  // ============================================================================
7
7
  // LP API Filter Builders
8
8
  // ============================================================================
@@ -88,8 +88,8 @@ export async function paginatedFetch(options) {
88
88
  const response = await fetchFn(url);
89
89
  if (!response.ok) {
90
90
  const errorText = await response.text();
91
- const { message, isDuplicate } = parseLPErrorResponse(errorText, response.status);
92
- return { error: { message, statusCode: response.status, isDuplicate } };
91
+ const { message } = parseLPErrorResponse(errorText, response.status);
92
+ return err(message, response.status);
93
93
  }
94
94
  const result = await response.json();
95
95
  const rawData = result.data || [];
@@ -98,11 +98,11 @@ export async function paginatedFetch(options) {
98
98
  allResults.push(...pageResults);
99
99
  continuationToken = result.continuationToken;
100
100
  } while (continuationToken);
101
- return { results: allResults };
101
+ return { ok: true, data: allResults };
102
102
  }
103
103
  catch (error) {
104
104
  // Network errors or JSON parse errors don't have HTTP status codes
105
- return { error: { message: getErrorMessage(error), statusCode: 0 } };
105
+ return err(getErrorMessage(error), 0);
106
106
  }
107
107
  }
108
108
  /**
package/dist/workflows.js CHANGED
@@ -37,14 +37,15 @@
37
37
  */
38
38
  export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
39
39
  // Step 1: Fetch the item
40
- const { item, error: fetchError } = await client.getItem(itemId);
41
- if (fetchError || !item) {
40
+ const itemResult = await client.getItem(itemId);
41
+ if (!itemResult.ok || !itemResult.data) {
42
42
  return {
43
43
  inputItem: { id: itemId, name: null, itemType: 'Task' },
44
44
  assignmentId: 0,
45
- error: fetchError?.message || 'Item not found',
45
+ error: itemResult.error || 'Item not found',
46
46
  };
47
47
  }
48
+ const item = itemResult.data;
48
49
  // Step 2: Check item type and resolve accordingly
49
50
  switch (item.itemType) {
50
51
  case 'Assignment':
@@ -57,14 +58,15 @@ export async function resolveTaskToAssignment(client, itemId, lpMemberId) {
57
58
  };
58
59
  case 'Task': {
59
60
  // Find assignments under this task
60
- const { assignments, error: assignmentError } = await client.findAssignments(item.id);
61
- if (assignmentError) {
61
+ const assignResult = await client.findAssignments(item.id);
62
+ if (!assignResult.ok) {
62
63
  return {
63
64
  inputItem: item,
64
65
  assignmentId: 0,
65
- error: `Failed to find assignments: ${assignmentError.message}`,
66
+ error: `Failed to find assignments: ${assignResult.error}`,
66
67
  };
67
68
  }
69
+ const assignments = assignResult.data;
68
70
  if (!assignments || assignments.length === 0) {
69
71
  return {
70
72
  inputItem: item,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/liquidplanner",
3
- "version": "1.12.0",
3
+ "version": "2.0.0",
4
4
  "description": "LiquidPlanner API client for timesheet integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,7 @@
16
16
  "clean": "rm -rf dist"
17
17
  },
18
18
  "dependencies": {
19
- "@markwharton/api-core": "^1.0.0"
19
+ "@markwharton/api-core": "^1.1.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.10.0",
package/dist/cache.d.ts DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * Simple in-memory TTL cache with request coalescing
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
- * Request coalescing: when multiple concurrent callers request the
10
- * same expired key, only one factory call is made. All callers
11
- * receive the same resolved value (or the same rejection).
12
- *
13
- * Not a distributed cache: each instance has its own cache.
14
- * Cold starts and instance recycling naturally clear stale data.
15
- */
16
- export declare class TTLCache {
17
- private store;
18
- private inflight;
19
- /**
20
- * Get a cached value, or call the factory to populate it.
21
- *
22
- * If a factory call is already in progress for this key,
23
- * returns the existing promise instead of starting a duplicate.
24
- *
25
- * @param key - Cache key
26
- * @param ttlMs - Time-to-live in milliseconds
27
- * @param factory - Async function to produce the value on cache miss
28
- */
29
- get<T>(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T>;
30
- /**
31
- * Invalidate cache entries matching a key prefix.
32
- *
33
- * Also cancels any in-flight requests for matching keys,
34
- * so subsequent calls will start fresh factory invocations.
35
- *
36
- * Example: invalidate('timesheet:') clears all timesheet entries.
37
- */
38
- invalidate(prefix: string): void;
39
- /**
40
- * Clear all cached data and in-flight requests.
41
- */
42
- clear(): void;
43
- }
package/dist/cache.js DELETED
@@ -1,78 +0,0 @@
1
- /**
2
- * Simple in-memory TTL cache with request coalescing
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
- * Request coalescing: when multiple concurrent callers request the
10
- * same expired key, only one factory call is made. All callers
11
- * receive the same resolved value (or the same rejection).
12
- *
13
- * Not a distributed cache: each instance has its own cache.
14
- * Cold starts and instance recycling naturally clear stale data.
15
- */
16
- export class TTLCache {
17
- constructor() {
18
- this.store = new Map();
19
- this.inflight = new Map();
20
- }
21
- /**
22
- * Get a cached value, or call the factory to populate it.
23
- *
24
- * If a factory call is already in progress for this key,
25
- * returns the existing promise instead of starting a duplicate.
26
- *
27
- * @param key - Cache key
28
- * @param ttlMs - Time-to-live in milliseconds
29
- * @param factory - Async function to produce the value on cache miss
30
- */
31
- async get(key, ttlMs, factory) {
32
- const existing = this.store.get(key);
33
- if (existing && existing.expiresAt > Date.now()) {
34
- return existing.data;
35
- }
36
- const pending = this.inflight.get(key);
37
- if (pending) {
38
- return pending;
39
- }
40
- const promise = factory().then((data) => {
41
- this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
42
- this.inflight.delete(key);
43
- return data;
44
- }, (err) => {
45
- this.inflight.delete(key);
46
- throw err;
47
- });
48
- this.inflight.set(key, promise);
49
- return promise;
50
- }
51
- /**
52
- * Invalidate cache entries matching a key prefix.
53
- *
54
- * Also cancels any in-flight requests for matching keys,
55
- * so subsequent calls will start fresh factory invocations.
56
- *
57
- * Example: invalidate('timesheet:') clears all timesheet entries.
58
- */
59
- invalidate(prefix) {
60
- for (const key of this.store.keys()) {
61
- if (key.startsWith(prefix)) {
62
- this.store.delete(key);
63
- }
64
- }
65
- for (const key of this.inflight.keys()) {
66
- if (key.startsWith(prefix)) {
67
- this.inflight.delete(key);
68
- }
69
- }
70
- }
71
- /**
72
- * Clear all cached data and in-flight requests.
73
- */
74
- clear() {
75
- this.store.clear();
76
- this.inflight.clear();
77
- }
78
- }