import {
assertNoUserErrors,
clampInt,
nodesFromConnection,
pageInfoFromConnection,
shopifyGraphql,
trimString,
} from './lib/client.ts'
import { PRODUCT_FIELDS, PAGE_INFO_FIELDS } from './lib/fragments.ts'
import { toGid } from './lib/shop.ts'
import type { DryRunResult, ShopifyClientOptions, ShopifyPageInfo } from './types.ts'
export type ProductVariantSummary = {
id: string
title: string | null
sku: string | null
barcode: string | null
price: string | null
compareAtPrice: string | null
inventoryQuantity: number | null
}
export type ProductSummary = {
id: string
title: string
handle: string | null
status: string | null
vendor: string | null
productType: string | null
tags: Array<string>
createdAt: string | null
updatedAt: string | null
totalInventory: number | null
description: string | null
variants: Array<ProductVariantSummary>
}
export type ListProductsParams = ShopifyClientOptions & {
/** Shopify search query, e.g. `status:active title:backpack`. */
query?: string
first?: number
after?: string
}
function mapVariant(node: Record<string, unknown>): ProductVariantSummary {
return {
id: String(node.id || ''),
title: typeof node.title === 'string' ? node.title : null,
sku: typeof node.sku === 'string' ? node.sku : null,
barcode: typeof node.barcode === 'string' ? node.barcode : null,
price: typeof node.price === 'string' ? node.price : null,
compareAtPrice:
typeof node.compareAtPrice === 'string' ? node.compareAtPrice : null,
inventoryQuantity:
typeof node.inventoryQuantity === 'number' ? node.inventoryQuantity : null,
}
}
function mapProduct(node: Record<string, unknown>): ProductSummary {
return {
id: String(node.id || ''),
title: String(node.title || ''),
handle: typeof node.handle === 'string' ? node.handle : null,
status: typeof node.status === 'string' ? node.status : null,
vendor: typeof node.vendor === 'string' ? node.vendor : null,
productType: typeof node.productType === 'string' ? node.productType : null,
tags: Array.isArray(node.tags) ? node.tags.map(String) : [],
createdAt: typeof node.createdAt === 'string' ? node.createdAt : null,
updatedAt: typeof node.updatedAt === 'string' ? node.updatedAt : null,
totalInventory:
typeof node.totalInventory === 'number' ? node.totalInventory : null,
description: typeof node.description === 'string' ? node.description : null,
variants: nodesFromConnection<Record<string, unknown>>(node.variants).map(
mapVariant,
),
}
}
/**
* List products with optional Shopify search syntax.
*
* @example
* import { listProducts } from 'kody:@kody/shopify/products'
* const { items } = await listProducts({ shop: 'acme', query: 'status:active', first: 10 })
*/
export async function listProducts(params: ListProductsParams = {}) {
const first = clampInt(params.first, 1, 50, 20)
const data = await shopifyGraphql<{ products: unknown }>({
shop: params.shop,
apiVersion: params.apiVersion,
query: `query ListProducts($first: Int!, $after: String, $query: String) {
products(first: $first, after: $after, query: $query) {
${PAGE_INFO_FIELDS}
nodes { ${PRODUCT_FIELDS} }
}
}`,
variables: {
first,
after: trimString(params.after) || null,
query: trimString(params.query) || null,
},
})
return {
items: nodesFromConnection<Record<string, unknown>>(data.products).map(
mapProduct,
),
pageInfo: pageInfoFromConnection(data.products) as ShopifyPageInfo,
}
}
/**
* Get one product by GID or numeric id.
*
* @example
* import { getProduct } from 'kody:@kody/shopify/products'
* const product = await getProduct({ shop: 'acme', id: '123' })
*/
export async function getProduct(
params: ShopifyClientOptions & { id: string },
): Promise<ProductSummary> {
const id = toGid('Product', params.id)
const data = await shopifyGraphql<{ product: Record<string, unknown> | null }>(
{
shop: params.shop,
apiVersion: params.apiVersion,
query: `query GetProduct($id: ID!) {
product(id: $id) { ${PRODUCT_FIELDS} }
}`,
variables: { id },
},
)
if (!data.product) throw new Error(`Product not found: ${id}`)
return mapProduct(data.product)
}
export type CreateProductParams = ShopifyClientOptions & {
title: string
descriptionHtml?: string
vendor?: string
productType?: string
tags?: Array<string> | string
status?: 'ACTIVE' | 'DRAFT' | 'ARCHIVED' | 'UNLISTED'
/** Preview the mutation payload without writing. */
dryRun?: boolean
}
/**
* Create a product. Defaults to dry-run; pass `dryRun: false` to write.
*
* @example
* import { createProduct } from 'kody:@kody/shopify/products'
* const preview = await createProduct({ shop: 'acme', title: 'Hiking backpack' })
*/
export async function createProduct(params: CreateProductParams) {
const title = trimString(params.title)
if (!title) throw new Error('title is required.')
const product = {
title,
descriptionHtml: trimString(params.descriptionHtml) || undefined,
vendor: trimString(params.vendor) || undefined,
productType: trimString(params.productType) || undefined,
tags: Array.isArray(params.tags)
? params.tags
: trimString(params.tags)
? trimString(params.tags)
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: undefined,
status: params.status,
}
if (params.dryRun !== false) {
const preview: DryRunResult<{ mutation: string; product: typeof product }> =
{
dryRun: true,
wouldCall: { mutation: 'productCreate', product },
}
return preview
}
const data = await shopifyGraphql<{ productCreate: unknown }>({
shop: params.shop,
apiVersion: params.apiVersion,
query: `mutation CreateProduct($product: ProductCreateInput!) {
productCreate(product: $product) {
product { ${PRODUCT_FIELDS} }
userErrors { field message }
}
}`,
variables: { product },
})
assertNoUserErrors(data.productCreate, 'productCreate')
const created = (data.productCreate as { product?: Record<string, unknown> })
.product
if (!created) throw new Error('productCreate returned no product.')
return mapProduct(created)
}
export type UpdateProductParams = ShopifyClientOptions & {
id: string
title?: string
descriptionHtml?: string
vendor?: string
productType?: string
tags?: Array<string> | string
status?: 'ACTIVE' | 'DRAFT' | 'ARCHIVED' | 'UNLISTED'
dryRun?: boolean
}
/**
* Update a product. Defaults to dry-run; pass `dryRun: false` to write.
*/
export async function updateProduct(params: UpdateProductParams) {
const id = toGid('Product', params.id)
const product = {
title: trimString(params.title) || undefined,
descriptionHtml: trimString(params.descriptionHtml) || undefined,
vendor: trimString(params.vendor) || undefined,
productType: trimString(params.productType) || undefined,
tags: Array.isArray(params.tags)
? params.tags
: trimString(params.tags)
? trimString(params.tags)
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: undefined,
status: params.status,
}
if (params.dryRun !== false) {
return {
dryRun: true as const,
wouldCall: { mutation: 'productUpdate', id, product },
}
}
const data = await shopifyGraphql<{ productUpdate: unknown }>({
shop: params.shop,
apiVersion: params.apiVersion,
query: `mutation UpdateProduct($input: ProductUpdateInput!) {
productUpdate(product: $input) {
product { ${PRODUCT_FIELDS} }
userErrors { field message }
}
}`,
variables: { input: { id, ...product } },
})
assertNoUserErrors(data.productUpdate, 'productUpdate')
const updated = (data.productUpdate as { product?: Record<string, unknown> })
.product
if (!updated) throw new Error('productUpdate returned no product.')
return mapProduct(updated)
}
/** Default export: list products. */
export default listProducts