@develemit/commerce-server 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
@@ -131,6 +131,38 @@ reference, see `apps/playground-e2e/tests/order-management.spec.ts`.
131
131
  - [Admin API](docs/admin.md): product and order management, refunds, and orders that need attention.
132
132
  - [Payment gateways](docs/payments.md): the `PaymentGateway` contract and the manual Stripe test-mode purchase.
133
133
 
134
+ ## Caching
135
+
136
+ The public catalog GETs send `Cache-Control: public, max-age=30,
137
+ stale-while-revalidate=300` and a strong `ETag`, and answer a matching
138
+ `If-None-Match` (including `W/` and comma lists) with an empty 304. Errors get
139
+ `Cache-Control: no-store`. Admin, checkout and order routes are untouched.
140
+
141
+ Pass `catalogCache: { cacheControl: '...' }` to `commercePlugin` to change the
142
+ header, or `cacheControl: false` to omit it (the `ETag` is still sent), for
143
+ hosts that manage caching at a CDN.
144
+
145
+ The `ETag` hashes the uncompressed body. Register `@fastify/compress` at the
146
+ app root before `commercePlugin`, so its `onSend` runs after the catalog's.
147
+
148
+ ## Production checklist
149
+
150
+ - **Compression:** register `@fastify/compress` at the app root before
151
+ `commercePlugin` (JSON shrinks 70-80%). The kit doesn't wrap your app.
152
+ - **Catalog caching:** see [Caching](#caching); put a CDN in front of the
153
+ catalog GETs.
154
+ - **Images:** serve uploads with long immutable caching and lazy-loaded
155
+ `<img>`s (the storefront's default); keep image types out of compression.
156
+ - **Uploads on a CDN:** point `storage` at object storage behind a CDN rather
157
+ than the API process.
158
+ - **Webhooks:** forward the Stripe events (`checkout.session.completed`,
159
+ expiry and async failure) to `/orders/webhooks/stripe`; see
160
+ [Payment gateways](docs/payments.md).
161
+ Webhook raw-body parsing is request-side and unaffected by compression.
162
+ - **Retries:** refunds require an `Idempotency-Key`; reuse it when retrying so
163
+ a refund can't be issued twice.
164
+ - Admin routes: see [Admin API](docs/admin.md).
165
+
134
166
  ## Routes
135
167
 
136
168
  Relative to the host's prefix:
package/dist/index.d.ts CHANGED
@@ -1257,6 +1257,10 @@ interface AdminOptions {
1257
1257
  storage: FileStorage;
1258
1258
  }
1259
1259
 
1260
+ interface CatalogCacheOptions {
1261
+ cacheControl?: string | false;
1262
+ }
1263
+
1260
1264
  declare class ProductNotFoundError extends Error {
1261
1265
  readonly slug: string;
1262
1266
  readonly code = "PRODUCT_NOT_FOUND";
@@ -1395,12 +1399,14 @@ type CommercePluginOptions = {
1395
1399
  currency: string;
1396
1400
  admin?: AdminOptions;
1397
1401
  categoryLabels?: CategoryLabels;
1402
+ catalogCache?: CatalogCacheOptions;
1398
1403
  } & (CheckoutOptions | CatalogOnlyOptions);
1399
1404
  declare const commercePlugin: FastifyPluginAsync<CommercePluginOptions>;
1400
1405
 
1401
1406
  interface CatalogPluginOptions {
1402
1407
  db: CommerceDb;
1403
1408
  categoryLabels?: CategoryLabels;
1409
+ catalogCache?: CatalogCacheOptions;
1404
1410
  }
1405
1411
  declare const catalogPlugin: FastifyPluginAsync<CatalogPluginOptions>;
1406
1412
 
@@ -1498,4 +1504,4 @@ interface MemoryStorage extends FileStorage {
1498
1504
  declare function createMemoryStorage(baseUrl?: string): MemoryStorage;
1499
1505
 
1500
1506
  export { ProductNotFoundError, SESSION_ID_PLACEHOLDER, WebhookSignatureError, catalogPlugin, commercePlugin, commerceSchema, createFakeGateway, createMemoryStorage, createStripeGateway, ordersPlugin, runCommerceMigrations, seedProducts, seedShippingRates, startHoldSweeper };
1501
- export type { AdminOptions, CatalogPluginOptions, CategoryLabels, CheckoutLine, CheckoutSession, CheckoutUrls, ChosenShipping, CommerceDb, CommercePluginOptions, CommerceSchema, CreateCheckoutSessionInput, FakeGateway, FakeGatewayOptions, FileStorage, GatewayRefund, HoldSweeper, HoldSweeperOptions, MemoryStorage, OrderHooks, OrdersPluginOptions, PaymentEvent, PaymentGateway, RefundInput, SeedProduct, SeedShippingRate, ShippingAddress, ShippingRateOption, SignedWebhook, StoredFile, StripeGatewayOptions };
1507
+ export type { AdminOptions, CatalogCacheOptions, CatalogPluginOptions, CategoryLabels, CheckoutLine, CheckoutSession, CheckoutUrls, ChosenShipping, CommerceDb, CommercePluginOptions, CommerceSchema, CreateCheckoutSessionInput, FakeGateway, FakeGatewayOptions, FileStorage, GatewayRefund, HoldSweeper, HoldSweeperOptions, MemoryStorage, OrderHooks, OrdersPluginOptions, PaymentEvent, PaymentGateway, RefundInput, SeedProduct, SeedShippingRate, ShippingAddress, ShippingRateOption, SignedWebhook, StoredFile, StripeGatewayOptions };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  adminProductInputSchema as adminProductInputSchema2,
4
4
  adminProductPatchSchema as adminProductPatchSchema2,
5
- adminProductSchema,
5
+ adminProductPageSchema,
6
6
  imageOrderInputSchema,
7
7
  shippingRatesInputSchema
8
8
  } from "@develemit/commerce-contracts";
@@ -18,6 +18,28 @@ var ProductNotFoundError = class extends Error {
18
18
  code = "PRODUCT_NOT_FOUND";
19
19
  };
20
20
 
21
+ // src/shared/cursor.ts
22
+ var InvalidCursorError = class extends Error {
23
+ code = "INVALID_CURSOR";
24
+ constructor() {
25
+ super("The cursor is malformed");
26
+ this.name = "InvalidCursorError";
27
+ }
28
+ };
29
+ var PG_TIMESTAMP = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?[+-]\d{2}(:?\d{2})?$/;
30
+ function encodeCursor({ at, key }) {
31
+ return Buffer.from(`${at}|${key}`).toString("base64url");
32
+ }
33
+ function decodeCursor(raw, accepts) {
34
+ const text2 = Buffer.from(raw, "base64url").toString("utf8");
35
+ const split = text2.indexOf("|");
36
+ const cursor = { at: text2.slice(0, split), key: text2.slice(split + 1) };
37
+ if (split < 0 || !cursor.key || !accepts(cursor)) {
38
+ throw new InvalidCursorError();
39
+ }
40
+ return cursor;
41
+ }
42
+
21
43
  // src/shipping/repo.ts
22
44
  import { and, asc, eq, notInArray } from "drizzle-orm";
23
45
 
@@ -242,7 +264,16 @@ async function setShippingRates(db, rates) {
242
264
  import { Readable } from "stream";
243
265
 
244
266
  // src/admin/repo.ts
245
- import { asc as asc2, eq as eq2, inArray, sql as sql2 } from "drizzle-orm";
267
+ import {
268
+ and as and2,
269
+ asc as asc2,
270
+ eq as eq2,
271
+ gt,
272
+ ilike,
273
+ inArray,
274
+ or,
275
+ sql as sql2
276
+ } from "drizzle-orm";
246
277
  async function withImages(db, rows) {
247
278
  if (rows.length === 0) return [];
248
279
  const images = await db.select().from(productImages).where(
@@ -256,9 +287,42 @@ async function withImages(db, rows) {
256
287
  images: images.filter((i) => i.productId === row.id)
257
288
  }));
258
289
  }
259
- async function listAllProducts(db) {
260
- const rows = await db.select().from(products).orderBy(asc2(products.createdAt), asc2(products.slug));
261
- return withImages(db, rows);
290
+ function escapeLike(value) {
291
+ return value.replace(/[\\%_]/g, (c) => `\\${c}`);
292
+ }
293
+ function pageConditions(filter) {
294
+ const parts = [];
295
+ if (filter.status) parts.push(eq2(products.status, filter.status));
296
+ if (filter.stock === "in") parts.push(gt(products.quantity, 0));
297
+ if (filter.stock === "sold") parts.push(eq2(products.quantity, 0));
298
+ if (filter.q) parts.push(ilike(products.name, `%${escapeLike(filter.q)}%`));
299
+ if (filter.after) {
300
+ const createdAt = sql2`${filter.after.createdAt}::timestamptz`;
301
+ parts.push(
302
+ or(
303
+ gt(products.createdAt, createdAt),
304
+ and2(
305
+ eq2(products.createdAt, createdAt),
306
+ gt(products.slug, filter.after.slug)
307
+ )
308
+ )
309
+ );
310
+ }
311
+ return and2(...parts);
312
+ }
313
+ async function selectProductPage(db, filter) {
314
+ const selected = await db.select({
315
+ product: products,
316
+ createdAtKey: sql2`${products.createdAt}::text`
317
+ }).from(products).where(pageConditions(filter)).orderBy(asc2(products.createdAt), asc2(products.slug)).limit(filter.limit);
318
+ const records = await withImages(
319
+ db,
320
+ selected.map((s) => s.product)
321
+ );
322
+ return records.map((r, i) => ({
323
+ ...r,
324
+ createdAtKey: selected[i].createdAtKey
325
+ }));
262
326
  }
263
327
  async function findProductById(db, id) {
264
328
  const rows = await db.select().from(products).where(eq2(products.id, id));
@@ -295,6 +359,7 @@ async function setImagePosition(db, id, position) {
295
359
  // src/admin/service.ts
296
360
  import {
297
361
  adminProductInputSchema,
362
+ adminProductListQuerySchema,
298
363
  adminProductPatchSchema
299
364
  } from "@develemit/commerce-contracts";
300
365
 
@@ -338,8 +403,25 @@ async function requireProduct(db, id) {
338
403
  if (!found) throw new ProductNotFoundError(id);
339
404
  return found;
340
405
  }
341
- async function listAdminProducts(db) {
342
- return (await listAllProducts(db)).map(toAdminProduct);
406
+ function decodeProductCursor(raw) {
407
+ const { at, key } = decodeCursor(raw, ({ at: at2 }) => PG_TIMESTAMP.test(at2));
408
+ return { createdAt: at, slug: key };
409
+ }
410
+ async function listAdminProducts(db, rawQuery = {}) {
411
+ const query = adminProductListQuerySchema.parse(rawQuery);
412
+ const found = await selectProductPage(db, {
413
+ status: query.status,
414
+ stock: query.stock,
415
+ q: query.q,
416
+ after: query.cursor ? decodeProductCursor(query.cursor) : void 0,
417
+ limit: query.limit + 1
418
+ });
419
+ const page = found.slice(0, query.limit);
420
+ const last = page[page.length - 1];
421
+ return {
422
+ items: page.map(toAdminProduct),
423
+ nextCursor: found.length > query.limit && last ? encodeCursor({ at: last.createdAtKey, key: last.row.slug }) : null
424
+ };
343
425
  }
344
426
  async function getAdminProduct(db, id) {
345
427
  return toAdminProduct(await requireProduct(db, id));
@@ -473,7 +555,7 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
473
555
  if (err instanceof ProductNotFoundError || err instanceof ProductImageNotFoundError) {
474
556
  return reply.code(404).send({ error: err.code, message: err.message });
475
557
  }
476
- if (err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
558
+ if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
477
559
  return reply.code(400).send({ error: err.code, message: err.message });
478
560
  }
479
561
  if (err.code === "FST_REQ_FILE_TOO_LARGE") {
@@ -488,7 +570,9 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
488
570
  });
489
571
  app.get(
490
572
  "/products",
491
- async () => (await listAdminProducts(db)).map((p) => adminProductSchema.parse(p))
573
+ async (req) => adminProductPageSchema.parse(
574
+ await listAdminProducts(db, req.query)
575
+ )
492
576
  );
493
577
  app.post("/products", async (req, reply) => {
494
578
  const input = adminProductInputSchema2.parse(req.body);
@@ -544,12 +628,46 @@ var adminPlugin = async (app, { db, currency, authorize, storage }) => {
544
628
  // src/catalog/routes.ts
545
629
  import {
546
630
  categorySchema,
631
+ productCardPageSchema,
547
632
  productListQuerySchema,
548
633
  productSchema
549
634
  } from "@develemit/commerce-contracts";
550
635
 
636
+ // src/catalog/cache-hooks.ts
637
+ import { createHash } from "crypto";
638
+ var DEFAULT_CATALOG_CACHE_CONTROL = "public, max-age=30, stale-while-revalidate=300";
639
+ function etagOf(body) {
640
+ return `"${createHash("sha1").update(body).digest("base64url")}"`;
641
+ }
642
+ function matchesIfNoneMatch(header, etag) {
643
+ if (!header) return false;
644
+ if (header.trim() === "*") return true;
645
+ return header.split(",").map((t) => t.trim().replace(/^W\//, "")).includes(etag);
646
+ }
647
+ function addCatalogCaching(app, { cacheControl = DEFAULT_CATALOG_CACHE_CONTROL } = {}) {
648
+ app.addHook("onSend", async (req, reply, payload) => {
649
+ if (reply.statusCode >= 400) {
650
+ reply.header("cache-control", "no-store");
651
+ return payload;
652
+ }
653
+ if (reply.statusCode !== 200 || typeof payload !== "string") {
654
+ return payload;
655
+ }
656
+ const etag = etagOf(payload);
657
+ reply.header("etag", etag);
658
+ if (cacheControl !== false) reply.header("cache-control", cacheControl);
659
+ if (matchesIfNoneMatch(req.headers["if-none-match"], etag)) {
660
+ reply.code(304);
661
+ reply.removeHeader("content-length");
662
+ reply.removeHeader("content-type");
663
+ return "";
664
+ }
665
+ return payload;
666
+ });
667
+ }
668
+
551
669
  // src/inventory/repo.ts
552
- import { and as and2, asc as asc3, eq as eq3, gt, inArray as inArray2, lt, sql as sql3 } from "drizzle-orm";
670
+ import { and as and3, asc as asc3, eq as eq3, gt as gt2, inArray as inArray2, lt, sql as sql3 } from "drizzle-orm";
553
671
  async function lockProductQuantities(tx, productIds) {
554
672
  const rows = await tx.select({ id: products.id, quantity: products.quantity }).from(products).where(inArray2(products.id, productIds)).orderBy(asc3(products.id)).for("update");
555
673
  return new Map(rows.map((r) => [r.id, r.quantity]));
@@ -560,10 +678,10 @@ async function activeHeldQuantities(db, productIds) {
560
678
  productId: inventoryHolds.productId,
561
679
  held: sql3`sum(${inventoryHolds.quantity})::int`
562
680
  }).from(inventoryHolds).where(
563
- and2(
681
+ and3(
564
682
  inArray2(inventoryHolds.productId, productIds),
565
683
  eq3(inventoryHolds.status, "active"),
566
- gt(inventoryHolds.expiresAt, sql3`now()`)
684
+ gt2(inventoryHolds.expiresAt, sql3`now()`)
567
685
  )
568
686
  ).groupBy(inventoryHolds.productId);
569
687
  return new Map(rows.map((r) => [r.productId, r.held]));
@@ -585,7 +703,7 @@ async function insertHolds(tx, reference, items, expiresAt) {
585
703
  }
586
704
  async function releaseHolds(db, reference) {
587
705
  await db.update(inventoryHolds).set({ status: "released" }).where(
588
- and2(
706
+ and3(
589
707
  eq3(inventoryHolds.reference, reference),
590
708
  eq3(inventoryHolds.status, "active")
591
709
  )
@@ -597,7 +715,7 @@ async function uncommittedHolds(tx, reference) {
597
715
  quantity: inventoryHolds.quantity,
598
716
  lapsed: sql3`(${inventoryHolds.status} = 'released' or ${inventoryHolds.expiresAt} <= now())`
599
717
  }).from(inventoryHolds).where(
600
- and2(
718
+ and3(
601
719
  eq3(inventoryHolds.reference, reference),
602
720
  inArray2(inventoryHolds.status, ["active", "released"])
603
721
  )
@@ -605,7 +723,7 @@ async function uncommittedHolds(tx, reference) {
605
723
  }
606
724
  async function markHoldsCommitted(tx, reference) {
607
725
  await tx.update(inventoryHolds).set({ status: "committed" }).where(
608
- and2(
726
+ and3(
609
727
  eq3(inventoryHolds.reference, reference),
610
728
  inArray2(inventoryHolds.status, ["active", "released"])
611
729
  )
@@ -613,10 +731,10 @@ async function markHoldsCommitted(tx, reference) {
613
731
  }
614
732
  async function extendActiveHolds(db, reference, expiresAt) {
615
733
  await db.update(inventoryHolds).set({ expiresAt }).where(
616
- and2(
734
+ and3(
617
735
  eq3(inventoryHolds.reference, reference),
618
736
  eq3(inventoryHolds.status, "active"),
619
- gt(inventoryHolds.expiresAt, sql3`now()`)
737
+ gt2(inventoryHolds.expiresAt, sql3`now()`)
620
738
  )
621
739
  );
622
740
  }
@@ -634,7 +752,7 @@ async function incrementQuantity(tx, productId, quantity) {
634
752
  }
635
753
  async function releaseExpiredHolds(db) {
636
754
  const released = await db.update(inventoryHolds).set({ status: "released" }).where(
637
- and2(
755
+ and3(
638
756
  eq3(inventoryHolds.status, "active"),
639
757
  lt(inventoryHolds.expiresAt, sql3`now()`)
640
758
  )
@@ -741,15 +859,14 @@ async function restock(tx, items) {
741
859
  }
742
860
 
743
861
  // src/catalog/repo.ts
744
- import { and as and3, asc as asc4, eq as eq4, inArray as inArray3, sql as sql4 } from "drizzle-orm";
745
- async function attachImages(db, rows) {
862
+ import { and as and4, asc as asc4, eq as eq4, gt as gt3, inArray as inArray3, or as or2, sql as sql4 } from "drizzle-orm";
863
+ async function attachImages(db, rows, firstOnly = false) {
746
864
  if (rows.length === 0) return [];
747
- const images = await db.select().from(productImages).where(
748
- inArray3(
749
- productImages.productId,
750
- rows.map((r) => r.id)
751
- )
752
- ).orderBy(asc4(productImages.position));
865
+ const where = inArray3(
866
+ productImages.productId,
867
+ rows.map((r) => r.id)
868
+ );
869
+ const images = firstOnly ? await db.selectDistinctOn([productImages.productId]).from(productImages).where(where).orderBy(productImages.productId, asc4(productImages.position)) : await db.select().from(productImages).where(where).orderBy(asc4(productImages.position));
753
870
  return rows.map((row) => ({
754
871
  row,
755
872
  images: images.filter((i) => i.productId === row.id)
@@ -760,11 +877,34 @@ async function listActiveProducts(db, filter) {
760
877
  if (filter.category) conditions2.push(eq4(products.category, filter.category));
761
878
  if (filter.featured !== void 0)
762
879
  conditions2.push(eq4(products.featured, filter.featured));
763
- const rows = await db.select().from(products).where(and3(...conditions2)).orderBy(asc4(products.createdAt), asc4(products.slug));
764
- return attachImages(db, rows);
880
+ if (filter.after) {
881
+ const createdAt = sql4`${filter.after.createdAt}::timestamptz`;
882
+ conditions2.push(
883
+ or2(
884
+ gt3(products.createdAt, createdAt),
885
+ and4(
886
+ eq4(products.createdAt, createdAt),
887
+ gt3(products.slug, filter.after.slug)
888
+ )
889
+ )
890
+ );
891
+ }
892
+ const selected = await db.select({
893
+ product: products,
894
+ createdAtKey: sql4`${products.createdAt}::text`
895
+ }).from(products).where(and4(...conditions2)).orderBy(asc4(products.createdAt), asc4(products.slug)).limit(filter.limit + 1);
896
+ const withImages2 = await attachImages(
897
+ db,
898
+ selected.map((s) => s.product),
899
+ true
900
+ );
901
+ return withImages2.map((p, i) => ({
902
+ ...p,
903
+ createdAtKey: selected[i].createdAtKey
904
+ }));
765
905
  }
766
906
  async function findActiveProductBySlug(db, slug) {
767
- const rows = await db.select().from(products).where(and3(eq4(products.slug, slug), eq4(products.status, "active"))).limit(1);
907
+ const rows = await db.select().from(products).where(and4(eq4(products.slug, slug), eq4(products.status, "active"))).limit(1);
768
908
  return (await attachImages(db, rows))[0];
769
909
  }
770
910
  async function countActiveByCategory(db) {
@@ -776,7 +916,7 @@ async function countActiveByCategory(db) {
776
916
  }
777
917
  async function findActiveProductsByIds(db, ids) {
778
918
  if (ids.length === 0) return [];
779
- return db.select().from(products).where(and3(inArray3(products.id, ids), eq4(products.status, "active")));
919
+ return db.select().from(products).where(and4(inArray3(products.id, ids), eq4(products.status, "active")));
780
920
  }
781
921
 
782
922
  // src/catalog/service.ts
@@ -802,17 +942,34 @@ function toProduct({ row, images }, free) {
802
942
  featured: row.featured
803
943
  };
804
944
  }
945
+ function toCard(product) {
946
+ const { description, materials, dimensions, ...card } = product;
947
+ void [description, materials, dimensions];
948
+ return card;
949
+ }
805
950
  function labelFor(slug, labels) {
806
951
  const label = typeof labels === "function" ? labels(slug) : labels?.[slug];
807
952
  return label ?? slug;
808
953
  }
954
+ function decodeProductCursor2(raw) {
955
+ const { at, key } = decodeCursor(raw, ({ at: at2 }) => PG_TIMESTAMP.test(at2));
956
+ return { createdAt: at, slug: key };
957
+ }
809
958
  async function listProducts(db, filter) {
810
- const found = await listActiveProducts(db, filter);
959
+ const found = await listActiveProducts(db, {
960
+ ...filter,
961
+ after: filter.cursor ? decodeProductCursor2(filter.cursor) : void 0
962
+ });
963
+ const page = found.slice(0, filter.limit);
964
+ const last = page[page.length - 1];
811
965
  const free = await freeQuantity(
812
966
  db,
813
- found.map((p) => p.row.id)
967
+ page.map((p) => p.row.id)
814
968
  );
815
- return found.map((p) => toProduct(p, free));
969
+ return {
970
+ items: page.map((p) => toCard(toProduct(p, free))),
971
+ nextCursor: found.length > filter.limit && last ? encodeCursor({ at: last.createdAtKey ?? "", key: last.row.slug }) : null
972
+ };
816
973
  }
817
974
  async function getProduct(db, slug) {
818
975
  const found = await findActiveProductBySlug(db, slug);
@@ -837,24 +994,29 @@ async function findPurchasable(db, ids) {
837
994
  }
838
995
 
839
996
  // src/catalog/routes.ts
840
- var catalogPlugin = async (app, { db, categoryLabels }) => {
997
+ var catalogPlugin = async (app, { db, categoryLabels, catalogCache }) => {
998
+ addCatalogCaching(app, catalogCache);
841
999
  app.setErrorHandler((err, _req, reply) => {
842
1000
  if (err instanceof ProductNotFoundError) {
843
1001
  return reply.code(404).send({ error: err.code, message: err.message });
844
1002
  }
1003
+ if (err instanceof InvalidCursorError) {
1004
+ return reply.code(400).send({ error: err.code, message: err.message });
1005
+ }
845
1006
  return reply.send(err);
846
1007
  });
847
1008
  app.get("/products", async (req, reply) => {
848
1009
  const raw = req.query;
849
1010
  const parsed = productListQuerySchema.safeParse({
850
1011
  category: raw.category || void 0,
851
- featured: raw.featured === void 0 ? void 0 : raw.featured === "true"
1012
+ featured: raw.featured === void 0 ? void 0 : raw.featured === "true",
1013
+ limit: raw.limit,
1014
+ cursor: raw.cursor || void 0
852
1015
  });
853
1016
  if (!parsed.success) {
854
1017
  return reply.code(400).send({ error: "INVALID_QUERY" });
855
1018
  }
856
- const products2 = await listProducts(db, parsed.data);
857
- return products2.map((p) => productSchema.parse(p));
1019
+ return productCardPageSchema.parse(await listProducts(db, parsed.data));
858
1020
  });
859
1021
  app.get("/products/:slug", async (req) => {
860
1022
  return productSchema.parse(await getProduct(db, req.params.slug));
@@ -874,8 +1036,8 @@ import {
874
1036
  } from "@develemit/commerce-contracts";
875
1037
 
876
1038
  // src/orders/admin-repo.ts
877
- import { and as and4, desc, eq as eq5, ilike, lt as lt2, or, sql as sql5 } from "drizzle-orm";
878
- function escapeLike(value) {
1039
+ import { and as and5, desc, eq as eq5, ilike as ilike2, lt as lt2, or as or3, sql as sql5 } from "drizzle-orm";
1040
+ function escapeLike2(value) {
879
1041
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
880
1042
  }
881
1043
  function conditions(filter) {
@@ -890,21 +1052,21 @@ function conditions(filter) {
890
1052
  parts.push(filter.hasPendingRefund ? pending : sql5`not ${pending}`);
891
1053
  }
892
1054
  if (filter.q) {
893
- const prefix = `${escapeLike(filter.q)}%`;
1055
+ const prefix = `${escapeLike2(filter.q)}%`;
894
1056
  parts.push(
895
- or(ilike(orders.email, prefix), sql5`${orders.id}::text ilike ${prefix}`)
1057
+ or3(ilike2(orders.email, prefix), sql5`${orders.id}::text ilike ${prefix}`)
896
1058
  );
897
1059
  }
898
1060
  if (filter.after) {
899
1061
  const { createdAt, id } = filter.after;
900
1062
  parts.push(
901
- or(
1063
+ or3(
902
1064
  lt2(orders.createdAt, createdAt),
903
- and4(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
1065
+ and5(eq5(orders.createdAt, createdAt), lt2(orders.id, id))
904
1066
  )
905
1067
  );
906
1068
  }
907
- return and4(...parts);
1069
+ return and5(...parts);
908
1070
  }
909
1071
  async function selectOrderPage(db, filter) {
910
1072
  const itemCount = sql5`coalesce((
@@ -926,11 +1088,11 @@ async function findOrderRefunds(db, orderId) {
926
1088
  }
927
1089
 
928
1090
  // src/orders/repo.ts
929
- import { and as and6, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
1091
+ import { and as and7, asc as asc5, eq as eq7, sql as sql7 } from "drizzle-orm";
930
1092
 
931
1093
  // src/orders/refund-repo.ts
932
- import { and as and5, eq as eq6, isNotNull, isNull, or as or2, sql as sql6 } from "drizzle-orm";
933
- var isRecordedRefund = or2(
1094
+ import { and as and6, eq as eq6, isNotNull, isNull, or as or4, sql as sql6 } from "drizzle-orm";
1095
+ var isRecordedRefund = or4(
934
1096
  isNull(orderRefunds.idempotencyKey),
935
1097
  isNotNull(orderRefunds.gatewayRefundId)
936
1098
  );
@@ -951,7 +1113,7 @@ async function sumReservedCents(tx, orderId) {
951
1113
  }
952
1114
  async function findRefundByKey(tx, orderId, idempotencyKey2) {
953
1115
  const [row] = await tx.select().from(orderRefunds).where(
954
- and5(
1116
+ and6(
955
1117
  eq6(orderRefunds.orderId, orderId),
956
1118
  eq6(orderRefunds.idempotencyKey, idempotencyKey2)
957
1119
  )
@@ -963,7 +1125,7 @@ async function insertPendingRefund(tx, refund) {
963
1125
  return row;
964
1126
  }
965
1127
  async function deletePendingRefund(db, id) {
966
- await db.delete(orderRefunds).where(and5(eq6(orderRefunds.id, id), isNull(orderRefunds.gatewayRefundId)));
1128
+ await db.delete(orderRefunds).where(and6(eq6(orderRefunds.id, id), isNull(orderRefunds.gatewayRefundId)));
967
1129
  }
968
1130
  async function confirmRefund(tx, pending, gatewayRefundId) {
969
1131
  const [existing] = await tx.select({ id: orderRefunds.id }).from(orderRefunds).where(eq6(orderRefunds.gatewayRefundId, gatewayRefundId));
@@ -972,7 +1134,7 @@ async function confirmRefund(tx, pending, gatewayRefundId) {
972
1134
  return void 0;
973
1135
  }
974
1136
  const [updated] = await tx.update(orderRefunds).set({ gatewayRefundId }).where(
975
- and5(
1137
+ and6(
976
1138
  eq6(orderRefunds.id, pending.id),
977
1139
  isNull(orderRefunds.gatewayRefundId)
978
1140
  )
@@ -998,7 +1160,7 @@ async function confirmPendingByKey(tx, orderId, scopedKey, gatewayRefundId) {
998
1160
  const prefix = `${orderId}:`;
999
1161
  if (!scopedKey.startsWith(prefix)) return void 0;
1000
1162
  const [updated] = await tx.update(orderRefunds).set({ gatewayRefundId }).where(
1001
- and5(
1163
+ and6(
1002
1164
  eq6(orderRefunds.orderId, orderId),
1003
1165
  eq6(orderRefunds.idempotencyKey, scopedKey.slice(prefix.length)),
1004
1166
  isNull(orderRefunds.gatewayRefundId)
@@ -1050,7 +1212,7 @@ async function recordEvent(db, id, type) {
1050
1212
  async function sumRefundedCents(db, orderId) {
1051
1213
  const [row] = await db.select({
1052
1214
  total: sql7`coalesce(sum(${orderRefunds.amountCents}), 0)::int`
1053
- }).from(orderRefunds).where(and6(eq7(orderRefunds.orderId, orderId), isRecordedRefund));
1215
+ }).from(orderRefunds).where(and7(eq7(orderRefunds.orderId, orderId), isRecordedRefund));
1054
1216
  return row.total;
1055
1217
  }
1056
1218
 
@@ -1100,13 +1262,6 @@ async function detailById(db, orderId) {
1100
1262
 
1101
1263
  // src/orders/admin-service.ts
1102
1264
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1103
- var InvalidCursorError = class extends Error {
1104
- code = "INVALID_CURSOR";
1105
- constructor() {
1106
- super("The cursor is malformed");
1107
- this.name = "InvalidCursorError";
1108
- }
1109
- };
1110
1265
  var InvalidOrderIdError = class extends Error {
1111
1266
  code = "INVALID_ORDER_ID";
1112
1267
  constructor() {
@@ -1137,30 +1292,26 @@ function toOrderRefund(row) {
1137
1292
  function isOrderId(value) {
1138
1293
  return UUID.test(value);
1139
1294
  }
1140
- function encodeCursor(createdAt, id) {
1141
- return Buffer.from(`${createdAt.toISOString()}|${id}`).toString("base64url");
1142
- }
1143
- function decodeCursor(cursor) {
1144
- const [iso, id, ...rest] = Buffer.from(cursor, "base64url").toString("utf8").split("|");
1145
- const createdAt = new Date(iso ?? "");
1146
- if (rest.length || !id || !isOrderId(id) || Number.isNaN(+createdAt)) {
1147
- throw new InvalidCursorError();
1148
- }
1149
- return { createdAt, id };
1295
+ function decodeOrderCursor(raw) {
1296
+ const { at, key } = decodeCursor(
1297
+ raw,
1298
+ (c) => isOrderId(c.key) && !Number.isNaN(+new Date(c.at))
1299
+ );
1300
+ return { createdAt: new Date(at), id: key };
1150
1301
  }
1151
1302
  async function listAdminOrders(db, query) {
1152
1303
  const rows = await selectOrderPage(db, {
1153
1304
  status: query.status,
1154
1305
  q: query.q,
1155
1306
  hasPendingRefund: query.hasPendingRefund,
1156
- after: query.cursor ? decodeCursor(query.cursor) : void 0,
1307
+ after: query.cursor ? decodeOrderCursor(query.cursor) : void 0,
1157
1308
  limit: query.limit + 1
1158
1309
  });
1159
1310
  const page = rows.slice(0, query.limit);
1160
1311
  const last = page[page.length - 1];
1161
1312
  return {
1162
1313
  items: page.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
1163
- nextCursor: rows.length > query.limit && last ? encodeCursor(last.createdAt, last.id) : null
1314
+ nextCursor: rows.length > query.limit && last ? encodeCursor({ at: last.createdAt.toISOString(), key: last.id }) : null
1164
1315
  };
1165
1316
  }
1166
1317
  async function getAdminOrder(db, id) {
@@ -1838,12 +1989,14 @@ var commercePlugin = async (app, {
1838
1989
  shippingCountries = ["US"],
1839
1990
  hooks,
1840
1991
  admin,
1841
- categoryLabels
1992
+ categoryLabels,
1993
+ catalogCache
1842
1994
  }) => {
1843
1995
  app.get("/health", async () => ({ status: "ok" }));
1844
1996
  await app.register(catalogPlugin, {
1845
1997
  db,
1846
1998
  categoryLabels,
1999
+ catalogCache,
1847
2000
  prefix: "/catalog"
1848
2001
  });
1849
2002
  if (admin) {