Synchroniser les données
Une intégration pousse ses données (clients, commandes, produits…) vers ShopiMind. Le kit ré-exporte le SDK (@shopimind/sdk-js), donc vous importez ses ressources et ses types depuis le kit et vous appelez ses méthodes statiques sur un client authentifié que le kit vous fournit sous ctx.spm. Vous décrivez quoi synchroniser via des étapes de synchronisation (SyncStep), et le kit s'occupe du quand (synchro initiale + incrémentale) et de la sûreté (curseurs, pagination, concurrence).
ctx.spm — le client SDK brut
ctx.spm est un SpmHttpClient brut, déjà lié à la clé API de l'installation. Vous n'appelez pas de méthodes sur lui ; vous le passez en premier argument aux méthodes statiques du SDK :
import { SpmCustomers, SpmOrders, SpmEvents } from '@shopimind/integration-kit-js';
// pousser des entités (création/mise à jour en lot) — passer ctx.spm + les éléments
await SpmCustomers.bulkSave(ctx.spm, items, { chunk: true });
await SpmOrders.bulkSave(ctx.spm, orders, { chunk: true });
// déclencher un événement
await SpmEvents.trigger(ctx.spm, 'cart_abandoned', payload);| Ressource | Exemples |
|---|---|
SpmCustomers / SpmOrders / SpmProducts / SpmOrderStatuses | Entités e-commerce / catalogue (création/mise à jour en lot). |
SpmProductsVariations / SpmCategories / SpmManufacturers / SpmCarriers / SpmVouchers | Entités e-commerce supplémentaires (certaines prennent un paramètre de chemin, ex. l'id du produit pour les variations). |
SpmContacts / SpmLists | Lire des contacts, les listes d'un contact, les listes/segments (enrichissement, dédoublonnage). |
SpmEvents | Événements — trigger, déclaration. |
SpmDataSources | Sources de données (voir Provisioning). |
SpmCustomDataDefinitions / SpmCustomDataRecords | Définitions et enregistrements de données personnalisées. |
Le client est générique — pas seulement POS/e-commerce
Au-delà des écritures catalogue, le SDK expose des chemins de lecture (contacts, listes, enregistrements de données personnalisées) et le CRUD complet des enregistrements : les intégrations fidélité, avis et CRM sont donc de premier plan. Un contact ne se crée/modifie pas directement (pas d'API d'écriture de contact) : un contact se matérialise par la synchro clients (SpmCustomers.bulkSave, pour les intégrations autorisées à synchroniser les clients), et on l'enrichit via des enregistrements de données personnalisées reliés au contact (record.relation = { by: 'email' | 'id_contact', value }). Un profil fidélité/CRM est donc un enregistrement de données personnalisées sur le contact, pas un faux client.
L'enveloppe de réponse — { ok, statusCode, data, error }
Le SDK ne lève jamais d'exception sur une erreur HTTP. Chaque appel se résout en une enveloppe :
const env = await SpmCustomers.bulkSave(ctx.spm, items, { chunk: true });
// env: { ok: boolean, statusCode: number, data?: ..., error?: ... }
if (!env.ok) {
// env.statusCode, env.error décrivent l'échec — aucune exception n'a été levée
}Le kit ré-exporte deux helpers pour que vous touchiez rarement à l'enveloppe brute :
| Helper | Rôle |
|---|---|
SpmHelpers.unwrapOrThrow(env) | Renvoie env.data quand ok, sinon lève une erreur construite à partir de statusCode/error. À utiliser quand vous voulez qu'un échec remonte. |
SpmHelpers.extractCounts(env) | Extrait les décomptes { sent, rejected, failed } d'une réponse en lot. |
import { SpmCustomers, SpmHelpers } from '@shopimind/integration-kit-js';
const env = await SpmCustomers.bulkSave(ctx.spm, items, { chunk: true });
const counts = SpmHelpers.extractCounts(env); // { sent, rejected, failed }Pour pousser à l'intérieur d'une étape de synchronisation, vous n'appelez presque jamais bulkSave directement — utilisez ctx.sendBulk (ci-dessous), qui encapsule pour vous l'enveloppe, les décomptes et la gestion des échecs.
Push sûr — ctx.sendBulk
ctx.sendBulk(fn, items) est la manière recommandée de pousser dans une étape de synchronisation ou un handler entrant. Elle :
- envoie les éléments par lots ;
- lève une exception sur un échec de transport — un
!okglobal ou un lot rapportantfailed_count > 0; - fait remonter les rejets par élément (rejets de validation) sans lever ;
- renvoie
{ sent, rejected, rejected_items }.
run: async (ctx) => {
let items = 0;
for await (const page of ctx.paginate((p) => fetchCustomers(p, ctx.window))) {
const { sent, rejected, rejected_items } = await ctx.sendBulk(
SpmCustomers.bulkSave,
page.map(toSpmCustomer),
);
items += sent;
if (rejected) ctx.logger.warn(`${rejected} customers rejected`, { rejected_items });
}
return { items, errors: [], advanceCursorTo: ctx.window.until };
};Le moteur retient le curseur sur les rejets — pas de perte de données silencieuse
Quand sendBulk rapporte des rejets par élément dans une étape de synchronisation, le moteur retient le curseur : la fenêtre est rejouée à la prochaine synchro, donc les éléments rejetés ne sont pas silencieusement abandonnés. Un échec de transport lève une exception et est lui aussi toujours rejoué. (Pour laisser le curseur avancer malgré des rejets de validation, voir tolerateRejects.)
Forme thunk pour les bulks à paramètre de chemin
Certaines méthodes en lot prennent un paramètre de chemin avant les éléments (ex. l'id du produit pour les variations). Passez un thunk au lieu de la référence de fonction :
await ctx.sendBulk(
() => SpmProductsVariations.bulkSave(ctx.spm, productId, items, { chunk: true }),
);tolerateRejects — l'échappatoire poison-pill
Un SyncStep peut définir tolerateRejects: true. Avec cette option, le curseur est autorisé à avancer même quand des rejets de validation par élément surviennent — une échappatoire poison-pill pour qu'un enregistrement malformé ne puisse pas bloquer une fenêtre indéfiniment. Les échecs de transport sont toujours rejoués ; seuls les rejets de validation sont tolérés.
Vous pouvez aussi passer un ratio plutôt qu'un simple booléen : tolerateRejects: { maxRatio }. Le curseur n'est alors autorisé à avancer que tant que la part d'éléments rejetés sur la fenêtre (rejetés ÷ tentés) reste inférieure ou égale à maxRatio (une valeur comprise entre 0 et 1). Dès que trop d'éléments sont rejetés et que le ratio est dépassé, l'étape revient au comportement strict et le curseur est figé pour que la fenêtre soit rejouée. true équivaut à { maxRatio: 1 } (toujours tolérer) ; false ou l'omission du champ conserve le comportement strict, sans perte, par défaut.
export const reviewsStep: SyncStep<MySettings> = {
entity: 'reviews',
tolerateRejects: true, // a single bad review won't freeze the window
run: async (ctx) => { /* ... */ },
};Une étape de synchronisation — SyncStep
Chaque entité à synchroniser est un SyncStep. Le kit l'exécute à la synchro initiale puis, périodiquement, en incrémental.
import type { SyncStep } from '@shopimind/integration-kit-js';
import { SpmCustomers } from '@shopimind/integration-kit-js';
import type { MySettings } from './settings.js';
export const customersStep: SyncStep<MySettings> = {
entity: 'customers',
cursorScope: 'global', // 'global' | 'per-source'
enabled: (settings) => settings.syncCustomers,
// sources: required only if cursorScope === 'per-source'
run: async (ctx) => {
let items = 0;
for await (const page of ctx.paginate((p) => fetchCustomers(p, ctx.window))) {
const { sent } = await ctx.sendBulk(SpmCustomers.bulkSave, page.map(toSpmCustomer));
items += sent;
}
// the cursor advances ONLY if errors=[] AND advanceCursorTo is set
return { items, errors: [], advanceCursorTo: ctx.window.until };
},
};Le contrat :
| Champ | Type | Rôle |
|---|---|---|
entity | string | Nom unique de l'entité (clé du curseur). |
cursorScope | 'global' | 'per-source' | Un seul curseur, ou un curseur par source (voir Multi-source). |
enabled | (settings) => boolean | L'étape tourne-t-elle (selon la config du marchand) ? |
sources? | (ctx) => string[] | Requis si per-source : la liste des sources à parcourir. |
tolerateRejects? | boolean | { maxRatio: number } | Laisse le curseur avancer malgré des rejets de validation par élément (voir ci-dessus). Les échecs de transport sont quand même rejoués. |
run | (ctx) => Promise<SyncStepResult> | Le travail. Renvoie { items, errors, advanceCursorTo? }. |
Le curseur sûr (anti perte de données)
Le kit n'avance le curseur que si errors est vide et advanceCursorTo est défini. En cas d'erreur partielle, le curseur reste figé : la prochaine synchro rejoue la fenêtre, rien n'est perdu.
// ✅ clean run → the cursor advances
return { items, errors: [], advanceCursorTo: ctx.window.until };
// ⚠️ error on a page → the cursor DOES NOT MOVE (we'll replay)
return { items, errors: ['page 3: 502 Bad Gateway'] };ctx.sendBulk coopère avec ce mécanisme : un échec de transport lève (la fenêtre est rejouée), et les rejets par élément retiennent le curseur sauf si tolerateRejects est défini.
Le contexte d'étape — SyncStepContext
ctx (dans run) étend le IntegrationContext avec :
| Champ | Rôle |
|---|---|
window | { since, until } — la fenêtre temporelle à synchroniser (backfill au 1ᵉʳ run, incrémentale ensuite). |
sourceKey | La source courante ('' en scope global, l'id de la source en per-source). |
cursor | Le curseur courant (ou null). |
paginate(fetchPage, opts?) | Générateur asynchrone de pages — pagination en flux (anti-OOM). |
mapConcurrent(items, limit, fn) | Traite des éléments avec une concurrence bornée (anti-429). |
Multi-source
Une intégration peut alimenter plusieurs sources de données sous un même compte ShopiMind (par exemple un magasin physique par point de vente, à côté de la source e-commerce). Une étape per-source :
import { SpmOrders } from '@shopimind/integration-kit-js';
export const ordersStep: SyncStep<MySettings> = {
entity: 'orders',
cursorScope: 'per-source',
enabled: (s) => s.syncOrders,
sources: (ctx) => ctx.settings.storeIds, // one source per store
run: async (ctx) => {
// ctx.sourceKey = the current store's id ; each source has its own cursor
const orders = await fetchStoreOrders(ctx.sourceKey, ctx.window);
const src = ctx.withSource(ctx.sourceKey);
const { sent } = await src.send(SpmOrders.bulkSave, orders.map(toSpmOrder));
return { items: sent, errors: [], advanceCursorTo: ctx.window.until };
},
};Un échec sur une source n'empêche pas les autres d'avancer : les curseurs sont isolés par source.
Sources dédiées — ctx.withSource
Quand vous poussez du catalogue (clients, produits, commandes…) dans une source qui vous est propre — pour ne jamais écraser le catalogue natif du marchand — taguez chaque entité avec son id_data_source. ctx.withSource(sourceKey) renvoie un SourceHandle :
| Membre | Rôle |
|---|---|
id | L'id_data_source résolu de la source provisionnée. |
tag(items) | Ajoute id_data_source à chaque élément (quand vous n'avez besoin que du tag). |
send(fn, items) | tag(items) + push sûr (ctx.sendBulk). Le one-liner que vous utilisez normalement. |
import { SpmProducts } from '@shopimind/integration-kit-js';
run: async (ctx) => {
const src = ctx.withSource(ctx.sourceKey); // the provisioned source for this step
const { sent } = await src.send(SpmProducts.bulkSave, products.map(toSpmProduct));
return { items: sent, errors: [], advanceCursorTo: ctx.window.until };
};src.send(SpmProducts.bulkSave, items) tague chaque élément avec id_data_source et pousse de manière sûre (par lots, levant sur échec de transport, faisant remonter les rejets) en un seul appel.
sourceKeydoit correspondre à une source déclarée dansprovisioning.dataSources(sinonwithSourcelève).ctx.withSourceest disponible partout où vous avez unctx(hooks,run, etc.).
Taguer la source ne suffit pas : namespacez aussi vos identifiants
Côté ShopiMind, id_data_source ne fait pas partie de la clé d'unicité d'une entité. Si vous poussez un produit dont l'identifiant natif existe déjà chez le marchand, vous écrasez sa ligne — même avec une source différente. Pour une isolation réelle, namespacez vos identifiants dans vos mappers : préfixe pour les ids texte (hib_<id>), décalage numérique pour les ids entiers (id + 1_000_000_000). withSource garantit le tag de source ; le namespacing reste votre responsabilité.
Unification des produits entre sources
Quand vous poussez des produits depuis votre propre source (ex. un point de vente), ShopiMind les unifie automatiquement avec le catalogue web du marchand pour que le même article physique soit un seul produit, pas un doublon :
- Il apparie votre produit au catalogue web via une colonne de matching —
referencepar défaut (d'autres colonnes commeean13pourront être proposées plus tard). Vous fixez la valeur par défaut de votre intégration ; le marchand peut la surcharger à l'installation, et elle est verrouillée dès que l'intégration est activée. - Match trouvé → votre produit se rattache au produit existant en overlay par source : votre prix et votre stock sont conservés par source, le produit partagé reste la fiche canonique du marchand. Reco, best-sellers et segmentation comptent alors un seul produit unifié.
- Pas de match → un produit autonome est créé pour votre source (un article réellement propre au magasin).
Namespacez toujours vos ids (ci-dessus) pour qu'un produit sans match n'entre jamais en collision avec le catalogue web. Les contacts sont unifiés au niveau e-mail/téléphone ; les produits à la colonne de matching. Cette unification est transparente pour un marchand mono-source (web) — rien ne change pour ses produits.
Données personnalisées — ctx.customData
Pour pousser des enregistrements de données personnalisées (soldes de fidélité, avis, attributs propres au magasin…), utilisez ctx.customData(name). C'est l'équivalent « données personnalisées » de withSource : name correspond à une définition déclarée dans provisioning.customData, et le kit résout l'id de la définition à votre place. Elle renvoie un CustomDataHandle :
| Membre | Rôle |
|---|---|
id | L'id de définition résolu. |
save(records) | Upsert de manière sûre les enregistrements dans cette définition — par lots, sûr au transport (lève sur échec de transport), fait remonter les rejets par élément. |
run: async (ctx) => {
const loyalty = ctx.customData('loyalty_points'); // matches provisioning.customData
const { sent } = await loyalty.save(rows.map(toLoyaltyRecord));
return { items: sent, errors: [], advanceCursorTo: ctx.window.until };
};C'est ainsi que vous poussez des enregistrements de données personnalisées — ne résolvez pas les ids de définition à la main. Reliez un enregistrement au contact via son champ de relation : positionnez le champ que vous avez déclaré comme relation système sur un resolver, ex. contact_id: { by: 'email', value } (le serveur stocke l'id_contact canonique). Les relations au niveau de la définition elles-mêmes sont déclarées dans provisioning.customData.
Provisioning
Avant de pousser des données, une intégration provisionne la structure d'accueil : sources de données (parent/enfants), définitions de données personnalisées, événements, statuts de commande. Déclarez un ProvisioningPlan via provisioning(ctx) — le kit le rejoue à l'activation (et à chaque config_updated), de façon idempotente (find-or-create).
provisioning: (ctx): ProvisioningPlan => ({
dataSources: [
{ key: 'parent', decl: { label: 'My App', type: 'api' } },
{ key: 'store-12', parentKey: 'parent', decl: { label: 'Store 12', type: 'api' } },
],
customData: [
// declare a target definition BEFORE the one that references it
{ name: 'stores', unique_keys: ['store_ref'], fields: [{ name: 'store_ref', type: 'text' }] },
{
name: 'loyalty_points', // referenced by ctx.customData('loyalty_points')
unique_keys: ['contact_id'],
fields: [
{ name: 'contact_id', type: 'number', required: true },
{ name: 'balance', type: 'number' },
{ name: 'store', type: 'number' },
],
relationships: [
{ sourceField: 'contact_id', targetSchemaType: 'system', targetSchema: 'contacts', targetField: 'id_contact' },
// custom -> custom: targetSchema is the SIBLING'S NAME — the kit resolves it to its id
{ sourceField: 'store', targetSchemaType: 'custom', targetSchema: 'stores', targetField: 'store_ref' },
],
},
],
// events: [...], // events
// orderStatuses: [...] // order statuses
}),| Champ du plan | Rôle |
|---|---|
dataSources | Sources { key, decl, parentKey? }. parentKey référence une autre key du plan → hiérarchie parent/enfant. La key est celle que vous passez ensuite à ctx.withSource. |
customData | Définitions de données personnalisées à créer + activer. Le name est celui que vous passez à ctx.customData. |
events | Événements à déclarer (tolère un doublon). |
orderStatuses | Statuts de commande à pousser. |
Relations entre définitions
Une définition de données personnalisées peut déclarer des relationships vers d'autres schémas :
| Champ | Rôle |
|---|---|
sourceField | Un champ de cette définition qui porte le lien. |
targetSchemaType | 'system' (un schéma ShopiMind comme contacts, products) ou 'custom' (une autre définition). |
targetSchema | 'system' → le nom du schéma (contacts). 'custom' → l'id de la cible ou le nom d'une définition sœur du même plan. |
targetField? | Le champ apparié dans la cible (par défaut son id — ex. id_contact pour les contacts, ou une clé unique de la cible comme store_ref). |
custom → custom par nom
Vous connaissez rarement l'id numérique d'une définition sœur au moment de l'écriture. Référencez-la par nom dans targetSchema (avec targetSchemaType: 'custom') ; le kit substitue l'id résolu au provisioning — exactement comme parentKey pour les sources de données. Déclarez la cible avant la définition qui la référence.
Provisioning convergent des données personnalisées
Le provisioning de customData est convergent : sur une définition qui existe déjà, le kit l'étend avec les champs et relations manquants (appariés par name / sourceField) — de façon idempotente. De nouveaux champs ou de nouvelles relations dans une version ultérieure de l'intégration se propagent donc correctement.
Quand la synchro se déclenche
- À l'activation : une synchro complète (backfill, profondeur
backfillDays, défaut 365 j) démarre automatiquement. - En continu : le planificateur interne lance une synchro incrémentale de chaque installation active toutes les
syncIntervalMinutes(défaut 15). - À la demande :
POST /admin/sync/{installationId}?full=true(protégé paradminToken).
Pour pousser une donnée en temps réel (sans attendre le prochain cycle), exposez plutôt une route entrante que votre application appelle.
Pour aller plus loin
- Le kit d'intégration —
ctx, runtime, options. - Configuration — activer/désactiver des étapes via la config.
- Middleware entrant — pousser une donnée en temps réel depuis votre app.
- Référence du SDK — toutes les ressources et méthodes.
- Exemple complet (Hiboutik) — multi-magasins de bout en bout.