Initial commit
This commit is contained in:
410
skills/database-best-practices/SKILL.md
Normal file
410
skills/database-best-practices/SKILL.md
Normal file
@@ -0,0 +1,410 @@
|
||||
---
|
||||
name: database-best-practices
|
||||
description: Prisma ORM best practices for Shopify apps including multi-tenant data isolation, query optimization, transaction patterns, and migration strategies. Auto-invoked when working with database operations.
|
||||
allowed-tools: [Read, Edit, Write, Grep, Glob]
|
||||
---
|
||||
|
||||
# Database Best Practices Skill
|
||||
|
||||
## Purpose
|
||||
Provides best practices and patterns for database operations in Shopify apps using Prisma ORM, focusing on data isolation, query optimization, and safe migrations.
|
||||
|
||||
## When This Skill Activates
|
||||
- Working with Prisma schema or queries
|
||||
- Creating database migrations
|
||||
- Optimizing database performance
|
||||
- Implementing multi-tenant data isolation
|
||||
- Handling transactions
|
||||
|
||||
## Critical: Multi-Tenant Data Isolation
|
||||
|
||||
**ALWAYS filter by shopId** - This prevents data leaks between shops.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Always include shopId
|
||||
const products = await db.product.findMany({
|
||||
where: {
|
||||
shopId: shop.id,
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
// ❌ WRONG - Missing shopId (data leak!)
|
||||
const products = await db.product.findMany({
|
||||
where: { status: "active" },
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Safe Query Pattern
|
||||
|
||||
```typescript
|
||||
// Always filter by shopId for shop-specific data
|
||||
async function getShopProducts(shopId: string) {
|
||||
return db.product.findMany({
|
||||
where: { shopId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
vendor: true,
|
||||
// Only select needed fields
|
||||
},
|
||||
take: 50,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Pagination Pattern
|
||||
|
||||
```typescript
|
||||
async function getPaginatedProducts(shopId: string, page: number = 1) {
|
||||
const pageSize = 50;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const [products, totalCount] = await Promise.all([
|
||||
db.product.findMany({
|
||||
where: { shopId },
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
db.product.count({
|
||||
where: { shopId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
products,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
currentPage: page,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Transaction Pattern
|
||||
|
||||
```typescript
|
||||
// Use transactions for operations that must succeed/fail together
|
||||
await db.$transaction(async (tx) => {
|
||||
// Update product
|
||||
await tx.product.update({
|
||||
where: { id: productId },
|
||||
data: { status: "synced" },
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
shopId: shop.id,
|
||||
entityType: "product",
|
||||
entityId: productId,
|
||||
action: "update",
|
||||
changesSummary: "Product synced",
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Upsert Pattern
|
||||
|
||||
```typescript
|
||||
// Create or update based on existence
|
||||
await db.product.upsert({
|
||||
where: {
|
||||
shopId_productId: {
|
||||
shopId: shop.id,
|
||||
productId: shopifyProductId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
shopId: shop.id,
|
||||
productId: shopifyProductId,
|
||||
title: "Product Title",
|
||||
},
|
||||
update: {
|
||||
title: "Updated Title",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Bulk Operations
|
||||
|
||||
```typescript
|
||||
// Use createMany for bulk inserts
|
||||
await db.product.createMany({
|
||||
data: products.map(p => ({
|
||||
shopId: shop.id,
|
||||
productId: p.id,
|
||||
title: p.title,
|
||||
})),
|
||||
skipDuplicates: true, // Skip if unique constraint violated
|
||||
});
|
||||
|
||||
// Use updateMany for bulk updates
|
||||
await db.product.updateMany({
|
||||
where: {
|
||||
shopId: shop.id,
|
||||
status: "pending",
|
||||
},
|
||||
data: {
|
||||
status: "processed",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 6. JSON Field Handling
|
||||
|
||||
```typescript
|
||||
// Store complex data as JSON
|
||||
const metadata = { tags: ["summer", "sale"], featured: true };
|
||||
|
||||
await db.product.create({
|
||||
data: {
|
||||
shopId: shop.id,
|
||||
title: "Product",
|
||||
metadata: JSON.stringify(metadata),
|
||||
},
|
||||
});
|
||||
|
||||
// Retrieve and parse JSON
|
||||
const product = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
});
|
||||
|
||||
const metadata = JSON.parse(product.metadata || "{}");
|
||||
```
|
||||
|
||||
### 7. Error Handling
|
||||
|
||||
```typescript
|
||||
// Handle unique constraint violations
|
||||
try {
|
||||
await db.product.create({
|
||||
data: { shopId, productId, title },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === "P2002") {
|
||||
// Unique constraint violation - update instead
|
||||
await db.product.update({
|
||||
where: { shopId_productId: { shopId, productId } },
|
||||
data: { title, updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. N+1 Query Prevention
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: N+1 query problem
|
||||
const products = await db.product.findMany();
|
||||
for (const product of products) {
|
||||
const shop = await db.shop.findUnique({
|
||||
where: { id: product.shopId },
|
||||
});
|
||||
console.log(shop.shopDomain);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Use include
|
||||
const products = await db.product.findMany({
|
||||
include: {
|
||||
shop: {
|
||||
select: {
|
||||
shopDomain: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Schema Design Patterns
|
||||
|
||||
### Multi-Tenant Schema
|
||||
|
||||
```prisma
|
||||
// Base Shop model - required
|
||||
model Shop {
|
||||
id String @id @default(cuid())
|
||||
shopDomain String @unique
|
||||
accessToken String
|
||||
installedAt DateTime @default(now())
|
||||
|
||||
// All shop-specific data references this
|
||||
products Product[]
|
||||
orders Order[]
|
||||
}
|
||||
|
||||
// Shop-specific model
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
shopId String
|
||||
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
|
||||
productId String // Shopify product ID
|
||||
title String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([shopId, productId])
|
||||
@@index([shopId])
|
||||
}
|
||||
```
|
||||
|
||||
### Common Indexes
|
||||
|
||||
```prisma
|
||||
// Essential indexes for performance
|
||||
@@index([shopId]) // Filter by shop
|
||||
@@index([shopId, status]) // Shop + status filter
|
||||
@@index([createdAt]) // Time-based queries
|
||||
@@index([updatedAt]) // Recently updated
|
||||
@@unique([shopId, externalId]) // Prevent duplicates per shop
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
### 1. Safe Migrations
|
||||
|
||||
```bash
|
||||
# Development - generates migration
|
||||
npx prisma migrate dev --name add_vendor_field
|
||||
|
||||
# Production - applies migrations
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### 2. Adding Fields Safely
|
||||
|
||||
```prisma
|
||||
// Step 1: Add as optional
|
||||
model Product {
|
||||
vendor String? // Optional first
|
||||
}
|
||||
|
||||
// Step 2: Backfill data
|
||||
// Run a script to populate vendor for existing records
|
||||
|
||||
// Step 3: Make required (in next migration)
|
||||
model Product {
|
||||
vendor String // Required after backfill
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Data Migration Script
|
||||
|
||||
```typescript
|
||||
// scripts/backfill-vendor.ts
|
||||
async function backfillVendor() {
|
||||
const products = await db.product.findMany({
|
||||
where: { vendor: null },
|
||||
});
|
||||
|
||||
for (const product of products) {
|
||||
await db.product.update({
|
||||
where: { id: product.id },
|
||||
data: {
|
||||
vendor: extractVendorFromTitle(product.title),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Updated ${products.length} products`);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### 1. Select Only Needed Fields
|
||||
|
||||
```typescript
|
||||
// ❌ Inefficient - fetches all fields
|
||||
const products = await db.product.findMany();
|
||||
|
||||
// ✅ Efficient - only needed fields
|
||||
const products = await db.product.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Use Appropriate Indexes
|
||||
|
||||
```prisma
|
||||
// Index frequently queried fields
|
||||
@@index([shopId, status]) // Composite index for common query
|
||||
@@index([createdAt]) // Time-based sorting
|
||||
@@index([vendor]) // Filtering by vendor
|
||||
```
|
||||
|
||||
### 3. Batch Operations
|
||||
|
||||
```typescript
|
||||
// Process in batches to avoid memory issues
|
||||
const batchSize = 100;
|
||||
let skip = 0;
|
||||
|
||||
while (true) {
|
||||
const batch = await db.product.findMany({
|
||||
where: { shopId: shop.id },
|
||||
skip,
|
||||
take: batchSize,
|
||||
});
|
||||
|
||||
if (batch.length === 0) break;
|
||||
|
||||
await processBatch(batch);
|
||||
skip += batchSize;
|
||||
}
|
||||
```
|
||||
|
||||
## Common Prisma Errors
|
||||
|
||||
### P2002 - Unique Constraint Violation
|
||||
|
||||
```typescript
|
||||
// Handle gracefully with upsert
|
||||
await db.product.upsert({
|
||||
where: { shopId_productId: { shopId, productId } },
|
||||
create: { shopId, productId, title },
|
||||
update: { title, updatedAt: new Date() },
|
||||
});
|
||||
```
|
||||
|
||||
### P2025 - Record Not Found
|
||||
|
||||
```typescript
|
||||
// Check existence first or use try/catch
|
||||
const product = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new Response("Product not found", { status: 404 });
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
- [ ] Always filter shop-specific queries by shopId
|
||||
- [ ] Use transactions for multi-step operations
|
||||
- [ ] Select only needed fields in queries
|
||||
- [ ] Add indexes for common query patterns
|
||||
- [ ] Handle unique constraint violations gracefully
|
||||
- [ ] Use upsert for create-or-update logic
|
||||
- [ ] Validate JSON data before storing
|
||||
- [ ] Test migrations in development first
|
||||
- [ ] Use cascade delete appropriately
|
||||
- [ ] Monitor query performance
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Proper database practices prevent data leaks, improve performance, and ensure data integrity.
|
||||
327
skills/polaris-ui-patterns/SKILL.md
Normal file
327
skills/polaris-ui-patterns/SKILL.md
Normal file
@@ -0,0 +1,327 @@
|
||||
---
|
||||
name: polaris-ui-patterns
|
||||
description: Build consistent UI using Polaris Web Components patterns for common page layouts like index pages, detail pages, forms, and empty states. Use this skill when creating new pages or refactoring existing UI components.
|
||||
allowed-tools: [Read, Edit, Write, Grep, Glob]
|
||||
---
|
||||
|
||||
# Polaris UI Patterns Skill
|
||||
|
||||
## Purpose
|
||||
This skill provides reusable UI patterns and templates for common page layouts in Shopify apps using Polaris Web Components.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating new pages (index, detail, form)
|
||||
- Implementing common UI patterns
|
||||
- Building consistent layouts
|
||||
- Adding empty states
|
||||
- Creating modals and forms
|
||||
- Implementing tables with actions
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Pattern 1: Index/List Page
|
||||
|
||||
**Use for**: Products, Orders, Customers, Custom Entities
|
||||
|
||||
```tsx
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { db } from "../db.server";
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const shop = await db.shop.findUnique({
|
||||
where: { shopDomain: session.shop }
|
||||
});
|
||||
|
||||
const items = await db.item.findMany({
|
||||
where: { shopId: shop.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: items.length,
|
||||
active: items.filter(i => i.isActive).length,
|
||||
};
|
||||
|
||||
return json({ items, stats });
|
||||
};
|
||||
|
||||
export default function ItemsIndexPage() {
|
||||
const { items, stats } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<s-page heading="Items">
|
||||
{/* Stats Section */}
|
||||
<s-section>
|
||||
<s-grid columns="3">
|
||||
<s-box border="base" borderRadius="base" padding="400">
|
||||
<s-stack gap="200" direction="vertical">
|
||||
<s-text variant="headingMd" as="h3">Total Items</s-text>
|
||||
<s-text variant="heading2xl" as="p">{stats.total}</s-text>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
<s-box border="base" borderRadius="base" padding="400">
|
||||
<s-stack gap="200" direction="vertical">
|
||||
<s-text variant="headingMd" as="h3">Active</s-text>
|
||||
<s-text variant="heading2xl" as="p">{stats.active}</s-text>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
</s-grid>
|
||||
</s-section>
|
||||
|
||||
{/* Table Section */}
|
||||
<s-section>
|
||||
<s-card>
|
||||
<s-table>
|
||||
<s-table-head>
|
||||
<s-table-row>
|
||||
<s-table-cell as="th">Name</s-table-cell>
|
||||
<s-table-cell as="th">Status</s-table-cell>
|
||||
<s-table-cell as="th">Created</s-table-cell>
|
||||
<s-table-cell as="th">Actions</s-table-cell>
|
||||
</s-table-row>
|
||||
</s-table-head>
|
||||
<s-table-body>
|
||||
{items.map(item => (
|
||||
<s-table-row key={item.id}>
|
||||
<s-table-cell>{item.name}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-badge tone={item.isActive ? "success" : undefined}>
|
||||
{item.isActive ? "Active" : "Inactive"}
|
||||
</s-badge>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{new Date(item.createdAt).toLocaleDateString()}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-button-group>
|
||||
<s-button variant="plain">Edit</s-button>
|
||||
<s-button variant="plain" tone="critical">Delete</s-button>
|
||||
</s-button-group>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
</s-card>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Detail/Edit Page
|
||||
|
||||
```tsx
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const shop = await db.shop.findUnique({
|
||||
where: { shopDomain: session.shop }
|
||||
});
|
||||
|
||||
const item = await db.item.findUnique({
|
||||
where: {
|
||||
id: params.id,
|
||||
shopId: shop.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return json({ item });
|
||||
};
|
||||
|
||||
export const action = async ({ params, request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name");
|
||||
const description = formData.get("description");
|
||||
|
||||
await db.item.update({
|
||||
where: { id: params.id },
|
||||
data: { name, description },
|
||||
});
|
||||
|
||||
return redirect("/app/items");
|
||||
};
|
||||
|
||||
export default function ItemDetailPage() {
|
||||
const { item } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<s-page heading={item.name} backUrl="/app/items">
|
||||
<form method="post">
|
||||
<s-card>
|
||||
<s-stack gap="400" direction="vertical">
|
||||
<s-text-field
|
||||
label="Name"
|
||||
name="name"
|
||||
defaultValue={item.name}
|
||||
required
|
||||
/>
|
||||
<s-text-field
|
||||
label="Description"
|
||||
name="description"
|
||||
defaultValue={item.description}
|
||||
multiline={4}
|
||||
/>
|
||||
<s-button-group>
|
||||
<s-button type="submit" variant="primary">Save</s-button>
|
||||
<s-button url="/app/items">Cancel</s-button>
|
||||
</s-button-group>
|
||||
</s-stack>
|
||||
</s-card>
|
||||
</form>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Modal Pattern
|
||||
|
||||
```tsx
|
||||
function ItemModal({ item, onClose }) {
|
||||
const submit = useSubmit();
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.target);
|
||||
submit(formData, { method: "post" });
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<s-modal open onClose={onClose} title="Edit Item">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<s-modal-section>
|
||||
<s-stack gap="400" direction="vertical">
|
||||
<s-text-field
|
||||
label="Name"
|
||||
name="name"
|
||||
defaultValue={item?.name}
|
||||
/>
|
||||
<s-text-field
|
||||
label="Description"
|
||||
name="description"
|
||||
defaultValue={item?.description}
|
||||
multiline={3}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-modal-section>
|
||||
<s-modal-footer>
|
||||
<s-button-group>
|
||||
<s-button onClick={onClose}>Cancel</s-button>
|
||||
<s-button type="submit" variant="primary">Save</s-button>
|
||||
</s-button-group>
|
||||
</s-modal-footer>
|
||||
</form>
|
||||
</s-modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Empty State
|
||||
|
||||
```tsx
|
||||
{items.length === 0 ? (
|
||||
<s-card>
|
||||
<s-empty-state
|
||||
heading="No items yet"
|
||||
image="https://cdn.shopify.com/..."
|
||||
>
|
||||
<s-text variant="bodyMd">
|
||||
Start by adding your first item
|
||||
</s-text>
|
||||
<s-button variant="primary" url="/app/items/new">
|
||||
Add Item
|
||||
</s-button>
|
||||
</s-empty-state>
|
||||
</s-card>
|
||||
) : (
|
||||
// Item list
|
||||
)}
|
||||
```
|
||||
|
||||
### Pattern 5: Loading State
|
||||
|
||||
```tsx
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
|
||||
export default function ItemsPage() {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "loading";
|
||||
|
||||
return (
|
||||
<s-page heading="Items">
|
||||
{isLoading ? (
|
||||
<s-card>
|
||||
<s-stack gap="400" direction="vertical">
|
||||
<s-skeleton-display-text />
|
||||
<s-skeleton-display-text />
|
||||
<s-skeleton-display-text />
|
||||
</s-stack>
|
||||
</s-card>
|
||||
) : (
|
||||
// Content
|
||||
)}
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Form with Validation
|
||||
|
||||
```tsx
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name");
|
||||
|
||||
const errors = {};
|
||||
if (!name) errors.name = "Name is required";
|
||||
if (name.length < 3) errors.name = "Name must be at least 3 characters";
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return json({ errors }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.item.create({ data: { name } });
|
||||
return redirect("/app/items");
|
||||
};
|
||||
|
||||
export default function NewItemPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
|
||||
return (
|
||||
<form method="post">
|
||||
<s-text-field
|
||||
label="Name"
|
||||
name="name"
|
||||
error={actionData?.errors?.name}
|
||||
required
|
||||
/>
|
||||
<s-button type="submit" variant="primary">Save</s-button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Consistent Layouts** - Use the same page structure across the app
|
||||
2. **Loading States** - Always show skeleton loaders during data fetching
|
||||
3. **Empty States** - Provide clear guidance when no data exists
|
||||
4. **Error Handling** - Show user-friendly error messages
|
||||
5. **Form Validation** - Validate on submit, show inline errors
|
||||
6. **Responsive Design** - Test on mobile, tablet, and desktop
|
||||
7. **Accessibility** - Use semantic HTML and ARIA attributes
|
||||
8. **SSR Compatibility** - Avoid hydration mismatches
|
||||
9. **Performance** - Lazy load components when appropriate
|
||||
10. **User Feedback** - Show success/error toasts after actions
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Consistent UI patterns create a professional, predictable user experience.
|
||||
396
skills/shopify-api-patterns/SKILL.md
Normal file
396
skills/shopify-api-patterns/SKILL.md
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
name: shopify-api-patterns
|
||||
description: Common Shopify Admin GraphQL API patterns for product queries, metafield operations, webhooks, and bulk operations. Auto-invoked when working with Shopify API integration.
|
||||
allowed-tools: [Read, Edit, Write, Grep, Glob]
|
||||
---
|
||||
|
||||
# Shopify API Patterns Skill
|
||||
|
||||
## Purpose
|
||||
Provides reusable patterns for common Shopify Admin GraphQL API operations including product queries, metafield management, webhook handling, and bulk operations.
|
||||
|
||||
## When This Skill Activates
|
||||
- Working with Shopify Admin GraphQL API
|
||||
- Querying products, variants, customers, or orders
|
||||
- Managing metafields
|
||||
- Implementing webhooks
|
||||
- Handling bulk operations
|
||||
- Implementing rate limiting
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Product Query with Pagination
|
||||
```graphql
|
||||
query getProducts($first: Int!, $after: String) {
|
||||
products(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
vendor
|
||||
handle
|
||||
productType
|
||||
tags
|
||||
variants(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
price
|
||||
sku
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Metafield Query Pattern
|
||||
```graphql
|
||||
query getProductMetafields($productId: ID!) {
|
||||
product(id: $productId) {
|
||||
id
|
||||
title
|
||||
metafields(first: 20, namespace: "custom") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
namespace
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Metafield Update Mutation
|
||||
```graphql
|
||||
mutation updateMetafields($metafields: [MetafieldsSetInput!]!) {
|
||||
metafieldsSet(metafields: $metafields) {
|
||||
metafields {
|
||||
id
|
||||
namespace
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage Example:**
|
||||
```typescript
|
||||
const response = await admin.graphql(UPDATE_METAFIELDS, {
|
||||
variables: {
|
||||
metafields: [
|
||||
{
|
||||
ownerId: "gid://shopify/Product/123",
|
||||
namespace: "custom",
|
||||
key: "color",
|
||||
value: "Red",
|
||||
type: "single_line_text_field",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Metafield Definition Creation
|
||||
```graphql
|
||||
mutation createMetafieldDefinition($definition: MetafieldDefinitionInput!) {
|
||||
metafieldDefinitionCreate(definition: $definition) {
|
||||
createdDefinition {
|
||||
id
|
||||
name
|
||||
namespace
|
||||
key
|
||||
type
|
||||
ownerType
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
await admin.graphql(CREATE_METAFIELD_DEFINITION, {
|
||||
variables: {
|
||||
definition: {
|
||||
name: "Product Color",
|
||||
namespace: "custom",
|
||||
key: "color",
|
||||
type: "single_line_text_field",
|
||||
ownerType: "PRODUCT",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Webhook Registration
|
||||
```graphql
|
||||
mutation registerWebhook($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
|
||||
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
|
||||
webhookSubscription {
|
||||
id
|
||||
topic
|
||||
endpoint {
|
||||
__typename
|
||||
... on WebhookHttpEndpoint {
|
||||
callbackUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common Topics:**
|
||||
- `PRODUCTS_CREATE`
|
||||
- `PRODUCTS_UPDATE`
|
||||
- `PRODUCTS_DELETE`
|
||||
- `ORDERS_CREATE`
|
||||
- `CUSTOMERS_CREATE`
|
||||
|
||||
### 6. Pagination Helper
|
||||
```typescript
|
||||
async function fetchAllProducts(admin) {
|
||||
let hasNextPage = true;
|
||||
let cursor = null;
|
||||
const allProducts = [];
|
||||
|
||||
while (hasNextPage) {
|
||||
const response = await admin.graphql(GET_PRODUCTS, {
|
||||
variables: { first: 250, after: cursor },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(`GraphQL error: ${data.errors[0].message}`);
|
||||
}
|
||||
|
||||
const products = data.data.products.edges.map(edge => edge.node);
|
||||
allProducts.push(...products);
|
||||
|
||||
hasNextPage = data.data.products.pageInfo.hasNextPage;
|
||||
cursor = data.data.products.pageInfo.endCursor;
|
||||
|
||||
// Rate limiting check
|
||||
const rateLimitCost = response.headers.get("X-Shopify-Shop-Api-Call-Limit");
|
||||
if (rateLimitCost) {
|
||||
const [used, total] = rateLimitCost.split("/").map(Number);
|
||||
if (used > total * 0.8) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allProducts;
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Bulk Operation Pattern
|
||||
```graphql
|
||||
mutation bulkOperationRunQuery {
|
||||
bulkOperationRunQuery(
|
||||
query: """
|
||||
{
|
||||
products {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
metafields {
|
||||
edges {
|
||||
node {
|
||||
namespace
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
) {
|
||||
bulkOperation {
|
||||
id
|
||||
status
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Check Status:**
|
||||
```graphql
|
||||
query {
|
||||
currentBulkOperation {
|
||||
id
|
||||
status
|
||||
errorCode
|
||||
createdAt
|
||||
completedAt
|
||||
objectCount
|
||||
fileSize
|
||||
url
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Download and Process Results:**
|
||||
```typescript
|
||||
async function processBulkOperationResults(url: string) {
|
||||
const response = await fetch(url);
|
||||
const jsonl = await response.text();
|
||||
|
||||
const lines = jsonl.trim().split("\n");
|
||||
const results = lines.map(line => JSON.parse(line));
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Rate Limiting Handler
|
||||
```typescript
|
||||
async function graphqlWithRetry(admin, query, variables, maxRetries = 3) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await admin.graphql(query, { variables });
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitCost = response.headers.get("X-Shopify-Shop-Api-Call-Limit");
|
||||
if (rateLimitCost) {
|
||||
const [used, total] = rateLimitCost.split("/").map(Number);
|
||||
console.log(`API calls: ${used}/${total}`);
|
||||
|
||||
if (used > total * 0.9) {
|
||||
console.warn("Approaching rate limit, slowing down...");
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(`GraphQL error: ${data.errors[0].message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.message.includes("Throttled") && i < maxRetries - 1) {
|
||||
const delay = Math.pow(2, i) * 1000; // Exponential backoff
|
||||
console.log(`Rate limited, retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Pagination** - Always use cursor-based pagination for large result sets
|
||||
2. **Field Selection** - Only request fields you need to reduce response size
|
||||
3. **Rate Limiting** - Monitor API call limits and implement backoff
|
||||
4. **Error Handling** - Check both `errors` and `userErrors` in responses
|
||||
5. **Bulk Operations** - Use for processing 1000+ products
|
||||
6. **Metafield Types** - Use appropriate types (single_line_text_field, number_integer, json, etc.)
|
||||
7. **Webhook Verification** - Always verify HMAC signatures
|
||||
8. **Caching** - Cache frequently accessed data like metafield definitions
|
||||
9. **Retry Logic** - Implement exponential backoff for transient failures
|
||||
10. **Logging** - Log API calls and errors for debugging
|
||||
|
||||
## Common Metafield Types
|
||||
|
||||
- `single_line_text_field` - Short text
|
||||
- `multi_line_text_field` - Long text
|
||||
- `number_integer` - Whole numbers
|
||||
- `number_decimal` - Decimal numbers
|
||||
- `json` - Structured data
|
||||
- `color` - Color values
|
||||
- `url` - URLs
|
||||
- `boolean` - True/false
|
||||
- `date` - Date values
|
||||
- `list.single_line_text_field` - Array of strings
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Get Product by Handle
|
||||
```graphql
|
||||
query getProductByHandle($handle: String!) {
|
||||
productByHandle(handle: $handle) {
|
||||
id
|
||||
title
|
||||
vendor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Product Variants
|
||||
```graphql
|
||||
query getProductVariants($productId: ID!) {
|
||||
product(id: $productId) {
|
||||
variants(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
price
|
||||
sku
|
||||
inventoryQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Product
|
||||
```graphql
|
||||
mutation productUpdate($input: ProductInput!) {
|
||||
productUpdate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Always check the Shopify Admin API documentation for the latest schema and deprecations.
|
||||
Reference in New Issue
Block a user