@porulle/import-shopify 0.1.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 ADDED
@@ -0,0 +1,50 @@
1
+ # @porulle/import-shopify
2
+
3
+ Import a Shopify store's catalog into Porulle. Maps Shopify's product / variant / option / image model onto `sellable_entities`, `variants`, `option_types`, `option_values`, `entity_media`.
4
+
5
+ ## Usage
6
+
7
+ Export your Shopify catalog (JSON via the Admin API or `shopify CLI products list --json`):
8
+
9
+ ```bash
10
+ shopify products list --json > shopify-catalog.json
11
+ porulle import ./shopify-catalog.json --format shopify
12
+ ```
13
+
14
+ Or programmatically:
15
+
16
+ ```ts
17
+ import { importShopifyProducts } from "@porulle/import-shopify";
18
+ import { commerce } from "./server";
19
+
20
+ const products = JSON.parse(await fs.readFile("./shopify-catalog.json", "utf-8"));
21
+ const result = await importShopifyProducts(commerce.api, products);
22
+ if (!result.ok) {
23
+ console.error("import failed:", result.error);
24
+ process.exit(1);
25
+ }
26
+ ```
27
+
28
+ ## What it maps
29
+
30
+ | Shopify | Porulle |
31
+ |---|---|
32
+ | `Product` | `sellable_entities` (type: `product`) |
33
+ | `Variant` | `variants` |
34
+ | `Option` (Size, Color, …) | `option_types` + `option_values` |
35
+ | `Image` | `media_assets` + `entity_media` (role: gallery / primary) |
36
+ | `tags` | `metadata.tags` |
37
+ | `vendor` | `metadata.vendor` (or wired to a brand if you've enabled brands) |
38
+
39
+ ## What it doesn't map
40
+
41
+ - Customers — Shopify customer export needs PII review; bring your own migration script
42
+ - Orders — Porulle doesn't import historical orders (different schema, different fulfillment state machine)
43
+ - Discounts — Shopify's discount engine is shaped differently; rebuild as Porulle promotions
44
+ - Apps / themes / scripts — out of scope
45
+
46
+ ## See also
47
+
48
+ - `@porulle/import-flat` — neutral JSON format
49
+ - `@porulle/import-woocommerce` — for WooCommerce origins
50
+ - [Shopify Admin API — Product](https://shopify.dev/docs/api/admin-rest/latest/resources/product)
@@ -0,0 +1,156 @@
1
+ import { type Result } from "@porulle/core";
2
+ export interface ShopifyImage {
3
+ id: number;
4
+ src: string;
5
+ alt?: string | null;
6
+ }
7
+ export interface ShopifyProductOption {
8
+ id?: number;
9
+ name: string;
10
+ position?: number;
11
+ values: string[];
12
+ }
13
+ export interface ShopifyVariant {
14
+ id: number;
15
+ title?: string;
16
+ sku?: string;
17
+ barcode?: string;
18
+ price?: string;
19
+ compare_at_price?: string | null;
20
+ option1?: string | null;
21
+ option2?: string | null;
22
+ option3?: string | null;
23
+ }
24
+ export interface ShopifyProduct {
25
+ id: number;
26
+ title: string;
27
+ handle?: string;
28
+ body_html?: string;
29
+ product_type?: string;
30
+ vendor?: string;
31
+ tags?: string;
32
+ options?: ShopifyProductOption[];
33
+ variants?: ShopifyVariant[];
34
+ images?: ShopifyImage[];
35
+ }
36
+ export interface ShopifyAddress {
37
+ first_name?: string;
38
+ last_name?: string;
39
+ address1?: string;
40
+ address2?: string;
41
+ city?: string;
42
+ province?: string;
43
+ zip?: string;
44
+ country_code?: string;
45
+ phone?: string;
46
+ default?: boolean;
47
+ }
48
+ export interface ShopifyCustomer {
49
+ id: number;
50
+ email?: string;
51
+ phone?: string;
52
+ first_name?: string;
53
+ last_name?: string;
54
+ addresses?: ShopifyAddress[];
55
+ }
56
+ export interface ShopifyImportTarget {
57
+ createEntity(input: {
58
+ type: string;
59
+ slug: string;
60
+ attributes: {
61
+ title: string;
62
+ description?: string;
63
+ };
64
+ metadata?: Record<string, unknown>;
65
+ }): Promise<{
66
+ id: string;
67
+ }>;
68
+ createOptionType?(input: {
69
+ entityId: string;
70
+ name: string;
71
+ displayName: string;
72
+ sortOrder?: number;
73
+ }): Promise<{
74
+ id: string;
75
+ }>;
76
+ createOptionValue?(input: {
77
+ optionTypeId: string;
78
+ value: string;
79
+ displayValue: string;
80
+ sortOrder?: number;
81
+ }): Promise<{
82
+ id: string;
83
+ }>;
84
+ createVariant?(input: {
85
+ entityId: string;
86
+ optionValueIds: string[];
87
+ sku?: string;
88
+ barcode?: string;
89
+ metadata?: Record<string, unknown>;
90
+ }): Promise<{
91
+ id: string;
92
+ }>;
93
+ uploadMedia?(input: {
94
+ filename: string;
95
+ contentType: string;
96
+ data: ArrayBuffer;
97
+ alt?: string;
98
+ metadata?: Record<string, unknown>;
99
+ }): Promise<{
100
+ id: string;
101
+ url: string;
102
+ }>;
103
+ attachMedia?(input: {
104
+ entityId: string;
105
+ mediaAssetId: string;
106
+ role: "primary" | "gallery";
107
+ }): Promise<void>;
108
+ upsertCustomer?(input: {
109
+ userId: string;
110
+ email?: string;
111
+ phone?: string;
112
+ firstName?: string;
113
+ lastName?: string;
114
+ addresses?: Array<{
115
+ type: "shipping" | "billing";
116
+ isDefault: boolean;
117
+ firstName: string;
118
+ lastName: string;
119
+ line1: string;
120
+ line2?: string;
121
+ city: string;
122
+ state?: string;
123
+ postalCode?: string;
124
+ country: string;
125
+ phone?: string;
126
+ }>;
127
+ metadata?: Record<string, unknown>;
128
+ }): Promise<void>;
129
+ }
130
+ export interface ShopifyImportOptions {
131
+ target: ShopifyImportTarget;
132
+ apiKey?: string;
133
+ storeUrl?: string;
134
+ apiVersion?: string;
135
+ products?: ShopifyProduct[];
136
+ customers?: ShopifyCustomer[];
137
+ fetchImpl?: typeof fetch;
138
+ mediaFetcher?: (url: string) => Promise<{
139
+ data: ArrayBuffer;
140
+ contentType: string;
141
+ filename?: string;
142
+ }>;
143
+ entityType?: string;
144
+ }
145
+ export interface ShopifyImportSummary {
146
+ entitiesImported: number;
147
+ variantsImported: number;
148
+ mediaImported: number;
149
+ customersImported: number;
150
+ errors: Array<{
151
+ scope: "entity" | "variant" | "media" | "customer";
152
+ message: string;
153
+ }>;
154
+ }
155
+ export declare function importShopifyCatalog(options: ShopifyImportOptions): Promise<Result<ShopifyImportSummary>>;
156
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,273 @@
1
+ import { Err, Ok } from "@porulle/core";
2
+ function slugify(value) {
3
+ return value
4
+ .toLowerCase()
5
+ .trim()
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "") || `product-${crypto.randomUUID().slice(0, 8)}`;
8
+ }
9
+ function parseMoney(value) {
10
+ if (!value)
11
+ return 0;
12
+ const parsed = Number.parseFloat(value);
13
+ if (!Number.isFinite(parsed))
14
+ return 0;
15
+ return Math.round(parsed * 100);
16
+ }
17
+ async function fetchJson(fetchImpl, url, apiKey) {
18
+ try {
19
+ const response = await fetchImpl(url, {
20
+ headers: {
21
+ "x-shopify-access-token": apiKey,
22
+ accept: "application/json",
23
+ },
24
+ });
25
+ if (!response.ok) {
26
+ return Err({
27
+ code: "SHOPIFY_API_FAILED",
28
+ message: `Shopify API request failed (${response.status}) for ${url}.`,
29
+ });
30
+ }
31
+ return Ok((await response.json()));
32
+ }
33
+ catch (error) {
34
+ return Err({
35
+ code: "SHOPIFY_API_FAILED",
36
+ message: error instanceof Error ? error.message : "Shopify API request failed.",
37
+ });
38
+ }
39
+ }
40
+ function filenameFromUrl(url) {
41
+ try {
42
+ const parsed = new URL(url);
43
+ const last = parsed.pathname.split("/").filter(Boolean).pop();
44
+ return last && last.length > 0 ? last : `media-${crypto.randomUUID().slice(0, 8)}.bin`;
45
+ }
46
+ catch {
47
+ return `media-${crypto.randomUUID().slice(0, 8)}.bin`;
48
+ }
49
+ }
50
+ async function defaultMediaFetcher(fetchImpl, url) {
51
+ const response = await fetchImpl(url);
52
+ if (!response.ok) {
53
+ throw new Error(`Failed to download media (${response.status}) from ${url}`);
54
+ }
55
+ return {
56
+ data: await response.arrayBuffer(),
57
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
58
+ filename: filenameFromUrl(url),
59
+ };
60
+ }
61
+ async function loadProducts(options) {
62
+ if (options.products)
63
+ return Ok(options.products);
64
+ if (!options.storeUrl || !options.apiKey) {
65
+ return Err({
66
+ code: "SHOPIFY_INPUT_REQUIRED",
67
+ message: "Provide products or both storeUrl and apiKey for Shopify import.",
68
+ });
69
+ }
70
+ const base = options.storeUrl.replace(/\/$/, "");
71
+ const version = options.apiVersion ?? "2024-10";
72
+ const fetchImpl = options.fetchImpl ?? fetch;
73
+ const response = await fetchJson(fetchImpl, `${base}/admin/api/${version}/products.json?limit=250`, options.apiKey);
74
+ if (!response.ok)
75
+ return response;
76
+ return Ok(response.value.products ?? []);
77
+ }
78
+ async function loadCustomers(options) {
79
+ if (options.customers)
80
+ return Ok(options.customers);
81
+ if (!options.storeUrl || !options.apiKey) {
82
+ return Ok([]);
83
+ }
84
+ const base = options.storeUrl.replace(/\/$/, "");
85
+ const version = options.apiVersion ?? "2024-10";
86
+ const fetchImpl = options.fetchImpl ?? fetch;
87
+ const response = await fetchJson(fetchImpl, `${base}/admin/api/${version}/customers.json?limit=250`, options.apiKey);
88
+ if (!response.ok)
89
+ return response;
90
+ return Ok(response.value.customers ?? []);
91
+ }
92
+ function toAddress(address) {
93
+ return {
94
+ type: "shipping",
95
+ isDefault: address.default ?? false,
96
+ firstName: address.first_name ?? "",
97
+ lastName: address.last_name ?? "",
98
+ line1: address.address1 ?? "",
99
+ ...(address.address2 ? { line2: address.address2 } : {}),
100
+ city: address.city ?? "",
101
+ ...(address.province ? { state: address.province } : {}),
102
+ ...(address.zip ? { postalCode: address.zip } : {}),
103
+ country: address.country_code ?? "US",
104
+ ...(address.phone ? { phone: address.phone } : {}),
105
+ };
106
+ }
107
+ export async function importShopifyCatalog(options) {
108
+ const productsResult = await loadProducts(options);
109
+ if (!productsResult.ok)
110
+ return productsResult;
111
+ const customersResult = await loadCustomers(options);
112
+ if (!customersResult.ok)
113
+ return customersResult;
114
+ const summary = {
115
+ entitiesImported: 0,
116
+ variantsImported: 0,
117
+ mediaImported: 0,
118
+ customersImported: 0,
119
+ errors: [],
120
+ };
121
+ const mediaFetcher = options.mediaFetcher
122
+ ? options.mediaFetcher
123
+ : async (url) => defaultMediaFetcher(options.fetchImpl ?? fetch, url);
124
+ for (const product of productsResult.value) {
125
+ try {
126
+ const createdEntity = await options.target.createEntity({
127
+ type: options.entityType ?? "product",
128
+ slug: product.handle ? slugify(product.handle) : slugify(product.title),
129
+ attributes: {
130
+ title: product.title,
131
+ ...(product.body_html ? { description: product.body_html } : {}),
132
+ },
133
+ metadata: {
134
+ source: "shopify",
135
+ shopifyProductId: product.id,
136
+ productType: product.product_type,
137
+ vendor: product.vendor,
138
+ tags: product.tags,
139
+ },
140
+ });
141
+ summary.entitiesImported += 1;
142
+ const optionTypeIdByName = new Map();
143
+ const optionValueIdByKey = new Map();
144
+ if (options.target.createOptionType && options.target.createOptionValue) {
145
+ const optionList = product.options ?? [];
146
+ for (let optionIndex = 0; optionIndex < optionList.length; optionIndex += 1) {
147
+ const option = optionList[optionIndex];
148
+ const createdType = await options.target.createOptionType({
149
+ entityId: createdEntity.id,
150
+ name: option.name,
151
+ displayName: option.name,
152
+ sortOrder: option.position ?? optionIndex,
153
+ });
154
+ optionTypeIdByName.set(option.name, createdType.id);
155
+ for (let valueIndex = 0; valueIndex < option.values.length; valueIndex += 1) {
156
+ const value = option.values[valueIndex];
157
+ const createdValue = await options.target.createOptionValue({
158
+ optionTypeId: createdType.id,
159
+ value,
160
+ displayValue: value,
161
+ sortOrder: valueIndex,
162
+ });
163
+ optionValueIdByKey.set(`${option.name}::${value}`, createdValue.id);
164
+ }
165
+ }
166
+ }
167
+ if (options.target.createVariant && (product.variants?.length ?? 0) > 0) {
168
+ for (const variant of product.variants ?? []) {
169
+ try {
170
+ const optionValues = [];
171
+ const selectors = [variant.option1, variant.option2, variant.option3];
172
+ const productOptions = product.options ?? [];
173
+ for (let idx = 0; idx < selectors.length; idx += 1) {
174
+ const selected = selectors[idx];
175
+ if (!selected)
176
+ continue;
177
+ const optionName = productOptions[idx]?.name;
178
+ if (!optionName)
179
+ continue;
180
+ const optionValueId = optionValueIdByKey.get(`${optionName}::${selected}`);
181
+ if (optionValueId) {
182
+ optionValues.push(optionValueId);
183
+ }
184
+ }
185
+ await options.target.createVariant({
186
+ entityId: createdEntity.id,
187
+ optionValueIds: optionValues,
188
+ ...(variant.sku ? { sku: variant.sku } : {}),
189
+ ...(variant.barcode ? { barcode: variant.barcode } : {}),
190
+ metadata: {
191
+ source: "shopify",
192
+ shopifyVariantId: variant.id,
193
+ title: variant.title,
194
+ price: parseMoney(variant.price),
195
+ compareAtPrice: parseMoney(variant.compare_at_price),
196
+ },
197
+ });
198
+ summary.variantsImported += 1;
199
+ }
200
+ catch (error) {
201
+ summary.errors.push({
202
+ scope: "variant",
203
+ message: error instanceof Error ? error.message : "Variant import failed.",
204
+ });
205
+ }
206
+ }
207
+ }
208
+ if (options.target.uploadMedia && options.target.attachMedia) {
209
+ const images = product.images ?? [];
210
+ for (let imageIndex = 0; imageIndex < images.length; imageIndex += 1) {
211
+ const image = images[imageIndex];
212
+ try {
213
+ const downloaded = await mediaFetcher(image.src);
214
+ const uploaded = await options.target.uploadMedia({
215
+ filename: downloaded.filename ?? filenameFromUrl(image.src),
216
+ contentType: downloaded.contentType,
217
+ data: downloaded.data,
218
+ ...(image.alt ? { alt: image.alt } : {}),
219
+ metadata: {
220
+ source: "shopify",
221
+ shopifyImageId: image.id,
222
+ },
223
+ });
224
+ await options.target.attachMedia({
225
+ entityId: createdEntity.id,
226
+ mediaAssetId: uploaded.id,
227
+ role: imageIndex === 0 ? "primary" : "gallery",
228
+ });
229
+ summary.mediaImported += 1;
230
+ }
231
+ catch (error) {
232
+ summary.errors.push({
233
+ scope: "media",
234
+ message: error instanceof Error ? error.message : "Media import failed.",
235
+ });
236
+ }
237
+ }
238
+ }
239
+ }
240
+ catch (error) {
241
+ summary.errors.push({
242
+ scope: "entity",
243
+ message: error instanceof Error ? error.message : "Entity import failed.",
244
+ });
245
+ }
246
+ }
247
+ if (options.target.upsertCustomer) {
248
+ for (const customer of customersResult.value) {
249
+ try {
250
+ await options.target.upsertCustomer({
251
+ userId: `shopify:${customer.id}`,
252
+ ...(customer.email ? { email: customer.email } : {}),
253
+ ...(customer.phone ? { phone: customer.phone } : {}),
254
+ ...(customer.first_name ? { firstName: customer.first_name } : {}),
255
+ ...(customer.last_name ? { lastName: customer.last_name } : {}),
256
+ addresses: (customer.addresses ?? []).map((address) => toAddress(address)),
257
+ metadata: {
258
+ source: "shopify",
259
+ shopifyCustomerId: customer.id,
260
+ },
261
+ });
262
+ summary.customersImported += 1;
263
+ }
264
+ catch (error) {
265
+ summary.errors.push({
266
+ scope: "customer",
267
+ message: error instanceof Error ? error.message : "Customer import failed.",
268
+ });
269
+ }
270
+ }
271
+ }
272
+ return Ok(summary);
273
+ }