Sources de données
Créez des sources de données déclaratives en mode poll et livrez-les dans une extension Scrydon — aucun code d'exécution requis
Cet artefact est livré dans un Extension. Pour le cycle de vie partagé — installation, extension build, téléversement — voir Extensions & SDK d'authoring.
Une Source de données déclarative est une source de table en mode poll encodée entièrement sous forme de configuration JSON sérialisable — une spécification de requête HTTP, un sélecteur itemsPath, une table de correspondance de champs et une liste de colonnes. Vous la créez une fois avec defineDataSource ; le runtime générique de poll de la plateforme l'exécute à chaque tick sans exécuter de code fourni par le client.
Une source interrogée, en flux ou push, pour une organisation peut aussi déclarer trackProjection pour transmettre des lignes complètes et validées à un flux ontologique correlated_tracks. Cette déclaration précise ontology, streamId, la révision immuable policyRevisionId, un marquage et les colonnes eventId, sourceEntityId, observedAt, latitude et longitude. Les colonnes facultatives sont commonIdentifier, heading, speed et quality. Chaque colonne référencée doit figurer dans table.columns. Une ligne dont latitude et longitude sont toutes deux nulles ne rapporte aucune position : elle est ignorée, jamais transmise et jamais un échec. Seule une table qui déclare ces deux colonnes facultatives peut contenir une telle ligne ; une ligne où une seule des deux manque fait échouer la projection. Une source à la demande, manuelle, de requête ou de projection qui déclare trackProjection est refusée à la validation du manifeste : une projection ne transmet rien de son cru, et une récupération à la demande ou un téléversement manuel ne sont pas des pistes en direct. Platform transmet les lignes d'une interrogation une fois écrites, dès que l'interrogation n'a eu aucune erreur de récupération ni d'écriture, même si d'autres lignes ont été rejetées, et celles d'un lot en flux ou push une fois le lot validé. Un échec de transmission n'annule jamais les lignes écrites. Une interrogation l'enregistre comme son propre échec. Un lot en flux ou push reste validé et ses positions ne sont pas renvoyées ; une source push signale l'échec par un avertissement de synchronisation, et une source en flux par un avertissement sur la synchronisation de la fenêtre de 30 secondes où le lot a été validé. L'identifiant de la source installée et l'environnement proviennent de la plateforme, jamais des lignes du fournisseur. L'ontologie doit déjà déclarer une politique privée correspondante qui accepte cette source. Sur le flux de pistes, la santé exige la configuration installée exacte et une interrogation, une fenêtre de flux ou un lot push complet. Une fenêtre de flux est complète lorsque sa connexion ne s'est ni terminée ni tue, que rien n'a été mis de côté, que la politique de données n'a écarté aucune ligne et qu'aucune position de piste n'a échoué. Un lot push est complet lorsque la politique de données n'a écarté aucune ligne et que chaque position de piste a été transmise ; sa preuve vaut deux périodes de flux, après quoi une source push qui n'a rien reçu redevient inconnue, et non dégradée. La fraîcheur d'une piste est distincte.
Pour une source en flux, la même projection alimente aussi un relevé de position en direct, au mieux, entre les lots, environ toutes les 250 ms, indépendamment de la validation du lot lui-même (écrit dans la table environ une fois par minute). La carte affiche ce qui a été reçu : une position transmise par ce relevé peut appartenir à un lot dont la validation est ensuite refusée, auquel cas la table ne la porte jamais même si elle a brièvement été affichée en direct.
Chaque position porte le marquage de la projection. Une ligne qui porte des marquages de ligne (une colonne markings non vide) n'est jamais transmise au flux de pistes : la table ne la montre qu'aux lecteurs qui détiennent ses marquages, et un flux de pistes ne peut pas les appliquer. L'avertissement de synchronisation d'une source interrogée ou push en indique le nombre, tout comme celui de la fenêtre d'une source en flux.
Pour une table AIS normalisée de l'exemple MIK, ajoutez ce bloc à une source dont id vaut fixture-ais et dont table.columns déclare ces noms :
trackProjection: {
ontology: "scrydon-pack-mik-north-sea-v2",
streamId: "north-sea-v3",
policyRevisionId: "mik-fixture-v3",
marking: { schemeId: "scrydon.default", rank: 0 },
columns: {
eventId: "event_id",
sourceEntityId: "mmsi",
observedAt: "observed_at",
latitude: "latitude",
longitude: "longitude",
},
}La correspondance HTTP doit fournir un event_id stable pour chaque observation ; réutiliser cet identifiant avec d'autres coordonnées est refusé. Le scénario synthétique de l'exemple MIK est un jeu d'essai, pas un flux HTTP configuré.
Une ligne porteuse de marquages de ligne (la colonne markings de la table) n'est jamais projetée, même quand la source est par ailleurs saine : un flux de piste régit ses lectures par un schéma et un rang uniquement, sans aucun slug propre, donc une ligne marquée publiée au marquage fixe de la projection atteindrait un lecteur à qui la table elle-même cache cette ligne. Ces lignes sont écartées et comptées dans l'avertissement de synchronisation de l'exécution d'interrogation ; une ligne non marquée (sans markings, ou avec un markings vide) est projetée exactement comme décrit ci-dessus.
Les sources de données livrées dans un Extension sont du JSON pur — l'archive contient la spécification de requête, la correspondance et les définitions de colonnes, jamais du code. Le runtime générique de poll (fetch → select → map → validate) réside dans la plateforme et est invoqué depuis le manifeste à chaque tick.
Quand utiliser ce SDK
Utilisez le SDK d'authoring de sources de données lorsque :
- Vous souhaitez extraire des lignes d'une API REST publique selon un calendrier et les rendre disponibles sous forme de table typée dans Scrydon.
- Vous avez besoin que la définition de source réside dans un Extension afin que le re-téléversement de l'extension rafraîchisse également la source (même contrat d'idempotence que les ontologies et les flux de processus).
- Vous construisez un Extension de démonstration ou de démarrage et souhaitez que les tables de données viennent précâblées sans configuration manuelle.
N'utilisez pas ce SDK si :
- Votre ingestion a besoin d'un état transmis d'une exécution à la suivante, ou de code. Une source déclarative envoie la même requête à chaque fois : elle n'a ni curseur de pagination ni curseur
since_id(les lectures incrémentales de l'API X en exigent un), ni logique conditionnelle, ni signature de requête personnalisée. Une extension ne peut pas livrer du code à la place — le bundler supprime une fonctionproduce(), voir ci-dessous. Pour un flux qui n'offre qu'un curseur, interrogez sa dernière page et laissezupsertsur l'id de l'élément absorber le chevauchement ; tout ce qui est publié plus vite qu'une page par intervalle est perdu. Voir Ce qu'une source déclarative ne sait pas encore faire. OAuth n'est pas une raison de recourir au code : la plateforme rafraîchit le jeton d'accès avant chaque tick (Connexions OAuth). - La destination n'est pas une table. Les chemins d'ingestion non tabulaires sont hors du périmètre de ce SDK aujourd'hui.
Contrainte clé : les extensions contiennent du JSON pur — jamais de fonctions. Le bundler supprime toutes les fonctions produce() lors de la sérialisation. Une source extensionageable doit donc être entièrement déclarative — chaque champ de la définition de source doit survivre à l'aller-retour JSON.parse(JSON.stringify(...)) intact.
Installation
bun add -d @scrydon/sdk-authoring zodnpm install --save-dev @scrydon/sdk-authoring zodimport { defineDataSource } from '@scrydon/sdk-authoring/extensions'Anatomie d'une source de données déclarative
Une source de données déclarative est composée de cinq blocs :
| Bloc | Champ(s) | Rôle |
|---|---|---|
request | url ou endpointRef + path, method, headers, query, authRef, auth, body | Décrit l'appel HTTP que le runtime effectue à chaque tick. url doit commencer par https:// ; pour que chaque connexion fournisse sa propre URL, désignez plutôt le champ d'endpoint de la connexion avec endpointRef et ajoutez path — voir Endpoints fournis par une connexion. Les identifiants sont référencés par authRef (un identifiant que l'extension déclare dans auth.credentials) — jamais inline. auth indique où cet identifiant est placé et body porte le payload d'un POST — voir Requêtes HTTP. |
response.itemsPath | itemsPath, itemsOptional | Un chemin point/crochet (par ex. $.ac, data.items, rss.channel.item) depuis le document de réponse décodé jusqu'aux entrées de lignes. Le $. initial est optionnel. Une clé qui contient un . s'écrit entre guillemets dans des crochets et se lit en entier : $['@odata.nextLink']. Pour une réponse CSV ou JSONL, les lignes décodées sont le document : utilisez itemsPath: '$' ; pour un flux RSS ou Atom, le chemin sélectionne les entrées qu'il contient — voir Formats de réponse. Un chemin qui ne résout rien fait échouer le tick : un chemin mal saisi ne passe donc jamais pour un flux calme. Lorsque le fournisseur omet la liste pour un résultat vide — GDELT répond {} à une requête sans correspondance, et une recherche RSS sans résultat a un <channel> sans <item> — définissez itemsOptional: true et cette réponse vaut zéro ligne. Un chemin qui aboutit à une valeur (un nombre, une chaîne) échoue dans tous les cas. |
mapping | Record<columnName, FieldMapping> | Associe chaque colonne de sortie à un champ source, en appliquant optionnellement l'une des dix transformations bornées du DSL de mapping. |
filter | requireNonNull | Supprime les lignes candidates avant le mapping si l'un des chemins de champ source listés est null ou undefined. |
table.columns | TableColumnDef[] | Déclare le schéma de sortie. Les noms et types de colonnes pilotent le validateur de lignes — aucun table.schema Zod séparé n'est nécessaire pour les sources déclaratives. |
Lors de l'exécution, le runtime résout l'hôte de la requête et l'épingle aux réponses DNS publiques validées. Il rejette les cibles loopback, privées, réservées, link-local, de métadonnées cloud, les réponses mixtes publiques/privées et les rebonds DNS ; les redirections HTTP ne sont pas suivies. La lecture de la réponse respecte la limite d'octets du manifeste même si le serveur omet Content-Length, et le délai d'expiration annule le flux actif.
Formats de réponse JSON, XML, CSV et JSONL
Une source poll ne se limite pas au JSON. Le runtime détermine comment lire le corps de la réponse à partir de trois signaux, dans cet ordre :
- Le
Content-Typede la réponse.text/csvetapplication/csv→ CSV.application/x-ndjsonetapplication/jsonl→ JSONL.application/json, ainsi que tout type à suffixe structuré commeapplication/geo+json, → JSON.application/xml,text/xml, ainsi que tout type à suffixe structuré+xml— ce qui fait fonctionnerapplication/rss+xmletapplication/atom+xmlsans configuration propre aux flux — → XML. - L'extension du chemin de l'URL de la requête —
.csv,.jsonlou.ndjson,.json,.xmlou.rssou.atom. - Le premier octet non blanc —
[ou{est lu comme du JSON,<comme du XML, tout le reste comme du CSV.
response.format vaut 'auto' par défaut, ce qui correspond exactement à la chaîne de signaux ci-dessus — une source qui sert déjà un Content-Type correct ne demande aucune configuration supplémentaire. Nommer un format ('csv', 'json', 'jsonl', 'xml') fixe le décodage et l'emporte sur tous les signaux, ce qui rattrape un fournisseur qui étiquette mal son propre payload. Voir Requêtes HTTP.
Une réponse qui déclare Content-Type: text/html est une page web, pas des données : en 'auto', le tick échoue avec unexpected content type text/html au lieu de la lire. Certaines API répondent ainsi à une erreur avec un statut 200 — GDELT envoie Timespan is too short. en text/html — et lue comme du CSV, cette phrase deviendrait un résultat vide que personne ne remarque. Si un fournisseur sert réellement ses données étiquetées HTML, nommez le format.
Quel que soit le format, le corps se décode en un document et itemsPath y sélectionne les entrées de lignes. Seul l'endroit où se trouvent ces entrées change.
Un corps CSV ou JSONL se décode en un tableau plat d'objets de lignes, et ce tableau est le document — il n'y a rien d'imbriqué à sélectionner à l'intérieur. Une telle source déclare donc itemsPath: '$' :
ingest: {
mode: 'poll',
intervalSec: 3600,
request: { url: 'https://example.org/exports/airfields.csv' },
response: { itemsPath: '$' },
mapping: {
icaoCode: { path: 'icao' },
name: { path: 'name', transform: 'trim_to_null' },
elevationFt: { path: 'elevation_ft', transform: 'number_or_null' },
},
}Pour le CSV, la première ligne est la ligne d'en-tête et ses cellules deviennent les noms de champs auxquels vos chemins de mapping se résolvent. Les champs entre guillemets peuvent contenir des virgules et des guillemets doublés (""). Chaque cellule arrive sous forme de chaîne — le CSV n'a ni null ni nombres — utilisez donc parse_number pour les colonnes numériques et l'une des transformations de date pour les horodatages. Notez qu'une cellule vide vaut '', ce que filter.requireNonNull ne supprime pas. Un champ entre guillemets ne peut pas s'étendre sur plusieurs lignes.
Pour le JSONL, chaque ligne non vide doit être un objet JSON ; les lignes vides sont ignorées, et une ligne malformée fait échouer le tick en indiquant son numéro de ligne.
Le Content-Type déclaré l'emporte sur l'extension de l'URL. Un serveur qui renvoie application/json pour un fichier nommé .csv est lu comme du JSON, et le tick échoue avec une erreur d'analyse nommant le format et l'URL — plutôt que de produire silencieusement des lignes erronées. L'erreur indique quel format a échoué, jamais les octets sur lesquels l'analyseur s'est arrêté, car un corps de réponse peut renvoyer un identifiant ou les données d'une personne. Les transformations au niveau de l'enveloppe telles que iso_from_epoch_offset n'ont pas d'enveloppe à lire sur une source CSV ou JSONL ; mappez plutôt une colonne d'horodatage par ligne.
Flux XML, RSS et Atom
'xml' est un seul format couvrant tous les dialectes XML. Il n'existe ni valeur 'rss' ni valeur 'atom', car l'analyse est identique dans les trois cas — la seule chose qui diffère est l'endroit où se trouvent les entrées, et itemsPath le dit déjà. Pour RSS 2.0 elles sont en rss.channel.item ; pour Atom en feed.entry.
Un flux d'actualités ressemble donc à n'importe quelle autre source à enveloppe imbriquée :
ingest: {
mode: 'poll',
intervalSec: 900,
request: { url: 'https://gcaptain.com/feed/' }, // servi en application/rss+xml
response: { itemsPath: 'rss.channel.item' }, // ← le chemin des entrées RSS
mapping: {
headline: { path: 'title' },
url: { path: 'link' },
externalId: { path: 'guid.#text' }, // <guid isPermaLink="false">…</guid>
publishedAt: { path: 'pubDate', transform: 'iso_from_rfc822' }, // ← RFC-822, le standard RSS
author: { path: 'dc:creator' }, // préfixe de namespace conservé tel quel
},
}La même source face à un flux Atom ne change qu'une seule ligne :
response: { itemsPath: 'feed.entry' },
mapping: {
headline: { path: 'title' },
externalId: { path: 'id' },
publishedAt: { path: 'updated' },
url: { path: 'link.@href' }, // <link rel="alternate" href="…"/>
},Trois propriétés du document décodé déterminent la façon d'écrire ces chemins de mapping :
- Un attribut devient
@nom.<link href="…"/>devient{ '@href': '…' }, donc le chemin estlink.@href. - Un élément portant des attributs et du texte devient
{ '@nom': …, '#text': … }, tandis qu'un élément nu est simplement sa chaîne. C'est pourquoiguidci-dessus s'écritguid.#text(il porteisPermaLink) alors quetitles'écrit simplementtitle. - Un préfixe de namespace est conservé exactement tel qu'écrit —
dc:creator,media:content. Il n'y a aucune résolution de namespace, et les déclarationsxmlnsarrivent comme des attributs ordinaires. - Un élément ne devient une liste que s'il se répète. Un item avec une seule
<category>est décodé en"Ports", un item avec trois en["Defense", "Ports", "Shipping"]. Une colonnearrayrefuse la chaîne seule : mappez un élément répétable avecas_array, qui produit toujours une liste :categories: { path: 'category', transform: 'as_array' }vers{ name: 'categories', dataType: 'array', nullable: true }.
Chaque feuille est une chaîne ; rien n'est converti. Utilisez parse_number pour les colonnes numériques, et une transformation de date pour les horodatages — mapper directement la chaîne de date d'un flux ne fonctionne pas pour une colonne timestamp, car le validateur de ligne exige un instant RFC 3339 (…Z ou …±hh:mm), ce qu'aucun format de flux ne produit (voir Dates issues des formats de flux).
Un flux ne contenant qu'une seule entrée produit malgré tout exactement une ligne. XML ne sait pas distinguer « un <item> » d'« une liste d'un élément » — la forme décodée s'effondre en un simple objet — c'est pourquoi le runtime normalise l'arité de la sélection après la résolution d'itemsPath. Cela compte parce que cet effondrement ne se produit que les jours où un flux est calme, c'est-à-dire précisément quand personne ne regarde la table.
XML est un format poll / on-demand uniquement. Un téléversement manuel de fichier n'a pas d'itemsPath pour sélectionner les entrées : l'assistant de téléversement refuse donc un fichier .xml au lieu d'en afficher un aperçu — pour un flux, écrivez plutôt une source déclarative. L'analyseur ne résout jamais de DTD ni d'entité externe, borne l'expansion d'entités, et lève une erreur plutôt que de récurser sans limite sur un document pathologiquement imbriqué — un flux non fiable ne peut donc pas monter une attaque XXE ou « billion laughs ». Un document malformé fait échouer le tick avec une erreur d'analyse nommant le format et l'URL. Un détail à connaître : une référence d'entité externe dans le contenu d'un élément subsiste sous forme de texte littéral &name; au lieu d'être résolue ou rejetée, un flux hostile peut donc placer cette chaîne dans une valeur de ligne.
Le DSL de mapping
Chaque entrée mapping copie soit un chemin directement, soit applique une transformation nommée. L'ensemble des transformations est borné et auditable — l'ajout d'une nouvelle transformation nécessite une modification de code dans la plateforme après revue, pas de données d'extension. Les expressions arbitraires, l'eval et le code en sandbox ne sont intentionnellement pas supportés.
| Transformation | Ce qu'elle fait | Exemple args |
|---|---|---|
trim_to_null | Supprime les espaces en début/fin ; transforme une chaîne vide ou non-chaîne en null. "RCH123 " → "RCH123", " " → null, undefined → null. | (aucun requis) |
number_or_null | Passe les nombres finis sans modification ; transforme les chaînes, NaN, Infinity et undefined en null. | (aucun requis) |
value_map | Associe des clés de chaîne littérales via un dictionnaire map ; passthrough: "number" passe les valeurs numériques sans modification ; tout le reste se replie sur default. | { map: { ground: 0 }, passthrough: "number", default: null } |
iso_from_epoch_offset | Combine un horodatage de base au niveau de l'enveloppe (basePath est résolu par rapport à la racine de la réponse) avec un décalage signé par ligne, et retourne une chaîne ISO 8601. Utile pour les API qui signalent une horloge now absolue plus des valeurs seen en secondes-passées par élément. | { basePath: "$.now", baseUnit: "ms", offsetUnit: "s", direction: "subtract" } |
parse_number | Convertit un texte numérique en nombre — celle à utiliser en CSV et en XML. Accepte les espaces alentour, un + initial et la notation exponentielle. Une cellule vide vaut null ; tout ce qu'elle ne peut pas convertir rejette la ligne au lieu d'y écrire un trou. | (aucun requis) |
iso_from_rfc822 | Convertit une date RFC-822 / RFC-1123 — le standard pubDate de RSS 2.0 — en chaîne ISO 8601 UTC. Gère la variante sans heure ainsi que GMT/UT/UTC/Z. | (aucun requis) |
iso_from_basic | Convertit un ISO au format de base (20260911T054500Z) ou une date calendaire nue (2026-09-15, 20260915) en chaîne ISO 8601 UTC. | (aucun requis) |
iso_from_go_time | Convertit le texte time.Time par défaut de Go — 2026-09-28 08:06:58.965887092 +0000 UTC, le time_utc d'aisstream.io — en chaîne RFC 3339 UTC, en conservant tous les chiffres de la fraction. | (aucun requis) |
iso_from_epoch | Convertit un epoch absolu — un nombre, ou un texte numérique — en chaîne ISO 8601 UTC. L'unité est obligatoire. | { unit: "ms" } ou { unit: "s" } |
as_array | Produit toujours une liste : enveloppe une valeur unique, laisse passer une liste, garde null une valeur absente. Pour un élément XML qui peut apparaître une ou plusieurs fois, comme le <category> de RSS, mappé vers une colonne array. | (aucun requis) |
parse_number, iso_from_rfc822, iso_from_basic, iso_from_go_time et iso_from_epoch convertissent du texte (parse_number et iso_from_epoch acceptent aussi un nombre) ; trim_to_null, number_or_null, value_map et iso_from_epoch_offset lisent des valeurs déjà typées ; as_array fixe la forme d'une valeur. La distinction texte / valeur typée est le point le plus important de cette page : elle a donc sa propre section.
parse_number vs number_or_null
Elles semblent interchangeables et ne le sont pas. number_or_null exige que la valeur soit déjà un nombre — elle teste typeof value === 'number' et met tout le reste à null. Un codec de lignes CSV ou XML produit une chaîne pour chaque cellule : sur ces formats, number_or_null retourne donc null pour chaque colonne numérique, une colonne nullable accepte chaque trou, et le tick signale un succès après avoir jeté les données.
Ce n'est pas hypothétique. La grille quotidienne d'interférences GPS (GPSJam, 48 461 lignes) a un jour été enregistrée avec toutes les colonnes numériques vides et un résultat au vert, parce que son manifeste indiquait number_or_null sur une source CSV. Un poll ne pourrait de toute façon pas prendre ce fichier aujourd'hui : une réponse qui produit plus de 10 000 lignes est refusée en entier, et l'URL du fichier change chaque jour. Il entre par une source manuelle, qui accepte jusqu'à 50 000 lignes — avec parse_number sur ses colonnes numériques.
| Format de la source | Colonne numérique | Pourquoi |
|---|---|---|
| JSON / JSONL | number_or_null | Le codec a déjà produit un nombre. |
| CSV | parse_number | Chaque cellule est une chaîne. |
| XML / RSS / Atom | parse_number | Chaque feuille est une chaîne. |
parse_number trace aussi une frontière que number_or_null ne trace pas :
- une cellule vide (
'', des espaces, un élément absent) est une véritable absence de valeur →null; - une valeur qu'elle ne peut pas convertir (
'n/a','1,420','12px',Infinity,NaN,0x10) fait échouer cette ligne. La ligne est comptée dans lerowsRejecteddu tick, la première est journalisée avec sa colonne et son code de validation (jamais la ligne ni sa valeur), et toutes les autres lignes du tick arrivent quand même.
La seconde règle est délibérée. Écrire silencieusement null pour une valeur que le flux a clairement envoyée est la façon dont un signal entier disparaît derrière un tick au vert ; perdre bruyamment une ligne est strictement préférable, et perdre le tick entier à cause d'une seule cellule serait strictement pire.
value_map porte la même hypothèse « JSON uniquement » dans son option passthrough: "number", qui ne se déclenche jamais en CSV ni en XML. Sur ces formats, listez les formes chaîne dans map (la recherche s'effectue sur la forme chaîne, donc "12000" correspond) ou utilisez passthrough: "string".
Dates issues des formats de flux
Une colonne date ou timestamp est validée comme un instant RFC 3339 — 2026-09-16T13:08:53Z ou 2026-09-16T13:08:53+02:00, avec des fractions de seconde facultatives — et stockée en UTC : une valeur avec décalage est convertie en sa forme Z (+02:00 → 11:08:53Z), en conservant ses chiffres fractionnaires, si bien qu'un flux qui écrit l'UTC +00:00 est stocké exactement comme un flux qui écrit Z. La plupart des autres formats de date qu'émet un vrai flux ne conviennent pas :
| Ce que le flux envoie | Valide pour une colonne timestamp ? | Transformation |
|---|---|---|
2026-09-16T13:08:53Z | oui | (aucune — mappez-la directement) |
2026-09-16T13:08:53+02:00 | oui — stocké comme 2026-09-16T11:08:53Z | (aucune — mappez-la directement) |
Wed, 16 Sep 2026 13:08:53 +0000 | non | iso_from_rfc822 |
Tue, 08 Sep 2026 | non | iso_from_rfc822 |
20260911T054500Z | non | iso_from_basic |
2026-09-15 | non | iso_from_basic |
2026-09-28 08:06:58.965887092 +0000 UTC | non | iso_from_go_time |
1789671802705 (epoch en millisecondes) | non | iso_from_epoch avec { unit: "ms" } |
1789671802 ou 1789671802.705 (epoch en secondes) | non | iso_from_epoch avec { unit: "s" } |
iso_from_rfc822 et iso_from_basic retournent minuit UTC pour une date sans heure, et lisent comme UTC une date-heure sans indicateur de fuseau. Toutes les transformations de date refusent plutôt que de deviner — un refus rejette la ligne, exactement comme parse_number : vous n'obtenez donc jamais un horodatage silencieusement décalé de plusieurs heures.
iso_from_go_time lit la disposition qu'imprime le time.Time de Go : le décalage numérique (+0000, -0500) est obligatoire et c'est le seul fuseau lu ; l'abréviation qui le suit (UTC, CEST) doit être présente mais n'est jamais interprétée. Go supprime les zéros finaux de la fraction : de 0 à 9 chiffres sont donc acceptés, et conservés tels quels. Une lecture d'horloge monotone finale ( m=+0.000123456) est ignorée. Une valeur sans décalage, sans abréviation, ou avec plus de neuf chiffres de fraction est refusée.
iso_from_epoch n'a pas d'unité par défaut, car 1789671802 lu en millisecondes est une date de janvier 1970 que toute colonne accepte. Les millisecondes doivent être un nombre entier — une fraction y trahit presque toujours des secondes déclarées en millisecondes — et les secondes peuvent porter une fraction, dont les chiffres au-delà de la milliseconde sont ignorés. Un instant hors des années 0000–9999, là où atterrissent des millisecondes déclarées en secondes, est refusé.
Ce qu'elles n'analysent délibérément pas :
- les années à deux chiffres (
16 Sep 26) — RFC-822 les autorisait ; le siècle serait une supposition ; - les abréviations de fuseau obsolètes (
EST,PDT,CEST) — ambiguës en pratique, et la RFC 2822 §4.3 demande de les traiter comme inconnues.GMT,UT,UTCetZsont acceptés ; - les noms de mois non anglais ou en toutes lettres (
septembre,September) — trois lettres anglaises uniquement ; - les dates impossibles (
31 Sep) et les heures hors plage, qu'un analyseur naïf bascule silencieusement sur le mois suivant ; - les dates ISO par semaine ou ordinales (
2026-W37-5,2026-254) ; - la suite de 14 chiffres qu'émettent certains flux (
20260911054500) — ce n'est pas de l'ISO 8601, elle est donc refusée plutôt que devinée.
Le nom du jour dans une date RFC-822 n'est pas vérifié par rapport à la date. La RFC 2822 indique qu'il ne fait pas foi et de nombreux flux se trompent ; rejeter sur ce critère supprimerait de vraies lignes au nom d'une redondance.
Le décalage doit s'écrire ±hh:mm. +0200, +02, un z minuscule et une date-heure étendue sans fuseau (2026-09-16T13:08:53) sont refusés plutôt que devinés, et aucune transformation ne les convertit aujourd'hui.
Le cas difficile de iso_from_epoch_offset
Certaines API REST — la famille ADS-B en est l'exemple canonique — ne retournent pas d'horodatages absolus par ligne. L'enveloppe porte un seul champ now (millisecondes epoch) et chaque ligne porte un champ seen (secondes passées). La transformation iso_from_epoch_offset établit le pont entre les deux :
seenAt = new Date(envelope.now - row.seen * 1000).toISOString()Configurez-la comme suit :
seenAt: {
path: "seen", // per-row offset field
transform: "iso_from_epoch_offset",
args: {
basePath: "$.now", // envelope field — resolved against the response root
baseUnit: "ms", // $.now is epoch milliseconds
offsetUnit: "s", // row.seen is seconds
direction: "subtract", // now − seen → absolute time
},
},Si row.seen est absent ou non numérique, le runtime prend par défaut un décalage de 0 (heure de base exacte), conformément à la convention de la source de code seenSecondsAgo = typeof raw.seen === "number" ? raw.seen : 0.
Un exemple complet
Ce qui suit est la vraie source adsb-lol-military-declarative livrée dans @scrydon/sdk-authoring. Elle extrait les positions d'aéronefs militaires depuis https://api.adsb.lol/v2/mil et les associe à une table typée aircraft_position. Le test de parité golden dans le monorepo affirme que cela produit des lignes identiques octet par octet à la source de code équivalente.
import { defineDataSource } from '@scrydon/sdk-authoring/extensions'
export const adsbLolMilitaryDeclarative = defineDataSource({
kind: "table",
id: "adsb-lol-military-declarative",
vendor: "adsb-lol",
displayName: "ADS-B Lol — Military Aircraft (declarative)",
scope: "global",
table: {
name: "aircraft_position",
primaryKey: ["icao24", "seenAt"],
timestampColumn: "seenAt",
columns: [
{ name: "icao24", dataType: "string", isPrimaryKey: true },
{ name: "callsign", dataType: "string", nullable: true },
{ name: "registration", dataType: "string", nullable: true },
{ name: "aircraftType", dataType: "string", nullable: true },
{ name: "category", dataType: "string", nullable: true },
{ name: "latitude", dataType: "decimal" },
{ name: "longitude", dataType: "decimal" },
{ name: "altitudeFeet", dataType: "int", nullable: true },
{ name: "groundSpeedKnots", dataType: "double", nullable: true },
{ name: "heading", dataType: "double", nullable: true },
{ name: "seenAt", dataType: "timestamp", isPrimaryKey: true },
],
},
ingest: {
mode: "poll",
intervalSec: 60,
minIntervalSec: 30,
request: {
url: "https://api.adsb.lol/v2/mil",
method: "GET",
headers: { accept: "application/json" },
},
response: { itemsPath: "$.ac" },
filter: {
// Drop rows missing hex (ICAO), lat, or lon — mirrors the code source guard.
requireNonNull: ["hex", "lat", "lon"],
},
mapping: {
icao24: { path: "hex" },
// "RCH123 " → "RCH123", " " → null. Mirrors raw.flight?.trim() || null
callsign: { path: "flight", transform: "trim_to_null" },
// undefined → null. Mirrors raw.r ?? null
registration: { path: "r" },
// undefined → null. Mirrors raw.t ?? null
aircraftType: { path: "t" },
// undefined → null. Mirrors raw.category ?? null
category: { path: "category" },
latitude: { path: "lat" },
longitude: { path: "lon" },
// "ground" → 0, number → passthrough, else → null. Mirrors parseAltitude()
altitudeFeet: {
path: "alt_baro",
transform: "value_map",
args: { map: { ground: 0 }, passthrough: "number", default: null },
},
// finite number → itself, string/NaN/undefined → null
groundSpeedKnots: { path: "gs", transform: "number_or_null" },
heading: { path: "track", transform: "number_or_null" },
// new Date(envelope.now - row.seen * 1000).toISOString()
seenAt: {
path: "seen",
transform: "iso_from_epoch_offset",
args: {
basePath: "$.now",
baseUnit: "ms",
offsetUnit: "s",
direction: "subtract",
},
},
},
},
})defineDataSource est une fonction d'identité à l'exécution — elle valide le manifeste via le schéma Zod DataSourceManifestSchema et dérive un validateur de lignes depuis table.columns. La définition émise est de la pure donnée : pas de fonction produce(), pas de fermetures, sérialisable tel quel dans data-source-<id>/manifest.json.
Clés de table
Une colonne de clé est toute colonne que table.primaryKey nomme ou que isPrimaryKey: true marque. Gardez les deux cohérents, comme dans l'exemple ci-dessus. La plateforme crée la table avec ces colonnes comme clé primaire, et une clé de table ne peut utiliser que ces types :
| Peut être une colonne de clé | Ne peut pas être une colonne de clé |
|---|---|
string, int, bigint, boolean, date, timestamp, geo_point | decimal, float, double, json, array, binary, vector |
Un manifeste dont une colonne de clé est du second groupe est refusé avant toute création : par defineDataSource(), extension build, extension validate, l'inspecteur de téléversement et l'installation. Le message nomme la colonne et indique quoi faire (il reste en anglais) :
data-source-observations/manifest.json validation failed at "table.columns.1.dataType":
Key column "latitude" has dataType "double", which a table key cannot use. Key columns must be
one of: string, int, bigint, boolean, date, timestamp, geo_point. Use a string or integer key,
or keep "latitude" as a normal column: remove it from table.primaryKey and drop its
isPrimaryKey flag.Une mesure comme une coordonnée fait de toute façon rarement une bonne identité : deux relevés d'une même chose concordent rarement au dernier chiffre près. Utilisez comme clé ce qui identifie la ligne, par exemple un identifiant du fournisseur ou une heure d'observation, et gardez latitude et longitude comme colonnes normales double ou decimal. Si l'API ne fournit aucun identifiant, construisez-en un dans une colonne string.
La colonne de marquages
markings est la colonne sur laquelle chaque lecture gouvernée filtre les lignes : les identifiants de marquage qu'un lecteur doit détenir pour voir la ligne. Vous n'avez pas à la déclarer — la plateforme en ajoute une vide à chaque table qu'une source de données écrit. Ne la déclarez que si votre source marque elle-même ses lignes, et déclarez-la alors comme { name: "markings", dataType: "array", nullable: true }. Tout autre type est refusé, par les mêmes contrôles qu'une colonne de clé, car une lecture gouvernée ne pourrait pas filtrer dessus et chaque lecture de la table par une ontologie échouerait. Le nom est comparé sans tenir compte de la casse : Markings est la même colonne.
data-source-observations/manifest.json validation failed at "table.columns.2.dataType":
Column "markings" is the row-markings column every governed read filters on, so its dataType
must be "array" (the marking slugs a reader must hold to see the row), not "string". Declare it
as { dataType: "array" }, or give the column another name.Si l'API renvoie un champ nommé markings qui n'est pas une liste d'identifiants de marquage, associez-le à une colonne d'un autre nom.
Modes d'écriture
Projections de tables stockées
Une source de projection combine selon un calendrier des colonnes de tables gérées existantes. Elle utilise mode: "poll", un intervalSec positif, un minIntervalSec facultatif et le writeMode: "replace" obligatoire. API Table exécute cette variante ; le runtime de poll HTTP des extensions ne le fait pas. Une projection est une union bornée de 1 à 32 entrées, avec des identifiants de source distincts et des clés d'espace de noms littérales :
ingest: {
mode: "poll", intervalSec: 300, writeMode: "replace",
projection: {
inputKeyColumn: "provider",
inputs: [{
sourceId: "tfl-jamcam", key: "tfl",
mapping: { cameraId: "id", region: null },
}],
},
}La colonne d'espace de noms doit être une chaîne non nullable figurant dans table.primaryKey, avec au moins une colonne d'identité mappée. Définissez isPrimaryKey: true sur exactement les colonnes listées dans table.primaryKey ; l'hôte utilise ces indicateurs pour provisionner la table. Chaque entrée doit mapper toutes les autres colonnes de sortie appartenant à l'auteur vers le nom exact d'une colonne source stockée, ou explicitement vers null, uniquement pour une colonne de sortie nullable hors clé. Les identifiants de source sont en kebab-case ; les noms de colonnes sont des identifiants SQL sûrs autres que __proto__, constructor et prototype, et doivent rester uniques sans tenir compte de la casse (l'entrepôt traite region et Region comme la même colonne). Chemins, transformations, SQL, identifiants inline et constantes arbitraires ne sont pas acceptés.
Certaines colonnes cibles appartiennent à l'hôte, pas à vous. Déclarez chacune exactement comme indiqué, avec un nullable: false explicite (un indicateur omis provisionnerait une colonne nullable), hors de table.primaryKey ; elles ne peuvent pas être mappées :
| Colonne | Type | Écrite par l'hôte comme |
|---|---|---|
sourceId | string | l'identifiant de source de l'entrée |
sourceTableId | string | l'identifiant de la table gérée de l'entrée |
sourceSnapshotId | string | le snapshot d'entrée dont la ligne a été lue |
sourceSyncedAt | timestamp | l'heure de commit de ce snapshot ; table.timestampColumn doit être sourceSyncedAt |
markings | array | les marquages de la ligne d'entrée dont elle a été projetée (un tableau vide lorsque cette entrée n'a pas de colonne markings) |
markings est la colonne de marquage de gouvernance que lit l'ontologie : c'est ce qui permet de lier une sortie de projection comme source d'ontologie. Chaque ligne de sortie est écrite avec les marquages de la ligne d'entrée unique dont elle a été projetée ; une ligne d'entrée marquée reste donc marquée dans la sortie et n'est lisible que par exactement les personnes qui peuvent lire la ligne d'entrée. Une entrée sans colonne markings produit des lignes non marquées, écrites comme un tableau vide. Pour changer qui peut lire une ligne de sortie, marquez la ligne d'entrée : le rafraîchissement suivant réécrit à partir d'elle les marquages de la ligne de sortie. classificationRank ne peut pas être déclarée du tout. La colonne markings ou classificationRank d'une entrée ne peut pas non plus servir de source de mapping : cela transformerait les métadonnées d'accès de l'entrée en données ordinaires de la sortie.
Une entrée est non saine pour un rafraîchissement dans l'un de ces deux cas :
- Son dernier poll n'est pas une réponse actuelle et complète. L'entrée ne s'est jamais synchronisée, son dernier poll a échoué, un poll a échoué depuis, ou sa dernière synchronisation réussie date de plus de deux fois son
intervalSec(et de plus d'une minute). - Sa table actuelle contient une ligne que la sortie ne peut pas contenir. La clé de la ligne est vide ou ne peut pas être lue exactement (par exemple un entier au-delà de la plage exacte). Une valeur ne peut pas être convertie exactement dans le type de sa colonne de sortie, comme un horodatage pour une date qui n'existe pas. L'identité de sortie de la ligne est vide ou répète une autre ligne de la même entrée. Ou bien la validation des lignes de la sortie la refuse. Une seule ligne de ce genre rend toute l'entrée non saine : publier l'entrée sans cette ligne ferait disparaître de la sortie l'entité qu'elle décrit.
Le rafraîchissement n'attend pas une entrée non saine. Les autres entrées se rafraîchissent normalement. L'entrée non saine est relue au dernier snapshot de sa propre table que la sortie a enregistré, et mappée avec le mapping actuel. Ses lignes gardent le sourceSnapshotId et le sourceSyncedAt de ce snapshot, si bien que sourceSyncedAt indique leur âge. Ces lignes passent les mêmes contrôles qu'une lecture fraîche : si l'une d'elles est refusée elle aussi, le rafraîchissement échoue. Les valeurs stockées dans la table de sortie elle-même ne sont jamais republiées. Le snapshot relu est déterminé par le sourceSnapshotId de la table de sortie. Quiconque est autorisé à écrire dans la table de sortie peut donc empêcher que l'entrée soit reportée, ou choisir lequel des snapshots que sa table conserve encore est relu. Les lignes restent alors les propres lignes de l'entrée, estampillées de leur snapshot et de leur heure réels. Il n'y a pas d'expiration : l'entrée reste reportée, et l'âge qu'indique son sourceSyncedAt continue de croître, jusqu'à ce qu'elle redevienne saine. Chaque rafraîchissement qui reporte une entrée est journalisé comme un avertissement qui nomme l'entrée et la raison. Lorsque la raison est une ligne refusée, seul cet avertissement l'indique : le statut de synchronisation de l'entrée affiche toujours son poll réussi.
Une entrée non saine n'est jamais publiée sous forme de zéro ligne. S'il n'existe aucun snapshot enregistré auquel la relire, le rafraîchissement échoue et la sortie précédente reste. C'est le cas au premier rafraîchissement après l'installation, après un changement de la key de l'entrée ou la recréation de sa table, lorsque la dernière contribution publiée de l'entrée était vide (une contribution vide n'enregistre aucun snapshot), lorsque ses lignes dans la sortie désignent plus d'un snapshot ou un identifiant de snapshot illisible, lorsque ce snapshot a expiré de la table d'entrée, et lorsque ce snapshot ne contient plus aucune ligne. Le rafraîchissement échoue alors avec la raison de l'entrée : projection_input_unhealthy pour un poll, ou le code du contrôle que sa ligne n'a pas passé. Le rafraîchissement échoue aussi, en laissant la sortie inchangée, lorsqu'aucune entrée ne peut être lue fraîchement. Il échoue alors avec la raison de la première entrée non saine, dans l'ordre de déclaration des entrées. Une entrée dont l'accès ou le schéma a changé fait échouer le rafraîchissement, qu'elle soit saine ou non. De même pour l'épuisement de tout budget de rafraîchissement. Les pages et octets d'une lecture refusée comptent quand même dans les budgets, car ils ont été lus.
Choisir le compte sous lequel s'exécute une projection
Un rafraîchissement de projection s'exécute sous un compte de service qu'un administrateur de l'organisation sélectionne pour cette projection, séparément dans chaque environnement. Aucune identité de plateforme n'est utilisée à la place : tant qu'aucun compte n'est sélectionné, chaque rafraîchissement échoue avec projection_execution_account_missing et la sortie n'est pas publiée. Un administrateur ne peut sélectionner qu'un compte couvert par sa propre habilitation. L'enregistrement d'un compte habilité au-dessus de lui est refusé. Chaque rafraîchissement le vérifie à nouveau pour l'administrateur qui a enregistré la sélection en dernier. Si son habilitation ne couvre plus le compte, le rafraîchissement échoue avec projection_execution_account_not_accredited jusqu'à ce qu'un administrateur suffisamment habilité enregistre de nouveau la sélection. Ces échecs comptent pour la mise en pause automatique. La personne qui a ajouté la projection, au nom de laquelle elle s'exécute, doit elle aussi pouvoir détenir le compte : sinon, la projection lirait pour elle des données qu'elle ne peut pas lire elle-même. Si son habilitation ne couvre pas le compte, la projection s'arrête avec runs_as_clearance_lost, comme les autres arrêts liés à l'identité d'exécution. Un administrateur de l'organisation relève son habilitation ou sélectionne un compte qu'elle peut détenir, puis la reprend.
Ouvrez Paramètres → Organisation → Membres, puis l'onglet Comptes de service. Les administrateurs voient Compte d'exécution des projections au-dessus de la liste.
Choisissez l'environnement et la Source de projection. Choisissez un compte, puis enregistrez. La liste ne propose que les comptes actifs, autorisés à agir dans cet environnement et disposant d'un accès en écriture à Tables (l'écriture inclut la lecture). L'enregistrement ne modifie que la sélection de cette projection dans cet environnement.
Donnez au compte l'accès aux données qu'il lit et écrit. Il doit être autorisé à écrire la table de sortie. Chaque colonne d'entrée que la projection mappe, ainsi que les colonnes de clé de chaque entrée, doivent lui être entièrement lisibles. Une colonne que le compte voit masquée, cachée ou refusée fait échouer le rafraîchissement au lieu d'être ignorée. Le sélecteur ne peut pas vérifier l'accès aux colonnes à l'avance.
Sélectionner un compte ne lui accorde par lui-même aucun accès aux tables. La plateforme émet un jeton de courte durée pour le compte à chaque rafraîchissement : vous ne lui remettez jamais de secret client. Voir Comptes de service pour les paramètres d'espace de travail, d'environnement et de permissions.
Les colonnes de sortie mappées peuvent être de type string, int, bigint, decimal, float, double, boolean, date, timestamp ou geo_point. Les colonnes json, array, binary et vector sont refusées à la validation du manifeste, car une projection ne peut pas les copier depuis une entrée sans les modifier. Chaque colonne mappée doit avoir le même type que sa colonne source, à deux exceptions près : une source int peut alimenter une colonne string, et une source string contenant un horodatage ISO 8601 peut alimenter une colonne timestamp. Les valeurs bigint sont copiées exactement, y compris au-delà de la plage des entiers sûrs de JavaScript.
La table de sortie a sa propre politique d'accès, qu'un administrateur gère explicitement. Modifier la politique d'une entrée affecte les lectures des rafraîchissements futurs ; cela ne révoque pas automatiquement l'accès à la sortie déjà matérialisée et ne réécrit pas la politique de cette sortie.
Lorsque la table de sortie est liée à un type d'objet de l'ontologie, l'accès à ces objets est accordé par l'ontologie : un analyste autorisé à lire le type d'objet n'a pas besoin en plus d'un droit sur la table. Une restriction explicite qu'un administrateur place sur la table de sortie s'applique bien aux lectures ontologiques et de graphe de cet analyste. Une politique deny sur toute la table ou sur une colonne qui correspond à l'analyste ne renvoie aucune ligne de cette table. Une politique hide ou mask cache ou masque la propriété correspondante.
Chaque tick produit un lot de lignes ; ingest.writeMode contrôle la façon dont ce lot atterrit dans la table. C'est uniquement à l'installation — il provisionne le modèle de clé StarRocks lors de la création de la table et ne peut pas être modifié par la suite (le changer nécessite une réinstallation de l'extension avec un manifeste mis à jour). La valeur par défaut est upsert.
Pour replace, une erreur du producteur, une ligne rejetée, des nombres de lignes incohérents ou un poll HTTP qui dépasse la limite de lignes du runtime empêchent la publication. Le contenu précédent de la table reste disponible et le tick enregistre une synchronisation échouée. Atteindre exactement la limite est autorisé ; les lignes délibérément exclues par filter.requireNonNull n'y comptent pas. Ce contrôle d'exhaustivité ne modifie pas le comportement existant de lot partiel des sources append ou upsert.
Un poll replace qui renvoie zéro ligne n'écrit rien : le snapshot précédent reste. Sans erreur du producteur, le tick enregistre une synchronisation réussie ; avec une erreur, une synchronisation échouée. Les polls append et upsert vides n'écrivent rien non plus.
writeMode | Clé de déduplication | Lignes conservées par entité | À utiliser pour |
|---|---|---|---|
upsert (par défaut) | table.primaryKey (identité) | 1 — la plus récente uniquement | Flux « état actuel ». Chaque poll écrase la ligne de l'entité en place ; pas d'historique. |
changed-only | primaryKey + un hash de contenu synthétisé | N — une par état distinct | Données à évolution lente où vous souhaitez un historique mais pas une ligne par poll. Les polls identiques consécutifs se replient sur la même clé ; une ligne n'est écrite que lorsqu'une valeur change réellement. |
append | aucune | chaque poll | Flux d'événements bruts où chaque observation compte, y compris les répétitions exactes. |
replace | primaryKey | dernier instantané complet | Petites tables de référence republikes en entier à chaque tick (truncate-then-load). |
changed-only vs upsert. Les deux effectuent un upsert en interne, mais ils dédupliquent sur des clés différentes. upsert conserve une ligne toujours courante par entité (pas d'historique). changed-only conserve une ligne par état distinct que l'entité a traversé, en sautant les polls où rien n'a changé. Pour la télémétrie en évolution continue (par ex. la position en direct d'un aéronef, où la latitude/longitude change à chaque poll), changed-only se comporte presque comme append — le hash de contenu diffère à chaque tick. Là, le levier de croissance est la rétention, pas le mode d'écriture.
ingest: {
mode: "poll",
intervalSec: 60,
writeMode: "changed-only", // omit for the "upsert" default
// ...request, response, mapping
}Mettre à niveau les colonnes d'une source
Publier une nouvelle version d'extension qui ajoute des colonnes à
table.columns fonctionne directement : lors de la première synchronisation
après la mise à niveau, la plateforme compare vos colonnes déclarées à la table
existante et ajoute les colonnes manquantes en place — avec le type, la
classification et la description déclarés, sans réinstallation et sans perte de
données. Les organisations qui ont mis à niveau avant de synchroniser se
réparent automatiquement à leur prochaine synchronisation planifiée.
Deux changements ne sont pas appliqués en place, car l'entrepôt ne peut pas les modifier sur une table active :
- Changer le type d'une colonne. La colonne existante conserve son type ; déclarez plutôt une nouvelle colonne.
- Ajouter ou modifier une colonne
primaryKey. Le modèle de clé est figé à la création (même règle quewriteMode). Le journal de synchronisation enregistre la colonne clé ignorée ; choisissez un nouveautable.name(qui provisionne une table neuve) si l'identité doit vraiment changer.
Les colonnes que vous retirez de la déclaration restent en place dans l'entrepôt — elles cessent simplement de recevoir des valeurs.
Après une mise à niveau, vous pouvez tout rafraîchir d'un coup : la vue Analytics → Sources de données propose une action Tout resynchroniser dans la barre d'outils, qui relance la synchronisation de chaque source installée (quelques-unes à la fois), en plus du Synchroniser maintenant par ligne. Les statuts, heures de dernière synchronisation et compteurs de lignes se mettent à jour dans la table au fil des synchronisations.
Données de référence statiques (lignes inline)
Toutes les tables ne proviennent pas d'un flux HTTP. Les données de référence — registres de bases, catalogues de types, flottes, télémétrie synthétique organisée — peuvent être livrées dans le manifeste sous forme de lignes inline. Remplacez le trio request/response/mapping par un tableau rows ; tout le reste (planificateur, modes d'écriture, rétention, flux d'installation) est identique, car le même pilote de sondage exécute la source et réémet simplement les lignes à chaque tick. La table sous-jacente est ainsi idempotente et auto-réparatrice : supprimez une ligne dans l'entrepôt et le tick suivant la restaure. Aucune sortie réseau, aucun identifiant, aucune surface SSRF.
import { defineDataSource } from "@scrydon/sdk-authoring/extensions";
export default defineDataSource({
kind: "table",
id: "nato-bases",
vendor: "scrydon",
displayName: "NATO Air Bases (Eastern Flank)",
scope: "org",
table: {
name: "nato_base",
primaryKey: ["baseId"],
timestampColumn: "updatedAt",
columns: [
{ name: "baseId", dataType: "string", isPrimaryKey: true },
{ name: "name", dataType: "string" },
{ name: "country", dataType: "string" },
{ name: "latitude", dataType: "decimal" },
{ name: "longitude", dataType: "decimal" },
{ name: "updatedAt", dataType: "timestamp" },
],
},
ingest: {
mode: "poll",
intervalSec: 3600, // reference data — an hourly re-assert is plenty
rows: [
{ baseId: "EYSA", name: "Šiauliai Air Base", country: "Lithuania", latitude: 55.894, longitude: 23.395 },
{ baseId: "EPPW", name: "Powidz Air Base", country: "Poland", latitude: 52.379, longitude: 17.854 },
// ... up to 10,000 rows
],
stampColumn: "updatedAt", // stamped with the tick time on every emit
writeMode: "upsert",
},
});La variante statique est reconnue par la présence de rows (la variante HTTP par request) ; un manifeste ne peut pas mélanger les deux. Notes de champs :
| Champ | Notes |
|---|---|
rows | 1 à 10 000 lignes inline. Les valeurs sont uniquement des scalaires (string, number, boolean, null) pour que le manifeste reste entièrement sérialisable. |
stampColumn | Optionnel. Estampillé avec l'heure du tick (ISO 8601) sur chaque ligne émise — permet aux lignes de référence de satisfaire table.timestampColumn sans figer des horodatages littéraux dans le manifeste. |
writeMode | Les quatre mêmes modes que la variante HTTP. upsert (défaut) réaffirme l'ensemble de référence en place ; replace est l'alternative truncate-then-load pour les instantanés complets. |
Les lignes calculées restent des lignes statiques. Comme le manifeste est construit par defineDataSource() au moment du build de l'extension, le tableau rows peut être généré par du TypeScript ordinaire — dériver une colonne nearestBase depuis une table de bases, simuler un instantané de télémétrie, sous-échantillonner un CSV. Ce qui est livré dans l'archive est le résultat figé ; la plateforme n'exécute jamais le code de l'extension au moment du tick.
Rétention
Pour les tables de séries temporelles qui croissent continuellement, déclarez table.ttlSec aux côtés de table.timestampColumn. La plateforme partitionne la table StarRocks par jour sur la colonne d'horodatage et conserve uniquement les ceil(ttlSec / 86400) partitions quotidiennes les plus récentes — les partitions plus anciennes sont supprimées automatiquement.
table: {
name: "aircraft_position",
primaryKey: ["icao24", "seenAt"],
timestampColumn: "seenAt", // the column partitions are cut on
ttlSec: 604800, // keep ~7 days of partitions
columns: [ /* ... */ ],
}Comme la table est partitionnée sur elle, une timestampColumn utilisée avec ttlSec doit être une colonne date ou timestamp, et elle doit faire partie des colonnes de clé, comme seenAt ci-dessus. Un manifeste qui déclare ttlSec et enfreint l'une de ces règles est refusé au build, avec un message qui nomme la colonne. timestampColumn doit toujours nommer l'une des table.columns, sauf qu'une source on-demand peut nommer la colonne système __fetchedAt (voir Modes d'ingestion), que ces règles vérifient alors comme toute autre. Sans ttlSec, rien n'est partitionné : la colonne peut alors être de n'importe quel type et n'a pas à faire partie de la clé.
ttlSec est la valeur par défaut d'authoring. Un administrateur d'organisation peut remplacer la rétention effective par source dans Analytics → Data sources : ouvrez le panneau de détail de la source et renseignez Retention (seconds), ou revenez à la valeur par défaut de l'extension. Le remplacement est appliqué en direct, sans reconstruction de table ; une source query n'a ni table gérée ni réglage de rétention. Le panneau de détail affiche également le mode d'écriture de la source (lecture seule) et son historique de synchronisation récent, et renvoie directement à la table sous-jacente.
Dans Analytics → Sources de données, le menu de la ligne d'une source propose Supprimer la source de données : elle efface la source, qui cesse de s'exécuter. Sa table et toutes ses lignes sont conservées — la source cesse seulement d'écrire — et la table reste sur Analytics → Tables, où la supprimer est une action à part. L'extension qui a fourni la source reste installée, même lorsque la source était son seul contenu ; désinstallez-la depuis Paramètres → Plateforme → Extensions lorsque plus rien n'en a besoin.
Seul un administrateur d'organisation peut modifier la rétention d'une source ou la supprimer. Les autres membres voient la rétention en lecture seule et ne se voient pas proposer Supprimer la source de données.
La rétention limite la croissance de la table pour tout mode d'écriture, et c'est l'outil approprié lorsque changed-only ne peut pas aider (télémétrie en évolution continue). Elle est indépendante du mode d'écriture : vous pouvez combiner append ou changed-only avec ttlSec pour conserver l'historique mais le limiter à une fenêtre glissante.
Santé de synchronisation et mise en pause automatique
Chaque tick planifié enregistre un état de synchronisation sur la source : ok, error (avec la dernière erreur et un compteur d'échecs consécutifs) ou paused. Une source est mise en pause de deux manières :
- Automatiquement. Lorsqu'un tick échoue et que le nombre d'échecs consécutifs atteint le seuil de l'organisation (10 par défaut), la plateforme met la source en pause au lieu de la relancer indéfiniment à chaque intervalle. Un administrateur d'organisation définit ou désactive le seuil dans Paramètres → Analytics (« Mettre en pause les sources de données en échec »). Un tick que le fournisseur limite en débit (HTTP
429) est enregistré commeprovider_rate_limited, avec leRetry-Afterdemandé par le fournisseur, et ne compte pas pour le seuil : le fournisseur a répondu, et l'intervalle suivant est la nouvelle tentative. UnRetry-Afterde 10 secondes au plus est aussi respecté une ou deux fois dans le tick lui-même. Si une source est limitée à chaque tick, allongez son intervalle. Un tick dont la table est occupée par une autre écriture n'est pas du tout un échec : rien n'est enregistré, la source garde son état, et le tick est relancé dans les minutes qui suivent (jamais plus tard que son intervalle). - Manuellement. Choisissez Mettre en pause la synchronisation sur une source dans Analytics → Sources de données (Mettre en pause la copie pour une source de requête qui se réplique, Mettre en pause l’accès en direct pour une source lue sur place), puis confirmez.
Une source query qui copie ses tables suit le même seuil avec ses exécutions de copie, et la mise en pause automatique n'arrête que la copie : le catalogue en direct de la source reste enregistré, si bien que les requêtes en direct sur la base de données continuent de fonctionner pendant que la copie est en pause. Chaque administrateur de l'organisation en est averti dans l'application (type de notification data_source.copy_paused). Une exécution de copie qui n'a pas pu s'exécuter parce qu'un autre détenteur occupait sa table — une autre exécution, une porte d'admission des requêtes saturée, ou une autre écriture sur la destination — n'est pas enregistrée et ne compte pas ; elle est relancée dans les minutes qui suivent. Une mise en pause manuelle arrête à la fois la copie et les requêtes en direct.
Une source en pause conserve sa table ; les exécutions planifiées l'ignorent sans interroger le fournisseur ni écrire de lignes, et Synchroniser maintenant est refusé sur elle (409 source_paused) avec la correction à apporter. Pour la réactiver, choisissez Reprendre la synchronisation (Reprendre la copie, Reprendre l’accès en direct) : la source repasse en pending et son compteur d'échecs est remis à zéro. Reprendre une source query dont la copie a été mise en pause automatiquement ne réenregistre pas son catalogue en direct.
La mise en pause automatique est un filet de sécurité pour une source qui ne peut pas réussir (identifiant révoqué, point de terminaison déplacé, table dont le stockage est cassé). Elle limite le coût d'un flux mort ; elle ne le diagnostique pas. Lisez la dernière erreur de la source avant de reprendre.
Empreintes de lot
sourceBatchFingerprint(rows) (@scrydon/sdk-authoring/data-sources) est le SHA-256 des
lignes d'un lot en JSON canonique (JCS) : les mêmes lignes donnent la même chaîne
hexadécimale, quel que soit l'ordre de leurs clés. Scrydon la calcule pour chaque lot
avant que les règles de protection des données de votre organisation ne l'analysent, et
s'en sert pour reconnaître un lot déjà écrit : un lot renvoyé après un changement de règle
est donc toujours reconnu. Utilisez-la pour désigner un lot comme Scrydon le fait, par
exemple dans un test.
import { sourceBatchFingerprint } from '@scrydon/sdk-authoring/data-sources'
await sourceBatchFingerprint([{ id: 'a', at: '2026-10-02T00:00:00Z' }]) // 64 caractères hexadécimauxStructure de l'extension
Une extension qui livre une source de données ajoute un sous-répertoire data-source-<id>/ par source, chacun contenant un seul manifest.json. Plusieurs sources de données peuvent coexister dans la même extension — data-source est un type de contenu répétable, comme workflow.
<extension>.scrydon-extension.tar.gz
├── extension.json # ExtensionArchiveManifestSchema
└── data-source-adsb-lol-military-declarative/
└── manifest.json # DataSourceManifestSchema (JSON pur, sans code)Composez le Extension avec defineExtension et listez chaque source dans provides.dataSources. extension build en écrit extension.json — une entrée data-source par source — ainsi que le manifest.json de chaque source :
import { defineExtension } from '@scrydon/sdk-authoring/extensions'
import { adsbLolMilitaryDeclarative } from './data-source-adsb-lol-military'
export default defineExtension({
id: 'adsb-lol-military',
name: 'ADS-B Lol Military Aircraft',
version: '1.0.0',
logo: './assets/icon.svg',
provides: { dataSources: [adsbLolMilitaryDeclarative] },
})Une source qui lit un identifiant ou un endpoint depuis sa connexion exige aussi que l'extension les déclare — auth.credentials et configFields dans le même appel à defineExtension. Voir Endpoints fournis par une connexion.
Build, inspection, téléversement
Créez chaque source de données avec defineDataSource. Composez le Extension avec defineExtension et listez chaque source dans provides.dataSources.
bunx @scrydon/sdk-authoring extension build src/extension.ts --outDir dist
# → dist/<id>-<version>.scrydon-extension.tar.gzbunx @scrydon/sdk-authoring extension inspect dist/adsb-lol-military-1.0.0.scrydon-extension.tar.gzL'inspecteur liste chaque sous-répertoire, valide chaque manifeste par rapport à DataSourceManifestSchema, et signale toute erreur de mapping ou de colonne avant le téléversement.
Les extensions de sources de données se téléversent dans Paramètres → Plateforme → Extensions → Sources dans l'application de plateforme. Le téléversement admet l'extension dans le catalogue d'extensions de l'organisation. Aucune ligne data_source n'est créée pour l'instant — les sources de données diffèrent à l'Étape 2 pour que l'utilisateur du workspace puisse choisir dans quel environnement elles se matérialisent.
Équivalent programmatique :
curl -X POST "$AGENTIC_URL/api/extensions/import?organizationId=$ORG_ID" \
-H "Cookie: $SESSION_COOKIE" \
-F "file=@dist/adsb-lol-military-1.0.0.scrydon-extension.tar.gz"La route retourne l'id de l'entrée du catalogue ; dataSources.installedIds est vide à ce stade par conception.
Dans Analytics → Marketplace, depuis un workspace + environnement, sélectionnez la source de données depuis une extension catalogué et cliquez sur Installer. La plateforme matérialise la ligne data_source limitée à votre environnement et commence à interroger selon la cadence intervalSec configurée — généralement dans l'espace d'une minute. Gérez la source depuis Analytics → Sources de données.
Les nouvelles règles s'appliquent aux nouvelles constructions. La construction et le téléversement soumettent chaque source aux règles actuelles du SDK. Une source déjà installée continue de fonctionner avec la définition installée, même si une règle ajoutée depuis la refuserait dans une nouvelle construction. Si une version ultérieure de Scrydon ne peut plus lire du tout une définition installée, seule cette source s'arrête et affiche This source's stored definition no longer validates against this version of Scrydon ; toutes les autres continuent. Installez une version plus récente de l'extension pour corriger.
Sources de données nécessitant des identifiants
Certaines API nécessitent un compte ou une clé API. L'extension déclare l'identifiant une fois, dans auth.credentials de defineExtension — une extension qui ne livre que des sources de données le déclare là aussi, sans aucun toolkit. La source le désigne avec request.authRef. Le manifeste ne contient jamais de secret ; authRef est un nom, pas une valeur. (Le DataSourceManifestSchema rejette toute tentative d'intégrer directement un en-tête Authorization ou une clé API dans le manifeste.)
// defineExtension({ ... })
auth: {
credentials: { exampleKey: { type: 'apiKey', label: 'Example API key' } },
default: 'exampleKey',
},
// defineDataSource({ ... }).ingest
ingest: {
mode: "poll",
intervalSec: 300,
request: {
url: "https://api.example.com/v1/records",
method: "GET",
authRef: "exampleKey", // une clé de auth.credentials — PAS un token inline
},
// ...
}Le build refuse un authRef ou un credentialRef qui ne désigne aucun identifiant de auth.credentials, ainsi qu'une source dont le vendor n'est pas l'id de l'extension — l'extension dont les connexions portent l'identifiant. Il refuse aussi un authRef qui désigne un identifiant basicAuth : une source de données qui appelle une API place un seul secret sur sa requête — une clé d'API (apiKey), un jeton de bot (botToken) ou un jeton d'accès OAuth (oauth, oauthClientCredentials) — et un nom d'utilisateur avec un mot de passe n'en est pas un. Une connexion qui s'authentifie par nom d'utilisateur et mot de passe est aussi refusée à l'exécution, avec HTTP 412 data_source_connection_required, plutôt que d'envoyer son secret comme une clé.
Déroulement de la connexion :
- Un administrateur d'organisation crée une connexion pour l'extension dans Paramètres → Plateforme → Extensions → <extension> → Connexions, en saisissant l'identifiant déclaré par l'extension.
- À chaque tick, la plateforme résout cette connexion côté serveur et attache les identifiants à la requête sortante — votre manifeste ne voit jamais le secret.
- Si aucune connexion activée n'existe encore, le tick retourne HTTP 412
data_source_connection_required— un signal clair de connecter d'abord le compte. Aucune donnée n'est extraite, et aucune erreur n'est silencieusement absorbée. - Une fois qu'une connexion est activée, les ticks suivants la résolvent automatiquement et s'exécutent.
Si plusieurs connexions activées correspondent à l'environnement de la source et qu'aucune n'est choisie, la plateforme ne choisit pas entre elles : la source affiche More than one connection in this environment can serve …, et ses ticks retournent HTTP 412 data_source_connection_required sans rien récupérer, jusqu'à ce qu'un administrateur en choisisse une avec Changer de connexion sur la ligne de la source, ou ajoute une instance pour lire les deux. Ajouter une connexion ne met jamais dans cet état une source qui fonctionnait déjà : avant l'enregistrement de la nouvelle connexion, la source est fixée sur la connexion qu'elle utilisait (voir Connecter des comptes).
Une source, plusieurs instances. Un administrateur peut installer la même source plusieurs fois dans un environnement, chaque copie étant une instance épinglée à l'une des connexions de l'extension (par exemple un Home Assistant personnel et un Home Assistant d'entreprise). Chaque exécution d'une instance — tick planifié, Sync now, Fetch now, livraison push, flux, catalogue de requêtes — lit exactement sa connexion épinglée. Chaque exécution, téléversement manuel compris, écrit dans la table propre à l'instance : le table.name de la source suivi du nom de l'instance (ha_states_company) ; l'instance d'origine garde table.name inchangé. Si la connexion épinglée d'une instance est supprimée, désactivée ou déplacée vers un autre environnement, l'instance retourne HTTP 412 data_source_connection_required et ne récupère rien. Elle ne se rabat jamais sur une autre connexion. Votre manifeste ne change pas : une seule définition sert toutes les instances.
Pas de authRef ? Une source sans authRef (API publique, pas d'authentification nécessaire) s'exécute immédiatement — aucune configuration d'identifiants requise.
authRef désigne un identifiant que l'extension déclare ; ce n'est pas le nom qu'un administrateur donne à une connexion. L'administrateur nomme ses connexions librement (« Example · production ») — la plateforme sélectionne celle de l'environnement de la source. Pour que chaque connexion fournisse aussi sa propre URL, voir Endpoints fournis par une connexion.
Sécurité
Garde de sortie : le runtime générique de poll applique une garde de sortie SSRF avant chaque fetch. Les URL de requête doivent utiliser https:// — le http:// simple est rejeté. Les requêtes vers des adresses de loopback (127.x.x.x, ::1), des adresses link-local (169.254.x.x), des plages privées (10.x, 172.16–31.x, 192.168.x), et les préfixes ULA IPv6 (fd00::/8) sont bloquées au niveau de la forme de l'URL avant qu'une connexion soit ouverte.
Le nom d'hôte est ensuite vérifié de nouveau au moment du fetch : il est résolu, chaque adresse obtenue est contrôlée, et la requête est épinglée sur ces adresses — un nom qui se résout vers une adresse privée, ou qui se résout de nouveau vers l'une d'elles entre le contrôle et la connexion, est donc refusé, sauf si cette adresse se trouve dans un réseau privé que votre opérateur Scrydon a ouvert pour votre organisation (voir Endpoints fournis par une connexion).
Identifiants par référence uniquement : le champ request.authRef désigne un identifiant que l'extension déclare ; le secret lui-même réside sur la connexion de l'organisation, dans le secret store de la plateforme. N'intégrez jamais une clé API, un token Bearer ou la valeur d'un en-tête Authorization directement dans le manifeste. Le DataSourceManifestSchema utilise .strict() sur le bloc request et rejette tout champ au-delà de url, endpointRef, path, method, headers, query, authRef, auth et body — les champs parasites comme apiKey ou secret provoquent une erreur de validation lors du build. auth ne fait que nommer un champ de l'identifiant résolu (apiKey, accessToken, …) ; il ne porte jamais de secret, et il est rejeté s'il n'y a pas d'authRef à résoudre.
Limites de ressources
Appliquées à chaque tick poll et à chaque récupération on-demand :
| Limite | Défaut |
|---|---|
| Corps de la réponse | 64 Mo |
| Nombre de lignes | 10 000 lignes — une réponse qui en produit davantage fait échouer le tick avec exceeds maxRows (10000) et n'écrit rien ; elle n'est jamais tronquée aux 10 000 premières |
| Délai d'expiration du fetch | 15 secondes |
Les lignes écartées par un filter ne comptent pas. Les autres modes ont leurs propres limites, et chacun refuse aussi en entier un contenu qui les dépasse plutôt que d'en garder les premières lignes : un import manuel accepte jusqu'à 50 000 lignes et 16 Mio, une livraison push jusqu'à 1 000 lignes et 1 Mio, et un message stream qui produit plus de 1 000 lignes est écarté (voir Modes d'ingestion).
Ce qu'une source déclarative ne sait pas encore faire
Quatre types d'API qu'une source déclarative n'atteint pas aujourd'hui, et que faire à la place :
| L'API a besoin | Pourquoi une source ne peut pas l'exprimer | Faites plutôt ceci |
|---|---|---|
D'une date dans l'URL — un fichier quotidien comme https://gpsjam.org/data/2026-09-27-h3_4.csv | L'URL d'une requête poll est fixée dans le manifeste ; elle n'a pas d'emplacement pour une date. | Importez le fichier de chaque jour dans une source manuelle. |
De son entrée dans le chemin de l'URL — /search/2/geocode/{query}.json | Un argument d'appelant devient un paramètre de requête ou un champ du corps JSON, jamais une partie du chemin. | Utilisez la variante à paramètres de requête du fournisseur, comme structuredGeocode.json chez TomTom — voir params. |
| De plusieurs pages en une exécution — suivre un jeton de page suivante | Une exécution envoie une requête et lit une réponse. Un curseur comme since_id transmis d'une exécution à la suivante est pris en charge : voir Polls incrémentaux. | Déclarez un cursor pour que chaque exécution reprenne là où la précédente s'est arrêtée, et raccourcissez l'intervalle si une réponse ne peut pas contenir les résultats d'un intervalle. |
| De plus de 10 000 lignes dans une réponse | Une réponse au-delà de la limite est refusée en entier. | Restreignez la requête (taille de page, fenêtre de temps), ou importez le fichier dans une source manuelle, qui accepte jusqu'à 50 000 lignes. |
Une recherche qui peut revenir vide ne figure pas dans cette liste : quand le fournisseur omet la liste faute de résultat (le {} de GDELT, un canal RSS sans <item>), définissez itemsOptional: true sur response et cette réponse donne zéro ligne au lieu d'un tick en échec — voir Anatomie.
Pour aller plus loin
Placement de l'identifiant (auth), corps de requête POST y compris GraphQL, et fixation du format de réponse.
Transmettez un curseur comme since_id d'une exécution à la suivante, pour que le fournisseur ne renvoie que les nouveautés.
Définissez une source une seule fois et laissez chaque connexion fournir sa propre URL, sa base de données et son identifiant.
Interrogez un fournisseur authentifié par OAuth — la plateforme fournit à votre tick un jeton d'accès valide.
Livrez des sources de données aux côtés des flux de processus — la plateforme peut les piloter depuis le même Extension.
Définissez les types d'objets typés et les types de liens qui référencent les données ingérées par vos sources.
Portes HITL, routes d'approbation et automatisations qui peuvent agir sur les données issues de sources de poll déclaratives.