@malawistore/store-sdk 0.1.3 → 0.2.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/CHANGELOG.md CHANGED
@@ -4,7 +4,32 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.2.0] - 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - Bulk admin writes: `products.bulkCreate()`, `products.bulkUpdate()`,
12
+ `products.bulkDelete()`, `variants.createBulk()`, `variants.updateBulk()`,
13
+ and `inventory.setStockBulk()`. Every method accepts `atomic`, `dryRun`,
14
+ and an optional `idempotencyKey`.
15
+ - Bulk shopper writes: `cart.addItems()` (returns per-line results plus the
16
+ full updated cart), `wishlist.addBulk()`, and `wishlist.removeBulk()`.
17
+ - Product gallery admin endpoints: `products.listImages()`,
18
+ `products.uploadImages()` (multi-file), `products.deleteImage()`, and
19
+ `products.setPrimaryImage()`.
20
+ - `inventory.getStockByVariants()` batch availability read.
21
+ - `reviews.listMine()` and `reviews.listMinePage()` for the caller's review
22
+ history.
23
+ - `products.list()` / `listPage()` accept `ids` to restrict the page.
24
+ - `BulkOptions`, `BulkResult`, `BulkResponse`, `BulkSummary`, and the new
25
+ bulk/gallery wire types are exported.
26
+
27
+ ### Fixed
28
+
29
+ - `users.get()` now calls `GET /admin/users/{id}`. The previous `/users/{id}`
30
+ route does not exist (owners use `users.me()`; lookups are admin-only).
31
+ - `ApiErrorCode` now includes `BULK_LIMIT_EXCEEDED`, `DUPLICATE_ITEM`, and
32
+ `IDEMPOTENCY_CONFLICT`.
8
33
 
9
34
  ## [0.1.3] - 2026-10-29
10
35
 
package/README.md CHANGED
@@ -115,14 +115,14 @@ several parallel queries does not rotate the token multiple times.
115
115
  | `store.auth` | Register, login, refresh, logout, email verification, password reset |
116
116
  | `store.users` | Current account, user lookup, role management (admin) |
117
117
  | `store.categories` | Taxonomy reads, admin CRUD, image upload |
118
- | `store.products` | Catalog reads, admin CRUD, image upload |
119
- | `store.variants` | SKU reads and admin CRUD |
120
- | `store.cart` | Get, add, update quantity, remove, clear |
121
- | `store.inventory` | Stock lookup and admin set/reserve/release/confirm |
118
+ | `store.products` | Catalog reads, admin CRUD, bulk writes, image + gallery upload |
119
+ | `store.variants` | SKU reads, admin CRUD, bulk create/update |
120
+ | `store.cart` | Get, add (single or bulk), update quantity, remove, clear |
121
+ | `store.inventory` | Stock lookup (single or batch), bulk set, reserve/release/confirm |
122
122
  | `store.orders` | Checkout, list, get, cancel |
123
123
  | `store.payments` | Stripe payment intents and hosted Checkout sessions |
124
- | `store.wishlist` | List, add, remove |
125
- | `store.reviews` | Product summary, create, edit, delete, admin moderation |
124
+ | `store.wishlist` | List, add, remove, bulk heart/unheart |
125
+ | `store.reviews` | Product summary, own history, create, edit, delete, moderation |
126
126
 
127
127
  ### Auth
128
128
 
@@ -160,6 +160,9 @@ console.log(detail.product.price_cents); // 12500 → K125.00
160
160
  console.log(detail.variants); // sellable SKUs
161
161
 
162
162
  const skus = await store.variants.list(detail.product.id);
163
+
164
+ // Fetch a specific set of products (e.g. a "recently viewed" rail)
165
+ const picked = await store.products.list({ ids: [id1, id2, id3] });
163
166
  ```
164
167
 
165
168
  For paginated grids use `listPage()`, which returns `{ data, meta }`:
@@ -208,6 +211,11 @@ const reviews = await store.reviews.summary(productId, {
208
211
  limit: 12,
209
212
  });
210
213
  reviews.meta; // filtered review page counters
214
+
215
+ // The caller's own review history (with product context)
216
+ const mine = await store.reviews.listMine({ sort: "newest" });
217
+ const minePage = await store.reviews.listMinePage({ page: 1, limit: 12 });
218
+ minePage.meta;
211
219
  ```
212
220
 
213
221
  Variant rows include `available` (on-hand minus reservations) and `in_stock`,
@@ -222,6 +230,18 @@ tambala; the API converts those at charge time.
222
230
  ```ts
223
231
  await store.cart.addItem({ product_id: "…", qty: 2 });
224
232
  await store.cart.addItem({ product_id: "…", variant_id: "…", qty: 1 });
233
+
234
+ // Batch add: per-line results plus the full updated cart
235
+ const bulk = await store.cart.addItems({
236
+ atomic: true,
237
+ items: [
238
+ { product_id: "…", qty: 2 },
239
+ { product_id: "…", variant_id: "…", qty: 1 },
240
+ ],
241
+ });
242
+ bulk.results; // [{ index, id, status, data, error? }]
243
+ bulk.cart; // no follow-up GET needed
244
+
225
245
  await store.cart.setQty("cart-line-id", 3);
226
246
  await store.cart.removeItem("cart-line-id");
227
247
 
@@ -255,6 +275,10 @@ await store.wishlist.add(productId);
255
275
  const wishlist = await store.wishlist.list();
256
276
  await store.wishlist.remove(productId); // product ID, not row ID
257
277
 
278
+ // Batch heart / unheart (idempotent; already-hearted rows succeed)
279
+ await store.wishlist.addBulk({ productIds: [id1, id2] });
280
+ await store.wishlist.removeBulk({ ids: [id1, id2], dryRun: false });
281
+
258
282
  const summary = await store.reviews.summary(productId);
259
283
  await store.reviews.create(productId, { rating: 5, title: "Great", body: "…" });
260
284
  await store.reviews.update(reviewId, { rating: 4 });
@@ -280,6 +304,38 @@ await store.inventory.setStock({ product_id: product.id, qty: 50 });
280
304
  await store.users.setRole(userId, { role: "admin" });
281
305
  await store.reviews.deleteAny(reviewId); // moderation
282
306
 
307
+ // Bulk product + variant + stock writes. Per-item failures stay inside
308
+ // the 200 envelope; atomic rolls the whole batch back.
309
+ await store.products.bulkCreate({ items: [draft1, draft2] });
310
+ await store.products.bulkUpdate({
311
+ updates: [{ id: productId, patch: { is_active: false } }],
312
+ });
313
+ await store.products.bulkDelete({ ids: [id1, id2], dryRun: true });
314
+
315
+ await store.variants.createBulk(product.id, { items: [sku1, sku2] });
316
+ await store.variants.updateBulk({
317
+ updates: [{ id: skuId, patch: { price_cents: 2500 } }],
318
+ });
319
+
320
+ await store.inventory.setStockBulk({
321
+ atomic: true,
322
+ items: [
323
+ { product_id: id1, qty: 50 },
324
+ { product_id: id1, variant_id: skuId, qty: 20 },
325
+ ],
326
+ });
327
+
328
+ // Batch availability read; unknown ids land in `notFound`
329
+ const stock = await store.inventory.getStockByVariants([skuId, otherSkuId]);
330
+ stock.stock;
331
+ stock.notFound;
332
+
333
+ // Bulk writes accept an optional replay-safe key (24h window)
334
+ await store.products.bulkCreate({
335
+ idempotencyKey: "catalog-import-2026-11-04",
336
+ items: [draft1, draft2],
337
+ });
338
+
283
339
  // Admin user directory: filters + pagination
284
340
  const users = await store.users.list({ role: "customer", q: "aisha", sort: "newest" });
285
341
  const page = await store.users.listPage({ email_verified: false, page: 1, limit: 50 });
@@ -296,6 +352,12 @@ Cloudinary finishes. The method resolves as soon as the job is accepted.
296
352
  const file = fileInput.files![0];
297
353
  await store.products.uploadImage(productId, file);
298
354
 
355
+ // Gallery: list, batch upload, reorder/primary, delete
356
+ const images = await store.products.listImages(productId);
357
+ await store.products.uploadImages(productId, [file, otherFile]); // up to 20
358
+ await store.products.setPrimaryImage(productId, images[0].id);
359
+ await store.products.deleteImage(productId, images[0].id);
360
+
299
361
  // Node: wrap a Buffer
300
362
  import { readFile } from "node:fs/promises";
301
363
  const data = await readFile("./photo.jpg");
package/dist/index.cjs CHANGED
@@ -433,6 +433,11 @@ var AuthResource = class {
433
433
  }
434
434
  };
435
435
 
436
+ // src/headers.ts
437
+ function idempotencyHeaders(key) {
438
+ return key ? { "Idempotency-Key": key } : void 0;
439
+ }
440
+
436
441
  // src/path.ts
437
442
  function segment(value) {
438
443
  return encodeURIComponent(value);
@@ -480,6 +485,22 @@ var CartResource = class {
480
485
  retry: false
481
486
  });
482
487
  }
488
+ /**
489
+ * Adds up to MAX_BULK_ITEMS lines in one request. Per-line results carry
490
+ * the written line, plus the full updated cart.
491
+ */
492
+ addItems(input) {
493
+ const { idempotencyKey, ...body } = input;
494
+ return this.http.request(
495
+ "POST",
496
+ "/cart/items/bulk",
497
+ {
498
+ body,
499
+ headers: idempotencyHeaders(idempotencyKey),
500
+ retry: false
501
+ }
502
+ );
503
+ }
483
504
  /** `qty: 0` removes the line. */
484
505
  setQty(itemId, qty) {
485
506
  return this.http.request(
@@ -502,12 +523,11 @@ var CartResource = class {
502
523
  };
503
524
 
504
525
  // src/upload.ts
505
- function createUploadForm(file, field = "file") {
506
- const form = new FormData();
526
+ function appendFile(form, file, field) {
507
527
  if (typeof Blob !== "undefined" && file instanceof Blob) {
508
528
  const name = typeof File !== "undefined" && file instanceof File ? file.name : "upload";
509
529
  form.append(field, file, name);
510
- return form;
530
+ return;
511
531
  }
512
532
  if (typeof file !== "object" || file === null || !("data" in file) || typeof file.name !== "string") {
513
533
  throw new TypeError(
@@ -518,6 +538,19 @@ function createUploadForm(file, field = "file") {
518
538
  type: file.type ?? "application/octet-stream"
519
539
  });
520
540
  form.append(field, blob, file.name);
541
+ }
542
+ function createUploadForm(file, field = "file") {
543
+ const form = new FormData();
544
+ appendFile(form, file, field);
545
+ return form;
546
+ }
547
+ function createUploadFormMany(files, field = "files") {
548
+ const list = Array.isArray(files) ? files : [files];
549
+ if (list.length === 0) {
550
+ throw new TypeError("At least one UploadFile is required");
551
+ }
552
+ const form = new FormData();
553
+ for (const file of list) appendFile(form, file, field);
521
554
  return form;
522
555
  }
523
556
 
@@ -622,6 +655,21 @@ var InventoryResource = class {
622
655
  }
623
656
  });
624
657
  }
658
+ /**
659
+ * Reads availability for up to MAX_BULK_ITEMS variants in one request.
660
+ * Unknown ids are returned in `notFound` instead of failing.
661
+ */
662
+ getStockByVariants(variantIds) {
663
+ return this.http.request(
664
+ "GET",
665
+ "/admin/inventory/stock",
666
+ {
667
+ query: {
668
+ variantIds: Array.isArray(variantIds) ? variantIds : [variantIds]
669
+ }
670
+ }
671
+ );
672
+ }
625
673
  /** Upserts the on-hand quantity (restocking). */
626
674
  setStock(input) {
627
675
  return this.http.request("POST", "/admin/inventory/stock", {
@@ -629,6 +677,19 @@ var InventoryResource = class {
629
677
  retry: false
630
678
  });
631
679
  }
680
+ /** Sets on-hand stock for many SKUs; `atomic` commits all or none. */
681
+ setStockBulk(input) {
682
+ const { idempotencyKey, ...body } = input;
683
+ return this.http.request(
684
+ "POST",
685
+ "/admin/inventory/stock/bulk",
686
+ {
687
+ body,
688
+ headers: idempotencyHeaders(idempotencyKey),
689
+ retry: false
690
+ }
691
+ );
692
+ }
632
693
  /** Holds all lines atomically; one short line fails the whole order. */
633
694
  reserve(input) {
634
695
  return this.http.request(
@@ -757,6 +818,7 @@ var ProductsResource = class {
757
818
  return this.http.request("GET", "/products", {
758
819
  query: {
759
820
  q: params.q,
821
+ ids: params.ids,
760
822
  category_id: params.category_id,
761
823
  category_slug: params.category_slug,
762
824
  min_price_cents: params.min_price_cents,
@@ -780,6 +842,7 @@ var ProductsResource = class {
780
842
  return this.http.request("GET", "/products", {
781
843
  query: {
782
844
  q: params.q,
845
+ ids: params.ids,
783
846
  category_id: params.category_id,
784
847
  category_slug: params.category_slug,
785
848
  min_price_cents: params.min_price_cents,
@@ -845,6 +908,86 @@ var ProductsResource = class {
845
908
  { form: createUploadForm(file), retry: false }
846
909
  );
847
910
  }
911
+ /**
912
+ * Admin only. Creates products in bulk. Per-item failures stay inside
913
+ * the 200 envelope; `atomic` rolls the whole batch back.
914
+ */
915
+ bulkCreate(input) {
916
+ const { idempotencyKey, ...body } = input;
917
+ return this.http.request(
918
+ "POST",
919
+ "/admin/products/bulk",
920
+ {
921
+ body,
922
+ headers: idempotencyHeaders(idempotencyKey),
923
+ retry: false
924
+ }
925
+ );
926
+ }
927
+ /** Admin only. Applies one partial patch per product id. */
928
+ bulkUpdate(input) {
929
+ const { idempotencyKey, ...body } = input;
930
+ return this.http.request(
931
+ "PATCH",
932
+ "/admin/products/bulk",
933
+ {
934
+ body,
935
+ headers: idempotencyHeaders(idempotencyKey),
936
+ retry: false
937
+ }
938
+ );
939
+ }
940
+ /**
941
+ * Admin only. POST rather than DELETE so the id list can travel in JSON.
942
+ * `dryRun` reports `wouldDelete` without writing.
943
+ */
944
+ bulkDelete(input) {
945
+ const { idempotencyKey, ...body } = input;
946
+ return this.http.request(
947
+ "POST",
948
+ "/admin/products/bulk-delete",
949
+ {
950
+ body,
951
+ headers: idempotencyHeaders(idempotencyKey),
952
+ retry: false
953
+ }
954
+ );
955
+ }
956
+ /** Admin only. Gallery rows, including uploads still queued (empty url). */
957
+ async listImages(id) {
958
+ const result = await this.http.request(
959
+ "GET",
960
+ `/admin/products/${segment(id)}/images`
961
+ );
962
+ return result.images;
963
+ }
964
+ /**
965
+ * Admin only. Multipart with repeated `files` parts (up to 20 per
966
+ * request). Each accepted file is queued asynchronously (HTTP 202).
967
+ */
968
+ async uploadImages(id, files) {
969
+ return this.http.request(
970
+ "POST",
971
+ `/admin/products/${segment(id)}/images`,
972
+ { form: createUploadFormMany(files), retry: false }
973
+ );
974
+ }
975
+ /** Admin only. Promotes the next ready image when the primary is deleted. */
976
+ deleteImage(id, imageId) {
977
+ return this.http.request(
978
+ "DELETE",
979
+ `/admin/products/${segment(id)}/images/${segment(imageId)}`,
980
+ { retry: false }
981
+ );
982
+ }
983
+ /** Admin only. Points the storefront primary image at one gallery row. */
984
+ setPrimaryImage(id, imageId) {
985
+ return this.http.request(
986
+ "PUT",
987
+ `/admin/products/${segment(id)}/images/${segment(imageId)}/primary`,
988
+ { retry: false }
989
+ );
990
+ }
848
991
  };
849
992
 
850
993
  // src/resources/reviews.ts
@@ -871,6 +1014,29 @@ var ReviewsResource = class {
871
1014
  }
872
1015
  );
873
1016
  }
1017
+ /** The caller's own reviews with product context (paging meta dropped). */
1018
+ listMine(params = {}) {
1019
+ return this.http.request("GET", "/reviews/me", {
1020
+ query: {
1021
+ sort: params.sort,
1022
+ page: params.page,
1023
+ limit: params.limit,
1024
+ offset: params.offset
1025
+ }
1026
+ });
1027
+ }
1028
+ /** Same as `listMine`, plus the `meta` paging counters. */
1029
+ listMinePage(params = {}) {
1030
+ return this.http.request("GET", "/reviews/me", {
1031
+ query: {
1032
+ sort: params.sort,
1033
+ page: params.page,
1034
+ limit: params.limit,
1035
+ offset: params.offset
1036
+ },
1037
+ unwrap: false
1038
+ });
1039
+ }
874
1040
  /** One review per user per product. */
875
1041
  create(productId, input) {
876
1042
  return this.http.request(
@@ -915,8 +1081,9 @@ var UsersResource = class {
915
1081
  me() {
916
1082
  return this.http.request("GET", "/users/me");
917
1083
  }
1084
+ /** Admin only. There is no public `GET /users/{id}` — owners use `me()`. */
918
1085
  get(id) {
919
- return this.http.request("GET", `/users/${segment(id)}`);
1086
+ return this.http.request("GET", `/admin/users/${segment(id)}`);
920
1087
  }
921
1088
  /** Admin only. User directory without envelope metadata. */
922
1089
  list(params = {}) {
@@ -996,6 +1163,32 @@ var VariantsResource = class {
996
1163
  { retry: false }
997
1164
  );
998
1165
  }
1166
+ /** Admin only. Creates up to MAX_BULK_ITEMS SKUs for one product. */
1167
+ createBulk(productId, input) {
1168
+ const { idempotencyKey, ...body } = input;
1169
+ return this.http.request(
1170
+ "POST",
1171
+ `/products/${segment(productId)}/variants/bulk`,
1172
+ {
1173
+ body,
1174
+ headers: idempotencyHeaders(idempotencyKey),
1175
+ retry: false
1176
+ }
1177
+ );
1178
+ }
1179
+ /** Admin only. Applies one partial patch per variant id. */
1180
+ updateBulk(input) {
1181
+ const { idempotencyKey, ...body } = input;
1182
+ return this.http.request(
1183
+ "PATCH",
1184
+ "/admin/variants/bulk",
1185
+ {
1186
+ body,
1187
+ headers: idempotencyHeaders(idempotencyKey),
1188
+ retry: false
1189
+ }
1190
+ );
1191
+ }
999
1192
  };
1000
1193
 
1001
1194
  // src/resources/wishlist.ts
@@ -1046,6 +1239,35 @@ var WishlistResource = class {
1046
1239
  { retry: false }
1047
1240
  );
1048
1241
  }
1242
+ /** Hearts up to MAX_BULK_ITEMS products; already-hearted rows succeed. */
1243
+ addBulk(input) {
1244
+ const { idempotencyKey, ...body } = input;
1245
+ return this.http.request(
1246
+ "POST",
1247
+ "/wishlist/bulk",
1248
+ {
1249
+ body,
1250
+ headers: idempotencyHeaders(idempotencyKey),
1251
+ retry: false
1252
+ }
1253
+ );
1254
+ }
1255
+ /**
1256
+ * Unhearts by product id. `dryRun` reports `wouldDelete` without
1257
+ * writing; already-removed rows succeed.
1258
+ */
1259
+ removeBulk(input) {
1260
+ const { idempotencyKey, ...body } = input;
1261
+ return this.http.request(
1262
+ "POST",
1263
+ "/wishlist/bulk-delete",
1264
+ {
1265
+ body,
1266
+ headers: idempotencyHeaders(idempotencyKey),
1267
+ retry: false
1268
+ }
1269
+ );
1270
+ }
1049
1271
  };
1050
1272
 
1051
1273
  // src/client.ts
@@ -1207,6 +1429,7 @@ exports.MalawiStoreError = MalawiStoreError;
1207
1429
  exports.buildQuery = buildQuery;
1208
1430
  exports.constructStripeEvent = constructStripeEvent;
1209
1431
  exports.createUploadForm = createUploadForm;
1432
+ exports.createUploadFormMany = createUploadFormMany;
1210
1433
  exports.isMalawiStoreError = isMalawiStoreError;
1211
1434
  exports.verifyStripeSignature = verifyStripeSignature;
1212
1435
  //# sourceMappingURL=index.cjs.map