Yayaw
Documentation
Intégrations

Connecteurs serveur

Envoyer les lignes du tableau vers Notion et Google Sheets, ou les synchroniser dans les deux sens, depuis votre serveur avec les modules connecteurs optionnels.

YaYaw Table fournit des connecteurs serveur optionnels pour Notion et Google Sheets, en React comme en Vue. Ils envoient les lignes du tableau vers une base de données Notion ou un onglet Google Sheets depuis votre serveur, ou les synchronisent dans les deux sens, et constituent le back end naturel d'une destination de connexion. Donnez un connector à cette destination et la table fournit elle-même l'écran d'envoi : cible et onglet, correspondance des colonnes, champ clé, mode, enregistrements et résultat. Voir Envoyer vers un connecteur.

Les connecteurs sont écrits en TypeScript pur, sans framework ni dépendance npm. Ils n'utilisent que fetch et Web Crypto, et fonctionnent donc avec Node 20+, Bun, Deno et les runtimes edge. Ils sont réservés au serveur : ne les importez jamais dans des composants client.

Qui fait quoi

La bibliothèque prend en charge l'envoi lui-même :

  • la conversion des valeurs vers les types de la cible (texte, nombres, dates, cases à cocher, listes de choix, URL, e-mails) ;

  • la correspondance entre les colonnes du tableau et les propriétés Notion ou les colonnes de la feuille ;

  • la mise à jour ou création (upsert) des lignes, identifiées par un champ Yayaw ID, ou le remplacement du contenu d'une feuille ;

  • les nouvelles tentatives qui respectent Retry-After, avec un délai croissant en cas d'erreur serveur, et la limitation du débit ;

  • des erreurs typées qui ne contiennent jamais de données de ligne.

Votre application fournit ce qu'une bibliothèque ne peut pas fournir :

Votre applicationPourquoi
Stocke les jetons et les clés de compte de serviceCe sont des secrets. Stockez-les chiffrés, par organisation ou par utilisateur, et ne les envoyez jamais au navigateur.
Vérifie qui peut envoyer quoiDécidez qui peut connecter une destination et y envoyer quelle vue, et vérifiez-le à chaque appel.
Appelle les fonctionsDepuis ses server actions, ses routes d'API ou ses handlers serveur.
Charge les lignesSur le serveur, avec la requête de la vue et les autorisations de l'utilisateur, jamais depuis le client.
Exécute les envois planifiésDans ses propres workers, voir Planifier une destination de connexion.
Stocke l'état de synchronisationUn SyncState par destination et par vue, voir Synchronisation bidirectionnelle.

Installation

Chaque connecteur est un élément du registre qui contient les fichiers partagés connector-model.ts et sync-engine.ts, ainsi que le module du fournisseur.

# React
npx shadcn@latest add https://table.yayaw.app/r/yayaw-table-connector-notion.json
npx shadcn@latest add https://table.yayaw.app/r/yayaw-table-connector-google-sheets.json

# Vue
npx shadcn-vue@latest add https://table.yayaw.app/r/yayaw-table-vue-connector-notion.json
npx shadcn-vue@latest add https://table.yayaw.app/r/yayaw-table-vue-connector-google-sheets.json

Les fichiers sont installés sous components/ui/yayaw-table/connectors/ (React) ou components/ui/yayaw-table-vue/connectors/ (Vue). Importez chaque module directement ; il n'y a pas de fichier index.

Configurer Notion

  1. Dans Notion, ouvrez Paramètres › Connexions › Développer ou gérer des intégrations et créez une intégration interne pour votre espace de travail, avec les capacités de lecture, de mise à jour et d'insertion de contenu.

  2. Copiez son secret d'intégration interne et stockez-le sur votre serveur comme jeton de la connexion.

  3. Partagez chaque base de données cible avec l'intégration : ouvrez la base, puis … › Connexions et ajoutez l'intégration. Notion signale une base non partagée par not_found.

  4. Ajoutez une propriété Yayaw ID (titre, texte ou nombre) à la base, ou choisissez une autre propriété clé dans la correspondance. Préparer peut l'ajouter pour vous.

Le module Notion (notion.ts) fournit :

  • verifyNotionToken(token) : renvoie { botId, name, workspaceName }.

  • listNotionDatabases(token) : les bases partagées avec l'intégration.

  • getNotionDatabaseSchema(token, databaseIdOrUrl) : les propriétés, avec les options des listes de choix et des statuts.

  • defaultNotionMapping(columns, schema) : associe les colonnes aux propriétés de même nom et de type compatible, puis renseigne la propriété titre.

  • pushRowsToNotionDatabase({ token, databaseId, mapping, rows, maxRows }) : crée ou met à jour une page par ligne, identifiée par la propriété Yayaw ID. Les requêtes sont espacées à environ trois par seconde, et un envoi est limité à 5 000 lignes. Les propriétés sont cherchées d'abord par identifiant : une propriété renommée continue de recevoir sa colonne.

  • notionTargetSchema(schema), prepareNotionDatabase({ token, databaseId, fixes }), listNotionPages(token) et createNotionDatabase({ token, parentPageId, title, columns }) : vérifier, préparer ou créer une base. Voir Garder la destination saine.

Configurer Google Sheets

  1. Dans la console Google Cloud, créez ou choisissez un projet et activez l'API Google Sheets. Si elle est désactivée, les envois échouent avec api_disabled.

  2. Créez un compte de service, ajoutez-lui une clé JSON et stockez le fichier téléchargé sur votre serveur comme secret de la connexion.

  3. Partagez chaque feuille de calcul avec l'adresse e-mail du compte de service (…@….iam.gserviceaccount.com) en tant qu'Éditeur.

Le module Google Sheets (google-sheets.ts) fournit :

  • parseServiceAccountKey(json) : valide le fichier de clé et renvoie les identifiants.

  • verifyGoogleSheetsCredentials(credentials) : vérifie la clé et renvoie l'adresse e-mail avec laquelle partager les feuilles de calcul.

  • parseSpreadsheetId(urlOrId), getSpreadsheet(credentials, urlOrId) et readHeaderRow(credentials, urlOrId, sheetTitle) : lisent la feuille de calcul, ses onglets et la première ligne.

  • pushRowsToSheet({ credentials, spreadsheetId, sheetTitle, columns, rows, keyColumn, mode, maxRows }) : avec mode: "upsert" (par défaut), met à jour les lignes dont la cellule Yayaw ID correspond et ajoute les autres ; avec mode: "replace", efface tout ce qui se trouve sous l'en-tête puis le réécrit. Les en-têtes manquants sont ajoutés à la fin, les colonnes de l'utilisateur ne sont jamais réordonnées, et les valeurs sont envoyées brutes : un texte commençant par = n'est jamais évalué. Un envoi est limité par défaut à 10 000 lignes.

  • getSheetTargetSchema({ credentials, spreadsheetId, sheetTitle }), sheetTargetSchema(grid) et prepareSheet({ credentials, spreadsheetId, sheetTitle, fixes }) : vérifier ou préparer un onglet. Voir Garder la destination saine.

Si une feuille de calcul n'est pas partagée avec le compte de service, l'envoi échoue avec not_shared et details.serviceAccountEmail, pour que votre interface puisse indiquer avec quelle adresse la partager.

Exemple de server action

push-view-to-sheet.ts
"use server";
import {
  isConnectorError,
  toConnectorRows,
} from "@/components/ui/yayaw-table/connectors/connector-model";
import {
  parseServiceAccountKey,
  pushRowsToSheet,
} from "@/components/ui/yayaw-table/connectors/google-sheets";
import { loadConnection, loadViewRows, requireUser } from "@/server/app";

export async function pushViewToSheet(input: {
  connectionId: string;
  viewId: string;
}) {
  const user = await requireUser();
  // Vos autorisations : l'utilisateur peut utiliser cette connexion et lire cette vue.
  const connection = await loadConnection(user, input.connectionId);
  const { columns, records } = await loadViewRows(user, input.viewId);
  try {
    const result = await pushRowsToSheet({
      credentials: parseServiceAccountKey(connection.secret),
      spreadsheetId: connection.spreadsheetId,
      sheetTitle: connection.sheetTitle,
      columns: columns.map((column) => ({ id: column.id, header: column.label })),
      rows: toConnectorRows(records),
    });
    return { ok: true, created: result.created, updated: result.updated };
  } catch (error) {
    if (isConnectorError(error)) {
      // Par exemple "not_shared", avec l'adresse avec laquelle partager la feuille.
      return {
        ok: false,
        code: error.code,
        shareWith: error.details.serviceAccountEmail,
      };
    }
    throw error;
  }
}

requireUser, loadConnection et loadViewRows représentent votre propre code. Pour le parcours complet, avec les cibles, la correspondance choisie à l'écran et les réglages enregistrés par vue, voir l'exemple Google Sheets des écrans de connecteur. En Vue, appelez les mêmes fonctions depuis une route serveur Nuxt ou n'importe quel handler serveur.

Résultats et erreurs

Chaque envoi renvoie { created, updated, skipped, failed, failures, warnings, warningCount, truncated }. Les lignes sans identifiant ou dont l'identifiant est répété sont ignorées avec un avertissement ; les lignes au-delà de maxRows renseignent truncated. Une ligne en échec est consignée dans failures, tandis qu'un jeton révoqué ou un accès manquant arrête l'envoi avec une ConnectorError.

ConnectorError.code vaut unauthorized, forbidden, not_shared, api_disabled, not_found, rate_limited, provider_unavailable, invalid_request, invalid_target, invalid_mapping, invalid_credentials ou aborted. details ne contient que des valeurs sûres (status, serviceAccountEmail) : le corps des erreurs du fournisseur peut reprendre des données de ligne, il n'est donc jamais inclus.

Chaque fonction accepte aussi des options : fetch, signal, timeoutMs, maxRetries et rateLimiter. Une réponse 429 est relancée après le délai de son Retry-After (30 secondes au plus). Les erreurs serveur et réseau sont relancées avec un délai croissant uniquement quand la requête peut être répétée sans risque : la création d'une page Notion ou l'ajout de lignes à une feuille n'est relancé que sur une 429, afin qu'un échec ambigu n'écrive jamais deux fois.

Pour les envois planifiés, votre worker vérifie à nouveau que le propriétaire de la planification peut toujours utiliser la connexion et la vue, charge les lignes sur le serveur, appelle la même fonction d'envoi avec l'AbortSignal de la tâche, et enregistre le résultat pour l'historique des exécutions. Partagez un même createRateLimiter(334) entre les tâches Notion simultanées qui utilisent le même jeton.

Synchronisation bidirectionnelle

Un envoi n'écrit que dans la cible. Une synchronisation la lit aussi, compare les deux côtés avec ce qu'ils contenaient après l'exécution précédente, et reporte chaque modification de l'autre côté. Le module partagé sync-engine.ts s'occupe de la planification ; il est pur : il ne fait aucune entrée-sortie, ne stocke rien et ne journalise rien. Votre application lit les deux côtés, stocke l'état de synchronisation et exécute la tâche. Dans la table, l'écran du connecteur permet de choisir le sens, la règle de conflit et le traitement des suppressions, de prévisualiser les changements et de synchroniser ; vos fonctions serveur exécutent le moteur et renvoient toSyncPreview et toSyncRunResult.

Sens, fusion et règles

planSync({ direction, conflictRule, deletePolicy, mapping, tableRecords, targetRecords, state }) compare les enregistrements des deux côtés avec l'état stocké et renvoie un SyncPlan :

  • direction : two-way fusionne les deux côtés ; push aligne la cible sur le tableau et pull aligne le tableau sur la cible. Un sens unique n'écrit jamais de l'autre côté, sauf le champ clé d'un enregistrement de la cible.

  • Fusion à trois voies par colonne. Chaque ligne liée conserve baseValues, ses valeurs lors de la dernière synchronisation (par défaut, storeBaseValues: true). Une colonne modifiée seulement dans le tableau est reportée dans la cible, une colonne modifiée seulement dans la cible est reportée dans le tableau, et une colonne modifiée des deux côtés avec des valeurs différentes est un conflit. Des colonnes différentes modifiées de chaque côté fusionnent sans conflit. Sans baseValues, le moteur compare plutôt les empreintes des enregistrements, et un enregistrement modifié des deux côtés fait de chaque colonne différente un conflit.

  • conflictRule : table-wins (par défaut), target-wins, ou latest-wins, qui compare le updatedAt des deux enregistrements et revient au tableau sans les deux dates ou en cas d'égalité. Les lignes Google Sheets n'ont pas de date de modification : sur Sheets, latest-wins se comporte donc comme table-wins. Chaque conflit est signalé dans conflicts avec les valeurs du tableau, de la cible et de la base, ainsi que la façon dont il est réglé. Les règles par colonne ou dans le code sont décrites dans Règles de conflit dans le code.

  • deletePolicy, pour un enregistrement lié absent d'un côté : ignore garde le lien et ne fait rien, flag (par défaut) garde le lien et signale l'enregistrement dans flagged, propagate le supprime de l'autre côté et retire le lien. Une suppression que le sens ne permet pas d'écrire est signalée. Un lien conservé ne recrée jamais l'enregistrement supprimé ; retirez le lien de l'état pour le recréer.

  • Adoption. Avant toute création, les enregistrements non liés sont rapprochés par la clé Yayaw ID : un enregistrement existant de la cible est adopté au lieu d'être dupliqué, et les colonnes différentes d'un enregistrement adopté sont des conflits. Les enregistrements qui partagent une clé sont signalés dans duplicates et laissés tels quels, jamais devinés. Un enregistrement de la cible sans clé reçoit l'identifiant de la ligne du tableau dans son champ clé.

Les valeurs sont comparées sous une forme normalisée (normalizeSyncValue), si bien qu'un aller-retour par Notion ou Sheets ne ressemble jamais à une modification : valeurs vides, nombres relus comme du texte, dates, mots oui/non et sélections multiples dans n'importe quel ordre sont égaux à ce qui a été écrit. summarizeSyncPlan(plan) renvoie les compteurs d'un aperçu (créations, mises à jour et suppressions de chaque côté, conflicts, flagged, duplicates, skipped, unchanged et changes). Une deuxième synchronisation d'affilée donne changes: 0.

Ce que votre application stocke

Les enregistrements des deux côtés sont des SyncRecord { id, key?, values, updatedAt? }, avec des values indexées par identifiant de colonne. toSyncMapping(settings, columns) construit le SyncMapping à partir des réglages des écrans de connecteur (champ clé et correspondance des colonnes).

Stockez un SyncState { links, lastSyncAt? } par destination et par vue, en JSON, par exemple à côté des réglages de connexion de la vue. Chaque SyncLink { rowId, remoteId, tableHash, targetHash, baseValues?, syncedAt } relie une ligne du tableau à son enregistrement dans la cible. Partez d'un état vide (EMPTY_SYNC_STATE) : la première exécution adopte par clé les enregistrements existants de la cible.

Exécuter une synchronisation dans un worker

Chaque exécution lit les deux côtés, appelle planSync, applique le plan avec applySyncPlan et enregistre result.state :

run-notion-sync.ts
import {
  applySyncPlan,
  planSync,
  summarizeSyncPlan,
  toSyncMapping,
  type SyncRecord,
} from "@/components/ui/yayaw-table/connectors/sync-engine";
import { createNotionSyncTarget } from "@/components/ui/yayaw-table/connectors/notion";
import { loadConnection, loadViewRows, syncStates, tableRows } from "@/server/app";

export async function runNotionSync(job: {
  connectionId: string;
  viewId: string;
  ownerId: string;
  signal: AbortSignal;
}) {
  // Votre autorisation : le propriétaire peut encore utiliser cette connexion et cette vue.
  const connection = await loadConnection(job.ownerId, job.connectionId);
  const { columns, records } = await loadViewRows(job.ownerId, job.viewId);
  const mapping = toSyncMapping(connection.settings, columns);
  const target = createNotionSyncTarget(
    { token: connection.secret, databaseId: connection.databaseId, mapping },
    { signal: job.signal }
  );
  const state = await syncStates.get(job.connectionId, job.viewId);

  // 1. Lire les deux côtés.
  const tableRecords: SyncRecord[] = records.map((record) => ({
    id: String(record.id),
    values: record,
    updatedAt: record.updatedAt,
  }));
  const targetRecords = await target.read();

  // 2. Planifier. Un aperçu peut s'arrêter ici et afficher summarizeSyncPlan(plan).
  const plan = planSync({
    direction: "two-way",
    conflictRule: "latest-wins",
    deletePolicy: "flag",
    mapping,
    tableRecords,
    targetRecords,
    state,
  });

  // 3. Appliquer via la cible et votre propre adaptateur de tableau.
  const result = await applySyncPlan(
    plan,
    { target, table: tableRows(job.ownerId, job.viewId) },
    { signal: job.signal }
  );

  // 4. Enregistrer l'état, même après un échec partiel.
  await syncStates.set(job.connectionId, job.viewId, result.state);
  return { summary: summarizeSyncPlan(plan), result };
}

loadConnection, loadViewRows, syncStates et tableRows représentent votre propre code. L'adaptateur de tableau est le vôtre : create insère des lignes et renvoie leurs identifiants, update modifie les colonnes données et delete supprime des lignes, toujours avec les autorisations du propriétaire. Chaque méthode reçoit un lot de SyncWrite { id?, key?, values } (uniquement les colonnes à écrire) et renvoie un { ok: true, id? } ou un { ok: false, code? } par élément, dans l'ordre.

applySyncPlan écrit par lots de 50 (batchSize) dans un ordre sûr : créations et mises à jour dans la cible, créations dans le tableau avec l'écriture de la clé en retour, mises à jour dans le tableau, puis suppressions. Un élément en échec est consigné dans failures et l'exécution continue. Une erreur d'autorisation (unauthorized, forbidden, not_shared, invalid_credentials, api_disabled) ou le signal arrête l'exécution et renseigne result.stopped. result.state n'enregistre que ce qui a réussi : une ligne en échec garde son lien précédent, la prochaine exécution la planifie donc à nouveau, et une création ambiguë est adoptée par clé au lieu d'être dupliquée. Verrouillez la destination et la vue pendant une synchronisation, afin que deux exécutions n'appliquent jamais des plans établis à partir du même état.

Notion et Google Sheets

  • Notion : readNotionDatabase({ token, databaseId, mapping, since }) lit chaque page de la base de données, en suivant la pagination, avec l'identifiant de la page, la clé Yayaw ID, last_edited_time comme updatedAt et les propriétés mises en correspondance reconverties en valeurs simples. Avec since, elle ne lit que les pages modifiées depuis le début de cette minute (Notion enregistre les dates de modification à la minute) ; passez alors targetPartial: true à planSync, pour qu'une page absente compte comme inchangée et non supprimée. Seule une lecture complète détecte les suppressions. createNotionSyncTarget({ token, databaseId, mapping }) est l'adaptateur de cible, avec en plus read(since?) : il crée des pages identifiées par Yayaw ID, ne modifie que les propriétés données et la clé, et archive les pages supprimées.

  • Google Sheets : readSheetRows({ credentials, spreadsheetId, sheetTitle, mapping }) lit l'onglet en une fois et fait correspondre les en-têtes aux colonnes. L'identifiant distant est la cellule Yayaw ID, ou row:<numéro> pour une ligne qui n'en a pas. createSheetSyncTarget({ credentials, spreadsheetId, sheetTitle, mapping }) ajoute les nouvelles lignes, met à jour les lignes trouvées par clé en n'écrivant que les cellules données et la clé, et supprime les lignes uniquement par clé, de bas en haut, en une requête par tranche de 500 lignes : les numéros de ligne se décalent quand des lignes sont supprimées, une ligne sans clé n'est donc jamais supprimée par son numéro. Les en-têtes manquants sont ajoutés à la fin de la ligne d'en-tête.

Limites connues

  • Dans Google Sheets, les nombres et les cases à cocher sont lus comme des valeurs typées et les dates telles qu'elles s'affichent. Une date saisie à la main dans un format local (comme 23/09/2026) est relue comme du texte, et non comme une date.

  • Une valeur que le fournisseur ne peut pas stocker telle quelle est relue différemment à l'exécution suivante, qui y voit une modification et la planifie à nouveau. Associez chaque colonne à un champ de type compatible pour l'éviter.

Règles de conflit dans le code

La règle globale conflictRule est un seul choix pour toutes les colonnes. La synchronisation bidirectionnelle peut aussi décider des conflits dans le code, colonne par colonne, avec trois entrées facultatives de planSync. Pour chaque colonne modifiée des deux côtés, elles s'appliquent dans cet ordre, et la première qui décide l'emporte : ownership, puis columnRules, puis resolveConflict, puis la règle globale conflictRule. Un plan sans elles se comporte exactement comme avant.

Propriété et règles par colonne

  • ownership: Record<columnId, "table" | "target"> : un côté fait foi pour une colonne et l'emporte toujours sur celle-ci. En synchronisation bidirectionnelle, une modification faite de l'autre côté est un écart : la valeur du côté propriétaire est réécrite à la synchronisation suivante et signalée dans plan.overridden ({ columnId, owner, tableValue, targetValue, bothChanged }), et non dans conflicts. Une synchronisation à sens unique n'écrit jamais une colonne vers son propriétaire : un envoi laisse de côté les colonnes qui appartiennent à la cible. Les créations écrivent toujours toutes les colonnes mises en correspondance.

  • columnRules: Record<columnId, règle> : table-wins, target-wins et latest-wins fonctionnent comme la règle globale, pour cette colonne seulement. merge est une union à trois voies pour les valeurs de liste, comme les sélections multiples et les étiquettes : les éléments que les deux côtés ont gardés ou que l'un des côtés a ajoutés, moins les éléments de la base retirés d'un côté ou de l'autre, ceux du tableau d'abord et sans doublon (mergeSyncLists est exportée). Sur une colonne qui n'est pas une liste, merge passe à l'étape suivante. manual laisse le conflit à une personne (voir Revue manuelle).

plan-with-rules.ts
import { planSync } from "@/components/ui/yayaw-table/connectors/sync-engine";

const plan = planSync({
  direction: "two-way",
  conflictRule: "table-wins", // pour toutes les colonnes que les règles laissent ouvertes
  mapping,
  tableRecords,
  targetRecords,
  state,
  // Le CRM fait foi pour les prix, le tableau pour le responsable interne.
  ownership: { price: "target", owner: "table" },
  // Les étiquettes sont fusionnées, les notes décidées par une personne.
  columnRules: { tags: "merge", notes: "manual" },
});

Un arbitre dans le code

resolveConflict(context) reçoit { columnId, field, tableValue, targetValue, baseValue, tableRecord, targetRecord, rowId, remoteId }, avec les valeurs sous leur forme normalisée (voir normalizeSyncValue), et renvoie l'une de ces réponses :

  • "table" ou "target" : la valeur de ce côté l'emporte ;

  • { value } : une valeur de votre choix, écrite des deux côtés là où elle diffère ;

  • "skip" : les deux côtés restent tels quels pour cette exécution, et le conflit revient à la suivante ;

  • "manual" : une personne décide ;

  • undefined : pas d'avis, la règle globale conflictRule décide.

Elle doit être pure et synchrone : elle s'exécute là où s'exécute planSync, sur votre serveur ou dans votre worker, jamais dans le navigateur. Un arbitre qui lève une erreur produit un conflit manuel avec error: "resolver_failed", et un arbitre qui renvoie une promesse ou toute autre valeur produit un conflit manuel avec error: "invalid_decision".

conflict-rules.ts
import {
  planSync,
  type ConflictResolver,
} from "@/components/ui/yayaw-table/connectors/sync-engine";

const STATUS_ORDER = ["Draft", "Active", "Won", "Archived"];

/** Garde le montant le plus élevé et ne laisse jamais un statut reculer. */
export const resolveConflict: ConflictResolver = (conflict) => {
  if (conflict.columnId === "amount") {
    const amounts = [conflict.tableValue, conflict.targetValue].map(Number);
    return { value: Math.max(...amounts) };
  }
  if (conflict.columnId === "status") {
    const rank = (value: unknown) => STATUS_ORDER.indexOf(String(value));
    return rank(conflict.tableValue) >= rank(conflict.targetValue)
      ? "table"
      : "target";
  }
  // Pas d'avis : la règle globale conflictRule décide.
  return undefined;
};

const plan = planSync({
  direction: "two-way",
  conflictRule: "latest-wins",
  mapping,
  tableRecords,
  targetRecords,
  state,
  resolveConflict,
});

Chaque conflit de plan.conflicts porte désormais resolution (table, target, merged, custom, manual ou skipped), source (column, resolver ou rule) et, pour les valeurs fusionnées ou choisies par l'arbitre, value. winner est conservé pour le code existant et n'a de sens que lorsque resolution désigne un côté. summarizeSyncPlan ajoute les compteurs overridden et pendingConflicts.

Vérifier la configuration

validateConflictConfig({ ownership, columnRules, resolveConflict, direction }, mapping) renvoie { code, columnId?, severity, message }[]. Erreurs : unknown_column, invalid_owner, invalid_rule, invalid_resolver, et owner_not_written (un propriétaire qu'un sens unique n'écrit jamais, comme ownership: { price: "target" } avec un envoi). Avertissements : merge_not_list, rule_on_owned_column (la propriété l'emporte toujours) et rules_unused (des règles par colonne ou un arbitre avec un sens unique). Appelez-la une fois au chargement de la configuration ; planSync ignore ce qu'elle signale au lieu de lever une erreur.

import { validateConflictConfig } from "@/components/ui/yayaw-table/connectors/sync-engine";

const issues = validateConflictConfig(
  { ownership, columnRules, resolveConflict, direction: "two-way" },
  mapping
);
if (issues.some((issue) => issue.severity === "error")) {
  throw new Error(issues.map((issue) => issue.message).join("\n"));
}

Revue manuelle

Un conflit manual n'est pas appliqué. Les deux côtés gardent leur valeur, les autres colonnes de la ligne continuent de se synchroniser, et le conflit attend dans SyncState.pendingConflicts sous la forme { rowId, remoteId, columnId, field, tableValue, targetValue, baseValue, detectedAt }. Il y en a au plus un par ligne et par colonne. Il garde son detectedAt tant que les valeurs ne changent pas, est remplacé quand l'une d'elles change, et disparaît quand les deux côtés sont de nouveau d'accord. Il reste en attente même si un côté revient à la valeur de base, jusqu'à ce qu'une personne décide ou que les côtés soient d'accord. Une lecture partielle (targetPartial) ou une ligne bloquée le laisse tel quel.

Quand une personne garde une valeur, resolvePendingConflicts(state, resolutions, { mapping }) transforme les décisions ({ rowId, columnId, choice: "table" | "target" | { value } }[]) en écritures (operations.updateInTable et operations.updateInTarget, une par ligne et par côté) et en state suivant : les conflits réglés sont retirés et la valeur choisie devient la base de la colonne, si bien que la synchronisation suivante voit les deux côtés d'accord. Les décisions qui ne correspondent à aucun conflit en attente (déjà réglé, ou périmé) sont renvoyées dans unmatched. applyConflictResolutions(plan, { table, target }) applique les écritures avec les mêmes adaptateurs que applySyncPlan ; une ligne dont l'écriture échoue garde ses conflits et son lien.

sync-conflicts.ts
import {
  applyConflictResolutions,
  resolvePendingConflicts,
  toSyncMapping,
} from "@/components/ui/yayaw-table/connectors/sync-engine";
import {
  toPendingConflicts,
  toSyncRunResult,
  type PendingConflictResolution,
} from "@/components/ui/yayaw-table/utils/connector-flow";
import { loadConnection, syncStates, tableRows, titles } from "@/server/app";

// Les conflits laissés à une personne, nommés d'après leurs enregistrements.
export async function listSyncConflicts(connectionId: string, viewId: string) {
  const state = await syncStates.get(connectionId, viewId);
  return toPendingConflicts(state.pendingConflicts, {
    rowLabel: (id) => titles.get(id),
  });
}

// Écrit les choix de la personne des deux côtés et enregistre le nouvel état.
export async function resolveSyncConflicts(
  connectionId: string,
  viewId: string,
  resolutions: PendingConflictResolution[]
) {
  const { settings, columns, target } = await loadConnection(connectionId, viewId);
  const mapping = toSyncMapping(settings, columns);
  const state = await syncStates.get(connectionId, viewId);
  const plan = resolvePendingConflicts(state, resolutions, { mapping });
  const result = await applyConflictResolutions(plan, {
    table: tableRows(viewId),
    target,
  });
  await syncStates.set(connectionId, viewId, result.state);
  return toSyncRunResult(result);
}

Utilisez le même verrou que pour une synchronisation, afin qu'une résolution ne s'exécute jamais en même temps qu'une synchronisation de la même destination et de la même vue.

Dans l'écran du connecteur

Les fonctions n'atteignent jamais le navigateur. Le connecteur déclare dans conflicts les règles que l'écran affiche, ainsi que deux fonctions hôtes facultatives pour la revue manuelle, listConflicts et resolveConflicts, qui appellent les fonctions ci-dessus. L'écran affiche alors Règles définies par votre application, annote la prévisualisation et propose Conflits à résoudre. Voir Règles de conflit définies par votre application.

Garder la destination saine

Un envoi ou une synchronisation casse quand la destination dérive : une propriété renommée, supprimée ou changée de type, des options de liste manquantes, pas de champ Yayaw ID. Les connecteurs vérifient désormais la destination avant d'envoyer, corrigent ce qui peut l'être sans risque et disent simplement ce qu'une personne doit faire. L'écran du connecteur lance la vérification sans code supplémentaire ; Préparer demande une fonction serveur, et une exécution planifiée devrait vérifier avant d'écrire.

Identité stable des champs

Les champs qu'un connecteur décrit portent un id (l'identifiant de la propriété Notion) et un index (la position de la colonne dans la feuille). L'écran les enregistre avec chaque entrée de correspondance sous la forme { columnId, field, fieldId?, fieldIndex? }, et avec la clé sous keyField, keyFieldId? et keyFieldIndex?. À l'exécution, un champ est cherché d'abord par identifiant, puis par nom (resolveMappedField) :

  • Notion. toConnectorMapping et toSyncMapping transmettent les identifiants de propriété, et pushRowsToNotionDatabase, readNotionDatabase et createNotionSyncTarget les utilisent. Un envoi continue d'écrire « Prix » après son renommage en « Coût » dans Notion, et l'écran affiche « Renommé dans Notion : Prix → Coût » avec Mettre à jour la correspondance, qui enregistre le nouveau nom.

  • Google Sheets. Les feuilles n'ont pas d'identifiants : un en-tête disparu est cherché à sa position enregistrée, décalée d'autant que la colonne clé s'est déplacée, lorsque l'en-tête qui s'y trouve n'est associé à rien d'autre, et signalé comme renommé. Les écritures suivent la même règle : pushRowsToSheet (fieldIndexes et keyColumnIndex, issus de toConnectorMapping), createSheetSyncTarget, readSheetRows (fieldIndex et keyFieldIndex dans toSyncMapping) et prepareSheet écrivent la colonne renommée à sa place et n'ajoutent jamais de nouveau l'ancien en-tête. Des en-têtes déplacés sont sans conséquence, car les cellules sont écrites par en-tête.

  • Cas ambigu. Quand la position enregistrée est inutilisable (rien à cet endroit, ou un en-tête qu'utilise une autre colonne associée), rien n'est écrit : les fonctions de feuille lèvent field_missing, ce qui arrête une synchronisation, et la vérification le signale comme bloquant jusqu'à ce que le champ soit choisi de nouveau.

Les correspondances enregistrées avec les noms seuls continuent de fonctionner et gagnent les identifiants au prochain enregistrement. Sur le serveur, upgradeMapping fait de même.

Vérifier la destination

checkTargetSchema({ columns, mapping, keyField, keyFieldId, keyFieldIndex, targetSchema, direction }) se trouve dans utils/connector-schema.ts (components/ui/yayaw-table/utils/connector-schema.ts en React, components/ui/yayaw-table-vue/connector-schema.ts en Vue). Il est installé avec la table, pur et utilisable sur le serveur. columns sont les colonnes de la table avec leur type et leurs options. targetSchema vaut { provider, fields }, issu de notionTargetSchema(await getNotionDatabaseSchema(token, databaseId)) ou de await getSheetTargetSchema({ credentials, spreadsheetId, sheetTitle }) (les en-têtes, leurs positions et un type déduit des 20 premières lignes).

Il renvoie { issues, fixes }, les problèmes triés par ordre : bloquants, corrigeables, puis avertissements. Chaque problème a une severity, un code, la columnId et le field concernés, un detail qui ne contient que des noms, des types et des noms d'options, et, lorsqu'il peut être corrigé, le fix qu'applique Préparer. La gravité dépend du sens :

CodeEnvoi et bidirectionnelImport
missing_field (jamais présent), deleted_field (identifiant disparu)corrigeable : le créeravertissement
renamed_fieldavertissement, l'exécution continue par identifiantavertissement
incompatible_typebloquant (avertissement dans une feuille)bloquant
coercible_type (texte qui doit se convertir)avertissementavertissement ; bloquant quand des valeurs échantillonnées échouent
unsupported_type (personnes, relations, fichiers)bloquantbloquant
read_only_field (formule, agrégation, date de création…)bloquantlisible sans problème
missing_optionscorrigeable (sélection et sélection multiple Notion), bloquant (statut Notion : son API ne peut pas ajouter d'options), avertissement (feuille)—
missing_keycorrigeablecorrigeable
key_wrong_typebloquant (avertissement dans une feuille)bloquant
duplicate_mapping (un champ pour deux colonnes, ou pour une colonne et la clé)bloquantbloquant
title_unmapped (titre de page Notion)bloquant—
field_missing (en-tête de feuille disparu, position enregistrée inutilisable)bloquantbloquant

Les types suivent typeCompatibility(columnType, targetType, direction). À l'envoi, un champ texte accepte toute colonne ; un nombre va vers Nombre (ou du texte), une date vers Date, une case à cocher vers Case à cocher, une sélection vers Sélection, Statut ou Sélection multiple, une sélection multiple vers Sélection multiple, et URL, e-mail et téléphone vers leur propre type. Un texte envoyé vers un champ Nombre, Sélection, Date, URL, e-mail ou téléphone doit pouvoir se convertir. Un import lit Sélection ou Statut dans une sélection, Sélection multiple, Sélection ou Statut dans une sélection multiple, et du texte ou des valeurs calculées dans des colonnes typées seulement quand ils se convertissent. Le bidirectionnel retient le pire des deux. Une destination sans provider, comme un connecteur sur mesure, n'est vérifiée que pour les types et les doublons, puisque son envoi ajoute lui-même les champs manquants.

schemaBlocksRun(report) vaut true dès qu'un problème est bloquant. Les problèmes corrigeables ne bloquent pas, mais un envoi Notion sans Yayaw ID échoue toujours avec invalid_mapping : préparez d'abord la destination.

Préparer la destination

La préparation ne fait qu'ajouter. Elle ne supprime, ne renomme et ne change le type de rien, et la lancer deux fois ne change rien la seconde fois.

  • prepareNotionDatabase({ token, databaseId, fixes }) lit la base, crée les propriétés manquantes avec le bon type (Yayaw ID en texte) et ajoute les options de sélection et de sélection multiple manquantes avec les couleurs de la table (la color de l'option quand elle nomme une couleur, sinon la couleur d'étiquette qu'affiche la table), en une seule requête PATCH /databases. Elle transmet de nouveau toutes les options existantes, telles quelles, ignore les noms d'options que Notion refuse (virgules, plus de 100 caractères) et renvoie { applied, skipped }. Les options de statut ne peuvent pas être ajoutées par l'API Notion : la personne les ajoute dans Notion.

  • prepareSheet({ credentials, spreadsheetId, sheetTitle, fixes, keyColumn?, keyColumnIndex? }) ajoute les en-têtes manquants à la fin de la ligne d'en-tête, la clé en premier, agrandit la grille si nécessaire et ne réordonne ni ne supprime jamais une colonne. Elle renvoie { applied, addedHeaders }.

Transmettez les fixes d'un rapport, ou ceux que l'écran envoie à prepareTarget.

Créer une base Notion

listNotionPages(token) liste les pages partagées avec l'intégration, en suivant la pagination : les parents dans lesquels une nouvelle base peut être créée. createNotionDatabase({ token, parentPageId, title, columns, titleColumnId?, keyProperty? }) crée une base sous l'une d'elles avec une propriété par colonne (le bon type Notion, les options de sélection avec leurs couleurs), une propriété titre remplie par la première colonne texte (ou par l'identifiant de l'enregistrement s'il n'y en a pas) et Yayaw ID. Elle renvoie { id, title, url, mapping }, où mapping contient chaque colonne avec son identifiant de propriété, prêt à être enregistré. La requête n'est jamais relancée après un échec ambigu : une base n'est jamais créée deux fois.

Fonctions hôtes pour l'écran

L'écran vérifie les champs qu'a renvoyés describe. Pour vérifier plutôt un schéma à jour sur le serveur, et pour permettre de préparer la destination, le connecteur déclare checkSchema et prepareTarget, qui appellent des server actions comme celles-ci (voir Vérification et préparation de la destination) :

notion-target-actions.ts
"use server";
import {
  getNotionDatabaseSchema,
  notionTargetSchema,
  prepareNotionDatabase,
} from "@/components/ui/yayaw-table/connectors/notion";
import type { ConnectorSettings } from "@/components/ui/yayaw-table/utils/connector-flow";
import {
  checkTargetSchema,
  type SchemaFix,
} from "@/components/ui/yayaw-table/utils/connector-schema";
import { loadConnection, loadViewColumns, requireUser } from "@/server/app";

export async function checkNotionTarget(settings: ConnectorSettings, viewId: string) {
  const user = await requireUser();
  // Vos autorisations : l'utilisateur peut utiliser cette connexion et lire cette vue.
  const { token } = await loadConnection(user, settings.targetId);
  const columns = await loadViewColumns(user, viewId); // { id, header, type, options }[]
  const schema = await getNotionDatabaseSchema(token, settings.targetId);
  return checkTargetSchema({
    columns,
    mapping: settings.mapping,
    keyField: settings.keyField,
    keyFieldId: settings.keyFieldId,
    targetSchema: notionTargetSchema(schema),
    direction: settings.direction ?? "push",
  });
}

export async function prepareNotionTarget(fixes: SchemaFix[], settings: ConnectorSettings) {
  const user = await requireUser();
  const { token } = await loadConnection(user, settings.targetId);
  // N'ajoute que des propriétés et des options ; renvoie { applied, skipped }.
  return await prepareNotionDatabase({ token, databaseId: settings.targetId, fixes });
}

requireUser, loadConnection et loadViewColumns représentent votre propre code. Pour Google Sheets, lisez le schéma avec getSheetTargetSchema et préparez avec prepareSheet, en passant keyColumn: settings.keyField et keyColumnIndex: settings.keyFieldIndex. Les réglages portent les noms enregistrés des champs renommés : le rapport nomme donc les deux.

Exécutions planifiées

Un worker vérifie la destination avant de s'exécuter et met la planification en pause au lieu d'échouer ligne par ligne :

run-scheduled-push.ts
import {
  getNotionDatabaseSchema,
  notionTargetSchema,
} from "@/components/ui/yayaw-table/connectors/notion";
import {
  checkTargetSchema,
  schemaBlocksRun,
} from "@/components/ui/yayaw-table/utils/connector-schema";

const schema = notionTargetSchema(await getNotionDatabaseSchema(token, databaseId));
const report = checkTargetSchema({
  columns,
  mapping: settings.mapping,
  keyField: settings.keyField,
  keyFieldId: settings.keyFieldId,
  targetSchema: schema,
  direction: settings.direction ?? "push",
});
if (schemaBlocksRun(report)) {
  // Votre planificateur : garder la planification, arrêter ses exécutions, dire pourquoi à son propriétaire.
  await schedules.pause(scheduleId, {
    reason: "target_schema",
    issues: report.issues.filter((issue) => issue.severity === "blocking"),
  });
  return;
}

Montrez les problèmes de la planification en pause à son propriétaire avec un message clair : schemaIssueMessage(issue, { t, target, columns }) de connector-flow.ts les formule comme l'écran, avec t issu de connectorLabels(locale), par exemple « Prix : la propriété Notion est de type Texte, type Nombre attendu. » Reprenez la planification dès qu'une vérification passe. Si le propriétaire l'a autorisé, lancez prepareNotionDatabase ou prepareSheet avec report.fixes avant l'envoi, pour qu'un Yayaw ID ou une option manquants soient ajoutés au lieu de faire échouer l'exécution.

Pas encore couvert : une case à cocher Notion par option de sélection multiple, un modèle de contenu de page, et l'envoi des enregistrements existants lors de la première connexion d'une destination d'envoi (un envoi envoie déjà tous les enregistrements de la vue).