Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:54:00 +08:00
commit e91028b77f
12 changed files with 3393 additions and 0 deletions

View 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.

View 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.

View 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.