basketeer 0.2.0 → 0.3.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/README.md CHANGED
@@ -196,6 +196,11 @@ await client.orders.cancel(orders[0]!.orderNo);
196
196
  // "Reorder my usual shop":
197
197
  const last = await client.orders.lastFulfilled();
198
198
  for (const it of last?.items ?? []) await client.basket.set(it.productId!, it.quantity, it.unit ?? "pcs");
199
+
200
+ // Completed orders, newest first, offset-paged (Tesco has no cursor or total).
201
+ // The result set is LIVE — dedupe by order.id and never persist nextOffset.
202
+ let page = await client.orders.history(); // { orders, nextOffset }
203
+ while (page.nextOffset !== null) page = await client.orders.history({ offset: page.nextOffset });
199
204
  ```
200
205
 
201
206
  ## MCP server (for AI agents)
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AuthBackend, Credentials } from "./auth/types.js";
2
- import type { Basket, BasketUpdateResult, BookedSlot, NutritionFilter, NutritionSort, Order, Product, SearchPage, Session, Slot } from "./models.js";
2
+ import type { Basket, BasketUpdateResult, BookedSlot, NutritionFilter, NutritionSort, Order, OrderHistoryPage, Product, SearchPage, Session, Slot } from "./models.js";
3
3
  import { type FulfilmentType, type OrderContext } from "./operations.js";
4
4
  import type { TokenStore } from "./store/types.js";
5
5
  export interface BasketeerOptions {
@@ -18,6 +18,8 @@ export interface BasketeerOptions {
18
18
  }
19
19
  /** Tesco's observed maximum number of aliased product fields in one operation. */
20
20
  export declare const MAX_PRODUCT_BATCH_SIZE = 15;
21
+ /** Basketeer's policy cap on `orders.history` page size — not a discovered Tesco limit. */
22
+ export declare const MAX_HISTORY_PAGE_SIZE = 100;
21
23
  /** A raw basket line input for the low-level `basket.update`. */
22
24
  export interface BasketItemInput {
23
25
  id: string;
@@ -128,6 +130,18 @@ export declare class Basketeer {
128
130
  list: (opts?: {
129
131
  contexts?: readonly OrderContext[];
130
132
  }) => Promise<Order[]>;
133
+ /**
134
+ * One page of completed (previous) orders, newest first. `limit` defaults
135
+ * to 25 (max {@link MAX_HISTORY_PAGE_SIZE}). Offset pagination runs over a
136
+ * LIVE result set: follow `nextOffset` within a single traversal, dedupe
137
+ * by `Order.id`, and never persist an offset across runs — a new completed
138
+ * order shifts every later offset.
139
+ */
140
+ history: (opts?: {
141
+ offset?: number;
142
+ limit?: number;
143
+ contexts?: readonly OrderContext[];
144
+ }) => Promise<OrderHistoryPage>;
131
145
  /**
132
146
  * Open an order for amendment. Returns an {@link Amendment} handle whose
133
147
  * basket ops apply to THAT order; finish by checking out again, or call
@@ -144,6 +158,7 @@ export declare class Basketeer {
144
158
  private cancelOrder;
145
159
  private lastFulfilledOrder;
146
160
  private listOrders;
161
+ private orderHistory;
147
162
  private orderAction;
148
163
  /**
149
164
  * Prepare for payment. Returns the current basket and the URL where payment
package/dist/client.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { BasketeerError, ItemUnavailableError, LineRejectedError, NotFoundError, } from "./errors.js";
2
2
  import { GraphQLTransport } from "./graphql.js";
3
3
  import { filterByNutrition } from "./nutrition.js";
4
- import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, } from "./operations.js";
4
+ import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, } from "./operations.js";
5
5
  import { isoDate, parseBasket, parseBookedSlot, parseOrder, parseProduct, parseProductNode, parseSlot, } from "./parsers.js";
6
- import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_PRODUCT, GET_UPCOMING_ORDERS, getProductsQuery, SEARCH, UPDATE_BASKET, } from "./queries.js";
6
+ import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_ORDER_HISTORY, GET_PRODUCT, GET_UPCOMING_ORDERS, getProductsQuery, SEARCH, UPDATE_BASKET, } from "./queries.js";
7
7
  import { SessionManager } from "./session-manager.js";
8
8
  /** Tesco's observed maximum number of aliased product fields in one operation. */
9
9
  export const MAX_PRODUCT_BATCH_SIZE = 15;
10
+ /** Basketeer's policy cap on `orders.history` page size — not a discovered Tesco limit. */
11
+ export const MAX_HISTORY_PAGE_SIZE = 100;
10
12
  /** Guard for numeric SDK inputs. Throws `RangeError` for non-integer or out-of-range values. */
11
13
  function reqInt(v, name, min) {
12
14
  if (v === undefined)
@@ -267,6 +269,14 @@ export class Basketeer {
267
269
  orders = {
268
270
  /** List upcoming (pending) orders with their items, slot, and amend window. */
269
271
  list: (opts = {}) => this.listOrders(opts),
272
+ /**
273
+ * One page of completed (previous) orders, newest first. `limit` defaults
274
+ * to 25 (max {@link MAX_HISTORY_PAGE_SIZE}). Offset pagination runs over a
275
+ * LIVE result set: follow `nextOffset` within a single traversal, dedupe
276
+ * by `Order.id`, and never persist an offset across runs — a new completed
277
+ * order shifts every later offset.
278
+ */
279
+ history: (opts = {}) => this.orderHistory(opts),
270
280
  /**
271
281
  * Open an order for amendment. Returns an {@link Amendment} handle whose
272
282
  * basket ops apply to THAT order; finish by checking out again, or call
@@ -317,6 +327,29 @@ export class Basketeer {
317
327
  });
318
328
  return (data.orderSearch?.orders ?? []).map(parseOrder);
319
329
  }
330
+ async orderHistory(opts) {
331
+ // Never forward surprising values: Tesco treats count: 0 as UNBOUNDED and
332
+ // returns an empty (terminal-looking) page for a negative offset.
333
+ reqInt(opts.offset, "offset", 0);
334
+ reqInt(opts.limit, "limit", 1);
335
+ if (opts.limit !== undefined && opts.limit > MAX_HISTORY_PAGE_SIZE)
336
+ throw new RangeError(`limit must be an integer <= ${MAX_HISTORY_PAGE_SIZE}`);
337
+ const offset = opts.offset ?? 0;
338
+ const limit = opts.limit ?? 25;
339
+ const data = await this.transport.execute({
340
+ operationName: "GetOrderHistory",
341
+ query: GET_ORDER_HISTORY,
342
+ variables: { orderContexts: opts.contexts ?? PREVIOUS_ORDER_CONTEXTS, count: limit, offset },
343
+ mfeName: MFE.orders,
344
+ });
345
+ // Strict, unlike list(): a short page is this API's only terminal signal,
346
+ // so a malformed response must not masquerade as one.
347
+ const raw = data.orderSearch?.orders;
348
+ if (!Array.isArray(raw))
349
+ throw new BasketeerError("GetOrderHistory returned no orderSearch.orders array");
350
+ const orders = raw.map(parseOrder);
351
+ return { orders, nextOffset: orders.length < limit ? null : offset + orders.length };
352
+ }
320
353
  async orderAction(query, operationName, orderNo) {
321
354
  await this.transport.execute({
322
355
  operationName,
package/dist/index.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
2
2
  export type { AuthBackend, Credentials, OtpProvider } from "./auth/types.js";
3
3
  export type { Amendment, BasketeerOptions, BasketItemInput } from "./client.js";
4
- export { Basketeer, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
4
+ export { Basketeer, MAX_HISTORY_PAGE_SIZE, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
5
5
  export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, ItemUnavailableError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
6
6
  export type { ImageSize } from "./images.js";
7
7
  export { resizeImageUrl } from "./images.js";
8
- export type { Basket, BasketLine, BasketUpdateResult, BookedSlot, CatchWeightOption, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderItem, OrderSlot, PackSize, Price, Product, ProductQuantityRules, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
8
+ export type { Basket, BasketLine, BasketUpdateResult, BookedSlot, CatchWeightOption, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderHistoryPage, OrderItem, OrderSlot, PackSize, Price, Product, ProductQuantityRules, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
9
9
  export { filterByNutrition, parseNutrition } from "./nutrition.js";
10
10
  export type { FulfilmentType, OrderContext } from "./operations.js";
11
- export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
11
+ export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
12
12
  export { FileTokenStore } from "./store/file-store.js";
13
13
  export { MemoryTokenStore } from "./store/memory-store.js";
14
14
  export type { TokenStore } from "./store/types.js";
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  // Public API surface. Everything exported here is part of the semver contract;
2
2
  // internal modules (transport, queries, parsers, session-manager) are not.
3
3
  export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
4
- export { Basketeer, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
4
+ export { Basketeer, MAX_HISTORY_PAGE_SIZE, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
5
5
  // Errors
6
6
  export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, ItemUnavailableError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
7
7
  export { resizeImageUrl } from "./images.js";
8
8
  // Nutrition
9
9
  export { filterByNutrition, parseNutrition } from "./nutrition.js";
10
10
  // Config & helpers
11
- export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
11
+ export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
12
12
  // Storage seam
13
13
  export { FileTokenStore } from "./store/file-store.js";
14
14
  export { MemoryTokenStore } from "./store/memory-store.js";
package/dist/models.d.ts CHANGED
@@ -176,6 +176,22 @@ export interface Order {
176
176
  items: OrderItem[];
177
177
  raw: unknown;
178
178
  }
179
+ /**
180
+ * One page of completed-order history. Pagination is offset-based over a
181
+ * LIVE, newest-first result set — a new completed order shifts every later
182
+ * offset — so keep `nextOffset` only for the duration of one traversal and
183
+ * deduplicate by `Order.id`.
184
+ */
185
+ export interface OrderHistoryPage {
186
+ orders: Order[];
187
+ /**
188
+ * Offset for the next page, or null when this page was short (terminal).
189
+ * Non-null only means "another request is needed to know" — Tesco supplies
190
+ * no authoritative has-next-page signal, so when the total is an exact
191
+ * multiple of `limit` the final page is empty.
192
+ */
193
+ nextOffset: number | null;
194
+ }
179
195
  /**
180
196
  * An authenticated session. For the browser-minted hybrid (v1), `accessToken`
181
197
  * is the harvested `OAuth.AccessToken` bearer and `customerUuid` is the `UUID`
package/dist/nutrition.js CHANGED
@@ -40,18 +40,43 @@ function basisFromHeader(v) {
40
40
  }
41
41
  const MACRO_LABELS = {
42
42
  fat: "fat",
43
+ "total fat": "fat",
43
44
  saturates: "saturates",
44
45
  "of which saturates": "saturates",
46
+ "saturated fat": "saturates",
47
+ "of which saturated fat": "saturates",
45
48
  carbohydrate: "carbs",
49
+ carbohydrates: "carbs",
46
50
  "available carbohydrate": "carbs",
47
51
  "total carbohydrate": "carbs",
48
52
  sugars: "sugars",
49
53
  "of which sugars": "sugars",
54
+ "total sugars": "sugars",
50
55
  fibre: "fibre",
51
56
  protein: "protein",
52
57
  salt: "salt",
53
58
  "salt equivalent": "salt",
54
59
  };
60
+ /** Trailing unit in a row name: "Fat (g)", "Energy (kcal)", "Calcium (mg)". */
61
+ const UNIT_IN_NAME = /\s*\(\s*(kj|kcal|g|mg|µg|μg)\s*\)\s*$/i;
62
+ /** Drop a leading dash or unbalanced paren: "- Saturates" / "(of which saturates". */
63
+ function cleanName(name) {
64
+ return name
65
+ .trim()
66
+ .replace(/^-\s*(?=[a-z])/i, "")
67
+ .replace(/^\((?![^(]*\))\s*/, "");
68
+ }
69
+ /** Lookup key for a row label: "(of which: sugars*)" / "Protein g" -> "of which sugars" / "protein". */
70
+ function labelKey(name) {
71
+ return name
72
+ .toLowerCase()
73
+ .replace(/[*†:,]+/g, " ")
74
+ .trim()
75
+ .replace(/(\s+|\s*\()(g|mg|µg|μg)\)?$/, "")
76
+ .replace(/^\((.*)\)$/, "$1")
77
+ .replace(/\s+/g, " ")
78
+ .trim();
79
+ }
55
80
  function isFootnote(name, value) {
56
81
  const n = name.trim();
57
82
  if (n.startsWith("*"))
@@ -62,7 +87,7 @@ function isFootnote(name, value) {
62
87
  return true;
63
88
  if (/reference intake|nutrient reference value/i.test(n))
64
89
  return true;
65
- const known = !!MACRO_LABELS[n.toLowerCase()] || /^energy/i.test(n);
90
+ const known = !!MACRO_LABELS[labelKey(n)] || /^energy/i.test(n);
66
91
  if (!known && (value === "-" || value === null || value === ""))
67
92
  return true;
68
93
  return false;
@@ -76,7 +101,7 @@ export function parseNutrition(rows) {
76
101
  let expectKcalNext = false;
77
102
  for (const r of rows) {
78
103
  const row = (r ?? {});
79
- const name = typeof row.name === "string" ? row.name.trim() : "";
104
+ const name = typeof row.name === "string" ? cleanName(row.name) : "";
80
105
  // Normalize a decimal comma so the energy kJ/kcal regexes (and num/nrvOf) see "1.5", not "1".
81
106
  const value1 = typeof row.value1 === "string" ? deComma(row.value1.trim()) : null;
82
107
  if (!name)
@@ -96,9 +121,18 @@ export function parseNutrition(rows) {
96
121
  basis = basisFromHeader(value1);
97
122
  continue;
98
123
  }
99
- // Energy (inline "486kJ / 115kcal" or split "257 kJ/")
100
- if (/^energy/i.test(name)) {
101
- if (value1) {
124
+ // Unit in the row name with a bare-number value: "Fat (g)" -> "3.0", "Energy (kcal)" -> "60".
125
+ const unitInName = name.match(UNIT_IN_NAME)?.[1]?.toLowerCase() ?? null;
126
+ const baseName = unitInName ? name.replace(UNIT_IN_NAME, "").trim() : name;
127
+ // Energy: unit in the name, inline "486kJ / 115kcal", or split "257 kJ/"
128
+ if (/^energy/i.test(baseName)) {
129
+ if (value1 && unitInName && !/kj|kcal/i.test(value1)) {
130
+ if (unitInName === "kcal")
131
+ macros.energyKcal = num(value1);
132
+ if (unitInName === "kj")
133
+ macros.energyKj = num(value1);
134
+ }
135
+ else if (value1) {
102
136
  const kj = value1.match(/(\d+(?:\.\d+)?)\s*kj/i);
103
137
  const kcal = value1.match(/(\d+(?:\.\d+)?)\s*kcal/i);
104
138
  if (kj)
@@ -110,16 +144,28 @@ export function parseNutrition(rows) {
110
144
  }
111
145
  continue;
112
146
  }
113
- if (isFootnote(name, value1))
147
+ // NRV on its own row named "-": "Calcium" => "124mg", then "-" => "(15.5% RI*)".
148
+ if (name === "-") {
149
+ const prev = micros[micros.length - 1];
150
+ if (prev && prev.nrvPercent == null && value1)
151
+ prev.nrvPercent = nrvOf(value1);
152
+ continue;
153
+ }
154
+ if (isFootnote(baseName, value1))
114
155
  continue;
115
- const key = MACRO_LABELS[name.toLowerCase()];
156
+ const key = MACRO_LABELS[labelKey(baseName)];
116
157
  if (key) {
117
158
  macros[key] = num(value1);
118
159
  continue;
119
160
  }
120
161
  // Anything else with a value is a micronutrient.
121
162
  if (value1) {
122
- micros.push({ name, amount: num(value1), unit: unitOf(value1), nrvPercent: nrvOf(value1) });
163
+ micros.push({
164
+ name: baseName,
165
+ amount: num(value1),
166
+ unit: unitOf(value1) ?? unitInName,
167
+ nrvPercent: nrvOf(value1),
168
+ });
123
169
  }
124
170
  }
125
171
  return { basis, macros, micros, raw: rows };
@@ -23,13 +23,15 @@ export declare const MFE: {
23
23
  readonly orders: "mfe-orders";
24
24
  readonly favourites: "mfe-favourites";
25
25
  };
26
- /** One context for `orders.list` — an order type plus the statuses to include. */
26
+ /** One context for the order queries — an order type plus the statuses to include. */
27
27
  export interface OrderContext {
28
28
  type: string;
29
29
  statuses: readonly string[];
30
30
  }
31
31
  /** Default contexts for listing upcoming (pending) orders across order types. */
32
32
  export declare const PENDING_ORDER_CONTEXTS: readonly OrderContext[];
33
+ /** Default contexts for `orders.history` — completed (previous) grocery orders. */
34
+ export declare const PREVIOUS_ORDER_CONTEXTS: readonly OrderContext[];
33
35
  /** Fulfilment method types accepted by the slot ops. */
34
36
  export declare const FULFILMENT_TYPE: {
35
37
  readonly delivery: "DELIVERY_VAN";
@@ -29,6 +29,10 @@ export const PENDING_ORDER_CONTEXTS = [
29
29
  { type: "MARKETPLACE", statuses: ["Pending"] },
30
30
  { type: "FNF", statuses: ["Pending"] },
31
31
  ];
32
+ /** Default contexts for `orders.history` — completed (previous) grocery orders. */
33
+ export const PREVIOUS_ORDER_CONTEXTS = [
34
+ { type: "GROCERY", statuses: ["Previous"] },
35
+ ];
32
36
  /** Fulfilment method types accepted by the slot ops. */
33
37
  export const FULFILMENT_TYPE = {
34
38
  delivery: "DELIVERY_VAN",
package/dist/queries.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare const GET_FAVOURITES: string;
11
11
  export declare const GET_BASKET: string;
12
12
  export declare const UPDATE_BASKET: string;
13
13
  export declare const GET_UPCOMING_ORDERS: string;
14
+ export declare const GET_ORDER_HISTORY: string;
14
15
  export declare const AMEND_ORDER: string;
15
16
  export declare const CANCEL_ORDER: string;
16
17
  export declare const CANCEL_AMEND: string;
package/dist/queries.js CHANGED
@@ -144,10 +144,7 @@ mutation UpdateBasket($items: [BasketLineItemInputType], $orderId: ID) {
144
144
  }
145
145
  }`.trim();
146
146
  // ---- orders ----------------------------------------------------------------
147
- export const GET_UPCOMING_ORDERS = `
148
- query GetUpcomingOrders($orderContexts: [OrderContextType]) {
149
- orderSearch(orderContexts: $orderContexts) {
150
- orders {
147
+ const ORDER_FIELDS = `
151
148
  id
152
149
  orderNo
153
150
  status
@@ -159,7 +156,22 @@ query GetUpcomingOrders($orderContexts: [OrderContextType]) {
159
156
  paymentMode
160
157
  slot { id start end charge }
161
158
  address { name city addressLine1 postcode }
162
- items { id quantity unit weight product { id title } }
159
+ items { id quantity unit weight product { id title } }`;
160
+ export const GET_UPCOMING_ORDERS = `
161
+ query GetUpcomingOrders($orderContexts: [OrderContextType]) {
162
+ orderSearch(orderContexts: $orderContexts) {
163
+ orders {${ORDER_FIELDS}
164
+ }
165
+ }
166
+ }`.trim();
167
+ // Offset pagination over a LIVE, newest-first result set. Tesco's orderSearch
168
+ // exposes only count/offset — no cursor, total, or page metadata — so terminal
169
+ // state is inferred from a short page. The schema accepts `page` but ignores
170
+ // it; never send it.
171
+ export const GET_ORDER_HISTORY = `
172
+ query GetOrderHistory($orderContexts: [OrderContextType], $count: Int, $offset: Int) {
173
+ orderSearch(orderContexts: $orderContexts, count: $count, offset: $offset) {
174
+ orders {${ORDER_FIELDS}
163
175
  }
164
176
  }
165
177
  }`.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basketeer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed, pure-HTTP TypeScript SDK for your own Tesco grocery account — search, basket, delivery slots, and orders over plain fetch. Browser only for login + payment.",
5
5
  "type": "module",
6
6
  "license": "MIT",