@odoro-cli/cloud-connect
v0.1.0
Published
Connexion PostgreSQL a une base Odoro, cote serveur. Pool, requetes liees, erreurs distinguees.
Downloads
105
Readme
@odoro-cli/cloud-connect
Le paquet que votre application installe pour parler a sa base Odoro.
npm install @odoro-cli/cloud-connectimport { connecter, sql } from '@odoro-cli/cloud-connect'
const db = connecter({ connectionString: process.env.ODORO_DATABASE_URL! })
const { rows } = await db.query<{ id: string; nom: string }>(
sql`select id, nom from clients where pays = ${pays}`,
)C'est tout ce dont une application a besoin en regime normal : une chaine de connexion, et des requetes. Le reste de ce document explique d'ou vient cette chaine, et pourquoi elle ne se redemande pas.
Serveur uniquement. Ce n'est pas une recommandation.
Ce paquet ouvre une connexion PostgreSQL directe. Une chaine de connexion envoyee au navigateur est lisible par quiconque ouvre les outils de developpement, et elle ouvre la base entiere — toutes les tables, en lecture et en ecriture, sans passer par la moindre de vos regles.
Le paquet refuse donc de fonctionner dans un navigateur, de deux facons :
- votre empaqueteur, s'il vise le navigateur, resout la condition d'export
browservers un module qui leve a l'import : la construction echoue ; - a l'execution,
connecter()leve siwindowetdocumentsont tous les deux presents.
Et le paquet n'accepte aucun jeton de plateforme : il n'a aucun parametre qui pourrait en transporter un. Voir la section suivante pour ce que cela protege.
Exposez vos propres routes a votre client, et gardez la base derriere.
Le chemin complet, de zero a la premiere requete
1. Obtenir un jeton de plateforme
[!WARNING] Un jeton de plateforme porte des portees sur toute votre organisation :
databases:create,databases:destroy,billing:write. Qui le tient peut detruire votre production et changer votre plan payant.Il sert a gerer des bases — depuis votre poste, ou depuis un pipeline de deploiement. Il n'a rien a faire dans l'application qui sert vos clients, et ce paquet-ci ne le demande jamais. Ne le posez ni dans un navigateur, ni dans une image de conteneur, ni dans un depot.
Depuis le tableau de bord Odoro, ou par l'API :
curl -X POST https://api.odoro.dev/v1/tokens \
-H "authorization: Bearer $ODORO_SESSION" \
-H "content-type: application/json" \
-d '{
"projectId": "prj_…",
"label": "deploiement production",
"scopes": ["databases:read", "databases:write", "credentials:rotate"]
}'Les portees demandees sont intersectees avec les votres : un jeton ne peut pas porter plus que celui qui le demande.
2. Creer une base — ou en prendre une existante
Avec @odoro-cli/cloud-sdk, le client de gestion (un autre paquet que
celui-ci) :
import { createClient } from '@odoro-cli/cloud-sdk'
const api = createClient({ baseUrl: 'https://api.odoro.dev', token: process.env.ODORO_TOKEN! })
// Le provisionnement est asynchrone : `createAndWait` interroge jusqu'au bout.
const operation = await api.databases.createAndWait({
idempotencyKey: crypto.randomUUID(),
environmentId: 'env_…',
name: 'production',
region: 'eu-central-1',
})
const databaseId = operation.subjectOu, pour une base qui existe deja :
const { databases } = await api.databases.list({ environmentId: 'env_…' })3. Obtenir la chaine de connexion — une fois
const { credentialId, connectionString, issuedAt } = await api.credentials.rotate({
databaseId,
})[!IMPORTANT] Cette route est une rotation, pas une lecture.
Elle emet un identifiant neuf et invalide le precedent. La chaine est rendue une seule fois : aucune autre route ne la rend, et celle-ci ne la rend pas deux fois. Qui la perd la fait tourner — et fait du meme coup tomber toutes les instances qui tenaient l'ancienne.
Elle est par ailleurs plafonnee a cinq appels par minute.
4. Ranger la chaine la ou votre application la lira
Dans un coffre — Secrets Manager, Vault, les secrets de votre plateforme — ou, a defaut, dans une variable d'environnement :
ODORO_DATABASE_URL=postgres://…C'est le point de bascule entre les deux mondes. Les etapes 1 a 3 se font une fois, depuis un poste ou un pipeline, avec un jeton de plateforme. L'etape 5 se fait a chaque demarrage, dans votre application, sans aucun jeton.
5. Se connecter et interroger
import { connecter, sql, estConnectError } from '@odoro-cli/cloud-connect'
const db = connecter({
connectionString: process.env.ODORO_DATABASE_URL!,
mode: 'service',
applicationName: 'ma-boutique-api',
})
try {
const { rows } = await db.query<{ id: string; nom: string }>(
sql`select id, nom from clients where pays = ${pays} limit ${20}`,
)
console.log(rows)
} catch (cause) {
if (estConnectError(cause, 'INJOIGNABLE')) {
// Reessayable : la base se reveille peut-etre.
}
}
process.on('SIGTERM', () => void db.fermer())Pourquoi votre application ne doit pas appeler credentials.rotate
C'est la seule erreur de conception que ce paquet existe pour vous empecher de commettre. Aller chercher la chaine a chaque demarrage semble elegant — plus de secret a gerer — et coute trois choses :
- Chaque demarrage couperait les autres. La rotation invalide l'identifiant precedent. Vos six instances deja en service perdent leur connexion au moment ou la septieme demarre.
- Vous vous bloqueriez vous-meme. Cinq appels par minute : un deploiement roulant de six instances echoue sur sa propre limite de cadence.
- Vous transporteriez un jeton de plateforme dans votre application. Un
jeton qui porte
databases:destroyetbilling:writesur toute votre organisation, la ou vous n'aviez besoin que d'une base.
La chaine de connexion se fournit donc a l'application ; elle ne se recupere
pas. C'est pourquoi connecter() ne prend qu'une chaine, et pourquoi ce paquet
ne connait aucune URL du plan de controle.
Quand vous faites tourner un identifiant pour de bon
Une fuite, un depart, une rotation periodique. L'ordre qui n'interrompt rien :
api.credentials.rotate({ databaseId })depuis votre pipeline ;- ecrire la nouvelle chaine dans votre coffre ;
- faire adopter la nouvelle chaine a vos instances.
Pour la troisieme etape, un redemarrage marche et coute une interruption.
remplacerChaine ne coute rien :
// Le pool neuf sert avant que l'ancien ne soit vide : les requetes en cours
// finissent, les suivantes partent sur le nouvel identifiant.
await db.remplacerChaine(nouvelleChaine)Si la nouvelle chaine est invalide, rien ne change et l'ancienne connexion continue de servir.
Une erreur
AUTHENTIFICATIONne doit pas vous faire appelerrotate. C'est le reflexe naturel, et il transforme une instance en panne en parc entier en panne. Relisez d'abord la chaine courante dans votre coffre : quelqu'un d'autre a probablement deja fait tourner l'identifiant.
Les valeurs ne rentrent jamais dans le texte
import { sql, identifiant, joindre } from '@odoro-cli/cloud-connect'
const nom = "Robert'); drop table clients; --"
await db.query(sql`select * from clients where nom = ${nom}`)
// texte → 'select * from clients where nom = $1'
// valeurs → ["Robert'); drop table clients; --"]Chaque ${…} devient un emplacement lie. pg savait deja lier des valeurs ; ce
que le gabarit ajoute, c'est de rendre la forme sure plus courte a ecrire
que la concatenation — parce que c'est la facilite comparee des deux ecritures
qui produit les injections, pas l'ignorance.
Les fragments se composent, et les emplacements sont renumerotes :
const conditions = [sql`pays = ${pays}`]
if (actifsSeulement) conditions.push(sql`actif = ${true}`)
await db.query(sql`select * from clients where ${joindre(conditions, ' and ')}`)Un nom de table ou de colonne ne peut pas etre lie — PostgreSQL ne l'accepte pas en parametre. C'est le seul echappement, et il ne prend jamais une valeur :
await db.query(sql`select * from clients order by ${identifiant(colonne)} desc`)La forme native reste disponible quand vous la preferez :
await db.query('select * from clients where pays = $1', [pays])Les transactions tiennent sur une seule connexion
await db.transaction(async (tx) => {
const { rows } = await tx.query<{ id: string }>(
sql`insert into commandes (client_id) values (${clientId}) returning id`,
)
await tx.query(sql`insert into lignes (commande_id, article) values (${rows[0]!.id}, ${article})`)
})Une exception annule tout et relance. N'envoyez jamais begin et commit par
query : avec un pool, les deux instructions tombent sur des connexions
differentes, la transaction reste ouverte sur l'une et le commit echoue sur
l'autre — sans que rien ne le signale avant que les verrous ne s'accumulent.
Distinguer les pannes, parce que les gestes sont opposes
import { estConnectError } from '@odoro-cli/cloud-connect'
catch (cause) {
if (estConnectError(cause)) {
console.error(cause.genre, cause.details.cible) // jamais le mot de passe
if (cause.reessayable) return reessayer()
}
}| Genre | Ce qui s'est passe | Ce qu'il faut faire |
| --- | --- | --- |
| CHAINE_INVALIDE | La chaine ne peut pas designer une base | Corriger la configuration. Reessayer ne changera rien |
| AUTHENTIFICATION | La base a refuse l'identifiant | Relire la chaine courante dans le coffre. Ne pas faire tourner |
| INJOIGNABLE | Le serveur n'a pas repondu | Reessayer : une base endormie met quelques secondes a se reveiller |
| DELAI | Une limite de temps a ete atteinte | Regarder la requete et ses index avant de rallonger le delai |
| REQUETE | Le serveur a compris et refuse | Corriger la requete : syntaxe, contrainte, droit |
| FERMEE | La connexion est fermee | Ouvrir une nouvelle connexion ; un pool ferme ne se rouvre pas |
Une chaine invalide echoue des connecter(), au demarrage, plutot qu'a la
premiere requete d'un client. Aucun message d'erreur ne contient votre mot de
passe : une erreur finit dans un journal, un agregateur, parfois un ticket.
Fonctions serverless et services longs
const db = connecter({ connectionString, mode: 'ephemere' })| | service (defaut) | ephemere |
| --- | --- | --- |
| Connexions gardees | 10 | 1 |
| Rendues apres | 30 s | 1 s |
| Laisse le processus se terminer | non | oui |
Le mode ephemere est plus lent par requete, et c'est un prix qu'on paie sciemment. Ces politiques se multiplient par le nombre d'instances : dix connexions gardees par une fonction qui monte a cent instances simultanees, ce sont mille connexions demandees a une base qui en accepte cent — et la panne frappe au moment precis ou le trafic monte.
allowExitOnIdle compte autant : sans lui, une connexion inactive garde la
boucle d'evenements de Node ouverte et votre fonction ne rend jamais la main.
Une fonction ephemere ne peut presque jamais appeler fermer().
Le mode est explicite et non devine : renifler AWS_LAMBDA_FUNCTION_NAME ou
VERCEL produirait une liste fausse le mois suivant, et une detection ratee
choisirait silencieusement la politique qui fait tomber la base.
Reglages
| Option | Defaut | |
| --- | --- | --- |
| connectionString | — | La chaine rendue par la rotation |
| mode | 'service' | 'service' ou 'ephemere' |
| max | selon le mode | Plafond de connexions. Toujours 1 en mode ephemere |
| connectTimeoutMs | 10000 | Attente pour ouvrir. Large a dessein : une base endormie met quelques secondes a se reveiller |
| statementTimeoutMs | 30000 | Attente pour executer |
| applicationName | 'odoro-connect' | Visible dans pg_stat_activity. Vaut la peine d'etre pose : devant une connexion qui tient un verrou, c'est ce qui dit quel service redemarrer |
Les deux delais partent avec la poignee de main PostgreSQL, pas par un SET :
avec un pool, deux requetes successives ne tombent pas forcement sur la meme
connexion, et un SET envoye sur l'une ne protegerait pas l'autre.
Un idle_in_transaction_session_timeout de trente secondes est pose et n'est
pas reglable : un BEGIN dont le COMMIT n'arrive jamais garderait ses verrous
jusqu'a ce que la production s'arrete.
Ce que ce paquet ne fait pas
| | Pourquoi |
| --- | --- |
| Constructeur de requetes | Il faudrait decrire votre schema, donc suivre vos migrations, donc devenir un ORM. Autre metier, autre paquet |
| Migrations | Un outil de migration doit tenir un journal versionne, gerer les reprises et les reculs. Le glisser ici ferait un paquet qui fait deux choses a moitie |
| ORM | Voir les deux lignes ci-dessus, additionnees |
| Gestion des bases | Creer, detruire, facturer : c'est @odoro-cli/cloud-sdk, et cela demande un jeton de plateforme que ce paquet refuse de connaitre |
| Recuperation d'identifiants | La route est une rotation. Voir plus haut |
| Pilote PostgreSQL | pg est la reference, et le plan de controle Odoro l'emploie deja |
Les deux paquets Odoro, cote a cote
| | @odoro-cli/cloud-sdk | @odoro-cli/cloud-connect |
| --- | --- | --- |
| A quoi il sert | Gerer des bases | Employer une base |
| A qui il parle | Au plan de controle, en HTTP | A PostgreSQL, en direct |
| Ce qu'il exige | Un jeton de plateforme | Une chaine de connexion |
| Portee d'un secret perdu | Toute l'organisation | Cette base |
| Ou il vit | Poste, pipeline de deploiement | Votre application |
