@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.
@@ -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
+ };