@tallyui/connector-vendure 1.0.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/dist/index.d.ts +69 -0
- package/dist/index.js +423 -0
- package/package.json +32 -0
- package/src/__tests__/product-traits.test.ts +248 -0
- package/src/index.ts +73 -0
- package/src/replication/products.test.ts +204 -0
- package/src/replication/products.ts +148 -0
- package/src/schemas/products.ts +110 -0
- package/src/sync/products.ts +154 -0
- package/src/traits/product.ts +91 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { RxJsonSchema } from 'rxdb';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RxDB schema for Vendure products.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the Vendure GraphQL Admin API product shape. Key differences:
|
|
7
|
+
* - Uses `name` (like WooCommerce, unlike Medusa's `title`)
|
|
8
|
+
* - Prices are integers in cents on `variants[].priceWithTax`
|
|
9
|
+
* - Images use `assets[].preview` and `featuredAsset.preview`
|
|
10
|
+
* - Categories are `collections[].name`
|
|
11
|
+
* - Stock level is a string (`IN_STOCK`, `OUT_OF_STOCK`, `LOW_STOCK`)
|
|
12
|
+
* - No native barcode field (custom fields only)
|
|
13
|
+
*/
|
|
14
|
+
export const vendureProductSchema: RxJsonSchema<any> = {
|
|
15
|
+
version: 0,
|
|
16
|
+
primaryKey: 'id',
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
id: { type: 'string', maxLength: 100 },
|
|
20
|
+
name: { type: 'string' },
|
|
21
|
+
slug: { type: 'string', maxLength: 255 },
|
|
22
|
+
description: { type: 'string' },
|
|
23
|
+
enabled: { type: 'boolean' },
|
|
24
|
+
featuredAsset: {
|
|
25
|
+
type: ['object', 'null'],
|
|
26
|
+
properties: {
|
|
27
|
+
id: { type: 'string' },
|
|
28
|
+
preview: { type: 'string' },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
assets: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
id: { type: 'string' },
|
|
37
|
+
preview: { type: 'string' },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
collections: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
id: { type: 'string' },
|
|
47
|
+
name: { type: 'string' },
|
|
48
|
+
slug: { type: 'string' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
facetValues: {
|
|
53
|
+
type: 'array',
|
|
54
|
+
items: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
id: { type: 'string' },
|
|
58
|
+
name: { type: 'string' },
|
|
59
|
+
code: { type: 'string' },
|
|
60
|
+
facet: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
id: { type: 'string' },
|
|
64
|
+
name: { type: 'string' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
variants: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
items: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
id: { type: 'string' },
|
|
76
|
+
name: { type: 'string' },
|
|
77
|
+
sku: { type: 'string' },
|
|
78
|
+
price: { type: 'number' },
|
|
79
|
+
priceWithTax: { type: 'number' },
|
|
80
|
+
currencyCode: { type: 'string' },
|
|
81
|
+
stockLevel: { type: 'string' },
|
|
82
|
+
stockOnHand: { type: 'number' },
|
|
83
|
+
trackInventory: { type: 'string' },
|
|
84
|
+
featuredAsset: {
|
|
85
|
+
type: ['object', 'null'],
|
|
86
|
+
properties: {
|
|
87
|
+
id: { type: 'string' },
|
|
88
|
+
preview: { type: 'string' },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
options: {
|
|
92
|
+
type: 'array',
|
|
93
|
+
items: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
id: { type: 'string' },
|
|
97
|
+
name: { type: 'string' },
|
|
98
|
+
code: { type: 'string' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
customFields: { type: 'object' },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
updatedAt: { type: 'string', maxLength: 50 },
|
|
107
|
+
},
|
|
108
|
+
required: ['id'],
|
|
109
|
+
indexes: ['slug', 'updatedAt'],
|
|
110
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { CollectionSync, SyncContext } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GraphQL query fragments used by the Vendure sync.
|
|
5
|
+
*/
|
|
6
|
+
const PRODUCT_LIST_QUERY = `
|
|
7
|
+
query GetProducts($options: ProductListOptions) {
|
|
8
|
+
products(options: $options) {
|
|
9
|
+
items {
|
|
10
|
+
id
|
|
11
|
+
updatedAt
|
|
12
|
+
}
|
|
13
|
+
totalItems
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const PRODUCT_DETAIL_QUERY = `
|
|
19
|
+
query GetProduct($id: ID!) {
|
|
20
|
+
product(id: $id) {
|
|
21
|
+
id
|
|
22
|
+
createdAt
|
|
23
|
+
updatedAt
|
|
24
|
+
name
|
|
25
|
+
slug
|
|
26
|
+
description
|
|
27
|
+
enabled
|
|
28
|
+
featuredAsset { id preview }
|
|
29
|
+
assets { id preview }
|
|
30
|
+
collections { id name slug }
|
|
31
|
+
facetValues { id name code facet { id name } }
|
|
32
|
+
variants {
|
|
33
|
+
id
|
|
34
|
+
name
|
|
35
|
+
sku
|
|
36
|
+
price
|
|
37
|
+
priceWithTax
|
|
38
|
+
currencyCode
|
|
39
|
+
stockLevel
|
|
40
|
+
stockOnHand
|
|
41
|
+
trackInventory
|
|
42
|
+
featuredAsset { id preview }
|
|
43
|
+
options { id name code }
|
|
44
|
+
customFields
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Vendure product sync implementation.
|
|
52
|
+
*
|
|
53
|
+
* Uses the Admin GraphQL API. Vendure uses offset-based pagination
|
|
54
|
+
* with `take` and `skip` options.
|
|
55
|
+
*/
|
|
56
|
+
export const vendureProductSync: CollectionSync = {
|
|
57
|
+
fetchAllIds: async (context: SyncContext) => {
|
|
58
|
+
const entries: { id: string; dateModified?: string }[] = [];
|
|
59
|
+
const take = 100;
|
|
60
|
+
let skip = 0;
|
|
61
|
+
let totalItems = Infinity;
|
|
62
|
+
|
|
63
|
+
while (skip < totalItems) {
|
|
64
|
+
const res = await gql(context, PRODUCT_LIST_QUERY, {
|
|
65
|
+
options: { take, skip },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const data = res.data?.products;
|
|
69
|
+
if (!data) break;
|
|
70
|
+
|
|
71
|
+
totalItems = data.totalItems;
|
|
72
|
+
for (const item of data.items ?? []) {
|
|
73
|
+
entries.push({
|
|
74
|
+
id: String(item.id),
|
|
75
|
+
dateModified: item.updatedAt,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
skip += take;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return entries;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
fetchByIds: async (ids: string[], context: SyncContext) => {
|
|
86
|
+
const products: any[] = [];
|
|
87
|
+
// Vendure doesn't support batch-by-IDs natively, so fetch individually
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const res = await gql(context, PRODUCT_DETAIL_QUERY, { id });
|
|
90
|
+
if (res.data?.product) {
|
|
91
|
+
products.push(res.data.product);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return products;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
fetchModifiedAfter: async (date: string, context: SyncContext) => {
|
|
98
|
+
const products: any[] = [];
|
|
99
|
+
const take = 100;
|
|
100
|
+
let skip = 0;
|
|
101
|
+
let totalItems = Infinity;
|
|
102
|
+
|
|
103
|
+
while (skip < totalItems) {
|
|
104
|
+
const res = await gql(context, PRODUCT_LIST_QUERY, {
|
|
105
|
+
options: {
|
|
106
|
+
take,
|
|
107
|
+
skip,
|
|
108
|
+
filter: {
|
|
109
|
+
updatedAt: { after: date },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const data = res.data?.products;
|
|
115
|
+
if (!data) break;
|
|
116
|
+
|
|
117
|
+
totalItems = data.totalItems;
|
|
118
|
+
|
|
119
|
+
// Fetch full details for each modified product
|
|
120
|
+
for (const item of data.items ?? []) {
|
|
121
|
+
const detail = await gql(context, PRODUCT_DETAIL_QUERY, { id: item.id });
|
|
122
|
+
if (detail.data?.product) {
|
|
123
|
+
products.push(detail.data.product);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
skip += take;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return products;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Helper to execute a GraphQL query against the Vendure Admin API.
|
|
136
|
+
*/
|
|
137
|
+
async function gql(
|
|
138
|
+
context: SyncContext,
|
|
139
|
+
query: string,
|
|
140
|
+
variables?: Record<string, any>,
|
|
141
|
+
): Promise<any> {
|
|
142
|
+
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
...context.headers,
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ query, variables }),
|
|
149
|
+
signal: context.signal,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
|
|
153
|
+
return res.json();
|
|
154
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ProductTraits } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vendure product trait implementations.
|
|
5
|
+
*
|
|
6
|
+
* Key differences from other connectors:
|
|
7
|
+
* - Product name is `name` (same as WooCommerce, unlike Medusa's `title`)
|
|
8
|
+
* - Price is on `variants[].priceWithTax` (integer cents, like Medusa)
|
|
9
|
+
* - Images use `featuredAsset.preview` and `assets[].preview`
|
|
10
|
+
* - Stock status is a string from the Shop API: 'IN_STOCK', 'OUT_OF_STOCK', 'LOW_STOCK'
|
|
11
|
+
* - Categories are `collections[].name` (Vendure's equivalent of categories)
|
|
12
|
+
* - No native sale price — Vendure handles sales via promotions at checkout
|
|
13
|
+
* - No native barcode field — uses custom fields if configured
|
|
14
|
+
*/
|
|
15
|
+
export const vendureProductTraits: ProductTraits = {
|
|
16
|
+
getId: (doc) => String(doc.id),
|
|
17
|
+
|
|
18
|
+
getName: (doc) => doc.name ?? '',
|
|
19
|
+
|
|
20
|
+
getSku: (doc) => doc.variants?.[0]?.sku || undefined,
|
|
21
|
+
|
|
22
|
+
getPrice: (doc) => {
|
|
23
|
+
// Vendure stores prices as integers in smallest currency unit (cents)
|
|
24
|
+
const amount = doc.variants?.[0]?.priceWithTax;
|
|
25
|
+
if (amount == null) return undefined;
|
|
26
|
+
return (amount / 100).toFixed(2);
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
getRegularPrice: (doc) => {
|
|
30
|
+
// Vendure has no separate regular/sale price — promotions happen at checkout
|
|
31
|
+
const amount = doc.variants?.[0]?.priceWithTax;
|
|
32
|
+
if (amount == null) return undefined;
|
|
33
|
+
return (amount / 100).toFixed(2);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
getSalePrice: () => {
|
|
37
|
+
// Vendure handles sales via promotions at the order level
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
isOnSale: () => {
|
|
42
|
+
// No product-level sale flag in Vendure
|
|
43
|
+
return false;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
getImageUrl: (doc) =>
|
|
47
|
+
doc.featuredAsset?.preview || doc.assets?.[0]?.preview || undefined,
|
|
48
|
+
|
|
49
|
+
getImageUrls: (doc) =>
|
|
50
|
+
(doc.assets ?? []).map((a: any) => a.preview).filter(Boolean),
|
|
51
|
+
|
|
52
|
+
getDescription: (doc) => doc.description || undefined,
|
|
53
|
+
|
|
54
|
+
getStockStatus: (doc) => {
|
|
55
|
+
const variant = doc.variants?.[0];
|
|
56
|
+
if (!variant) return 'unknown';
|
|
57
|
+
|
|
58
|
+
// Vendure Shop API returns stockLevel as a string
|
|
59
|
+
const level = variant.stockLevel;
|
|
60
|
+
if (level === 'IN_STOCK' || level === 'LOW_STOCK') return 'instock';
|
|
61
|
+
if (level === 'OUT_OF_STOCK') return 'outofstock';
|
|
62
|
+
|
|
63
|
+
// Fall back to Admin API stockOnHand if available
|
|
64
|
+
if (variant.stockOnHand != null) {
|
|
65
|
+
return variant.stockOnHand > 0 ? 'instock' : 'outofstock';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return 'unknown';
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
getStockQuantity: (doc) => {
|
|
72
|
+
// stockOnHand is available from Admin API; Shop API only has the string stockLevel
|
|
73
|
+
return doc.variants?.[0]?.stockOnHand ?? null;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
hasVariants: (doc) => (doc.variants?.length ?? 0) > 1,
|
|
77
|
+
|
|
78
|
+
getType: (doc) => {
|
|
79
|
+
// Vendure has no native product type field
|
|
80
|
+
if ((doc.variants?.length ?? 0) > 1) return 'variable';
|
|
81
|
+
return 'simple';
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
getBarcode: (doc) => {
|
|
85
|
+
// Vendure has no native barcode — check custom fields
|
|
86
|
+
return doc.variants?.[0]?.customFields?.barcode || undefined;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
getCategoryNames: (doc) =>
|
|
90
|
+
(doc.collections ?? []).map((c: any) => c.name).filter(Boolean),
|
|
91
|
+
};
|