Migration d'un CRM AppSheet vers Express + Base de données relationnelle

Remplacement d’un CRM AppSheet atteignant ses limites par un backend Express typé, une base relationnelle et une interface React — avec réconciliation vérifiée à 100%.

Projet client · anonymisé

Une petite équipe commerciale a dépassé AppSheet : synchronisations lentes, pas de véritable intégrité relationnelle, impossible à intégrer avec la facturation et tarification par utilisateur mal évolutive. Ils avaient besoin de la propriété de leurs données et d'une API que d'autres outils pourraient appeler.

TL;DR

AspectsAvant (AppSheet)Après (Express + Base de données)
Modèle de donnéesFlat Google Sheets, nom du client dupliqué sur 5 ongletsTables relationnelles normalisées (customers ↔ contacts ↔ deals)
IntégritéAucun — trois orthographes du même clientClés étrangères, contraintes UNIQUE, CHECK
IntégrationHacks Google Apps Script, copier-coller dans la facturationAPI REST + webhooks sortants
Latence de synchronisation20 à 40 s aller-retour, pire sous chargeRequêtes locales, <100 ms
Échelle des coûtsPar utilisateur AppSheet (~ 5 à 10 $/siège/mois, escalade)Hébergement à plat (~ 20 $/mois)
Piste d’audit”Dernière modification par” sur une celluleJournal des événements en ajout uniquement

Vous découvrirez les signes concrets indiquant qu’une équipe est devenue trop grande pour AppSheet, la cible l’architecture vers laquelle nous migrons vers et le modèle de migration sécurisée en cinq étapes (remodelage relationnel → validation → ETL idempotent → exécution parallèle → basculement) qui déplace une entreprise hors d’AppSheet sans perdre d’enregistrement ni geler l’équipe.

Le problème

AppSheet est un excellent outil pour la première version d’une application de données de terrain : pointez-le sur une feuille Google, rassemblez quelques formulaires et expédiez-les aux téléphones dans l’après-midi. Il cache la base de données. C’est sa force et, finalement, son plafond.

L’équipe dans ce cas – huit personnes commerciales et opérationnelles réparties sur deux fuseaux horaires – avait a vécu dans un CRM AppSheet pendant deux ans. Au moment où ils m’ont appelé, l’outil qui les obtenir de 0 → 1 leur coûtait activement chaque semaine :

Ils avaient besoin de la propriété de leurs données et d’une API que d’autres systèmes pourraient appeler, sans perdre deux ans de dossiers ou geler l’entreprise pendant un mois. C’est le forme de presque tous les projets de sortie AppSheet : pas une réécriture en soi, mais une migration forcée par un mur que l’outil n’a jamais été conçu pour surmonter.

Pour la version générale de cette histoire (feuilles de calcul en général), voir Quand Google Sheets arrête de évoluer.

Signes que vous êtes devenu trop grand pour AppSheet

Si vous envisagez de rester ou de migrer, ce sont les signaux que je recherche. Trois ou plus signifie que le coût du séjour dépasse déjà le coût de la migration.

1. La latence de synchronisation est le rythme de la journée de travail

AppSheet synchronise chaque modification avec le magasin de sauvegarde. Au fur et à mesure que le magasin grandit et le nombre d’utilisateurs simultanés augmente, les synchronisations s’étendent de « instantanées » à « attendre ». Quand le L’équipe commence à planifier ses modifications en fonction du temps de synchronisation, l’outil les combat.

2. Vous simulez des relations avec déréférencement

Les expressions [_thisrow]/déréférencement d’AppSheet simulent des relations au-dessus de draps plats. Ils fonctionnent jusqu’à ce qu’ils ne le fassent plus : lignes orphelines, références brisées après un changement de nom, des rapports qui renvoient discrètement les mauvaises lignes. Si vous avez écrit un texte de 15 lignes expression pour émuler un JOIN, la couche de base de données demande à être réelle.

3. La tarification par siège est l’élément de campagne que les gens remarquent

La tarification sans code par utilisateur est juste pour cinq utilisateurs et pénible pour vingt. Quand le La facture mensuelle est une conversation récurrente, vous avez heurté le mur économique.

4. Un autre système doit lire ou écrire vos donnéesLe moment où la facturation, un ERP, un entrepôt de données ou une automatisation doit intervenir

vos données CRM, l’absence d’API externe stable dans AppSheet devient le bloqueur. Vous êtes de retour au copier-coller ou à la fragile colle Apps Script.

5. Vous avez besoin d’une piste d’audit qu’AppSheet ne peut pas fournir

La conformité, la finance ou un litige client demande finalement “qui a changé cela et quand.” La « dernière modification par » d’une cellule n’est pas un journal d’audit.

Architecture

Une répartition nette : API Express + base de données relationnelle comme source de vérité, un React SPA pour l’équipe et un pont ETL unique qui a intégré les feuilles d’AppSheet dans tables relationnelles normalisées.

AppSheet vers Express + Architecture de migration de base de données

AppSheet (Google Sheets) ──ETL──▶  Relational DB (normalized)


                                  Express API (REST)

                          ┌─────────────┴─────────────┐
                          ▼                           ▼
                    React SPA               Billing / webhooks

Le modèle de migration sûre

C’est la procédure que j’utilise pour tout déplacement d’une feuille de calcul vers une base de données, spécialisée ici pour AppSheet avec basculement en direct en parallèle.

Étape 1 — Modélisez les données de manière relationnelle, et non à l’échelle 1:1 des feuilles

Les onglets plats d’AppSheet comportaient une colonne « client » dupliquée sur cinq feuilles. Le le premier travail consiste à normaliser et à remplir les clés étrangères. Tout en aval — la facturation, le reporting, la déduplication — cela dépend de cela.

CREATE TABLE customers (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name       text NOT NULL,
  email      citext UNIQUE NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE contacts (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id uuid NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  name        text NOT NULL,
  email       citext,
  phone       text
);

CREATE TABLE deals (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id uuid NOT NULL REFERENCES customers(id),
  stage        text NOT NULL CHECK (stage IN ('lead','qualified','won','lost')),
  amount_cents integer NOT NULL DEFAULT 0,
  closed_at    timestamptz,
  -- the ETL cursor anchor: every migrated row keeps its AppSheet "updatedAt"
  appsheet_row_updated_at timestamptz NOT NULL
);

CREATE INDEX deals_customer_idx ON deals(customer_id);

Une contrainte CHECK sur stage et une contrainte UNIQUE sur email font la différence entre des données propres et trois semaines de nettoyage avant de pouvoir faire confiance à un rapport.

Étape 2 — Validez avant de migrer

AppSheet exporte les nombres sous forme de texte, permet aux e-mails d’être mal formés et les rend orphelins références. Un passage Zod sur les lignes exportées fait apparaître des déchets avant qu’ils n’atteignent la base de données — dans ce projet, environ 3 % des lignes ont échoué à la validation et ont été routées vers une file d’attente de révision au lieu de la base de données.

const AppSheetDeal = z.object({
  DealID:   z.string().min(1),
  Customer: z.string().min(1),
  Stage:    z.enum(["lead", "qualified", "won", "lost"]),
  Amount:   z.string().regex(/^\d+(\.\d{1,2})?$/), // exported as text, not a number
  UpdatedAt: z.string(),
});

export type AppSheetDeal = z.infer<typeof AppSheetDeal>;

Les lignes qui échouent atterrissent dans une table migration_rejections avec la raison, donc rien est lâché silencieusement et rien de sale ne pénètre dans le système propre.

Étape 3 — ETL idempotent avec un curseur

Chaque ligne AppSheet comporte un updatedAt. L’importateur suit le dernier vu horodatage par table, donc réexécuter l’ETL ne fait jamais de double insertion et toujours rattrape. L’idempotence est ce qui rend l’exécution parallèle de l’étape 4 sûre.

async function importDealsSince(cursor: Date): Promise<number> {
  const rows = await appSheet.list("Deals", {
    // AppSheet filters client-side on the export; the cursor narrows the set
    filter: (row) => new Date(row.UpdatedAt) > cursor,
  });

  for (const row of rows) {
    const parsed = AppSheetDeal.parse(row);                 // Step 2 validation
    const customer = await upsertCustomerByName(parsed.Customer);

    await db
      .insert(deals)
      .values({
        customerId: customer.id,
        stage: parsed.Stage,
        amountCents: Math.round(parseFloat(parsed.Amount) * 100),
        appsheetRowUpdatedAt: new Date(parsed.UpdatedAt),
      })
      .onConflictDoUpdate({                                 // idempotent re-runs
        target: deals.id,
        set: {
          stage: parsed.Stage,
          amountCents: sql`excluded.amount_cents`,
          appsheetRowUpdatedAt: new Date(parsed.UpdatedAt),
        },
      });
  }

  return rows.length;
}

Étape 4 — Exécution parallèle avec réconciliation nocturne

Pendant deux semaines, les deux systèmes ont fonctionné en direct. Les écritures allaient toujours vers AppSheet (où l’équipe était confortable); l’ETL les a mis en miroir dans la base de données tous les soirs. Chaque matin un le travail automatisé a comparé les deux et a signalé une dérive.

-- Per-table count drift between the AppSheet snapshot and database
SELECT 'deals' AS table_name,
       a.row_count AS appsheet,
       d.row_count AS db_count,
       a.row_count - d.row_count AS drift
FROM appsheet_snapshot a
JOIN db_counts d USING (table_name);

Une correspondance de nombre est nécessaire mais pas suffisante, le travail a donc également exécuté une analyse au niveau des lignes. vérification de hachage sur un échantillon de 1 % pour détecter les modifications qui ont maintenu les comptes stables mais modifiés contenu. L’exécution parallèle a détecté trois bogues de cartographie qu’un script d’exécution à sec avait détecté. manqué - exactement son but.

Étape 5 — Transition, avec un miroir de lecture pour les récalcitrants

Lors du basculement, les écritures sont transférées vers la nouvelle application. AppSheet est resté en vie en tant que lecture seule miroir pendant deux semaines via une tâche d’exportation planifiée, afin que quiconque ne soit pas encore sur le nouveau le flux a gardé la visibilité sans produire de données divergentes. Ensuite, nous l’avons éteint.

Décisions intéressantes

Leçons apprises

Avantages et inconvénients

Avantages : propriété totale des données, API stable que d’autres services peuvent appeler, prévisible un coût forfaitaire, de réelles contraintes d’intégrité et un journal d’audit approprié.

Inconvénients : vous exploitez désormais une base de données : les sauvegardes, la surveillance et les migrations vous appartiennent. AppSheet a caché tout cela. Le commerce est une responsabilité opérationnelle de la propriété et la capacité, et c’est le bon métier une fois que vous avez heurté le mur au-dessus.

FAQ

Pouvons-nous conserver AppSheet pour la capture des données sur le terrain ? Oui. AppSheet reste un client léger performant. Après la migration, certaines équipes le conservent uniquement pour la capture mobile hors ligne, en écrivant sur la nouvelle API au lieu d’un support feuille. Ce que vous laissez derrière vous, c’est AppSheet en tant que système d’enregistrement, et non en tant qu’interface utilisateur.

Combien de temps prend une migration comme celle-ci ? Pour environ 50 000 lignes et une équipe comprise entre 8 et 20, attendez-vous à 3 à 6 semaines de temps écoulé, de dont environ la moitié est le nettoyage des données et la vérification en parallèle - pas codage. L’interface utilisateur est la partie rapide.

Et si nous ne sommes pas prêts à quitter AppSheet ? Exécutez la vérification des cinq signes ci-dessus. Si vous en voyez moins de trois, le coût du séjour est encore inférieur au coût de la migration, et c’est une raison légitime d’attendre. Migrez lorsque le mur est clairement devant vous, et non par précaution.

Allons-nous perdre des données ? Pas avec ce modèle. La validation achemine les lignes incorrectes vers une file d’attente de révision, l’ETL est idempotent, et l’exécution parallèle prouve la parité avant le basculement. « Sans perdre un single record” est la norme, pas l’espoir.

Vous voulez ceci pour votre application AppSheet ?

Si votre équipe se heurte au mur d’AppSheet : synchronisations lentes, pas d’API, coûts par siège escalade - je cartographie la sortie en un seul appel de découverte : quoi migrer, ce que le à quoi ressemble l’architecture cible et un calendrier réaliste. Vous conservez vos données et votre élan.Réserver un appel découverte →

Évaluateur interactif du risque et du ROI Google Sheets

Évaluez la santé de votre feuille et le temps hebdomadaire perdu estimé.

Score de risque du tableur80 / 100
Friction estimée~10 h/semaine perdues
0 · Sain40 · Modéré70 · Critique100
Courbe de seuil d’échelle et de latence
<25k Sûr 25-60k Ralentissement >60k Mur
0%40%70%100%025k50k75k100k50,000 rows · 80%
Volume de lignes50,000 / 100k
Concurrence8 / 25 éditeurs
Profondeur VLOOKUP15 / 40 formules
Risque critique — migration immédiate recommandéeLa latence et les écrasements silencieux coûtent déjà du temps à l’équipe.