Documentazione · riferimento
Standard per gli agenti
Le regole che un agente di IA segue per costruire un'app che Pyrite ospita senza sorprese: com'è fatta la cartella, chi entra, chi può leggere e scrivere i dati, cosa possono fare gli script, quali strumenti del motore usare, in che lingua scrivere e come si consegna il pacchetto per «Carica app».
Questa pagina è il testo che si dà all'agente di IA (Claude, ChatGPT, Copilot o un altro) quando gli si chiede di costruire o modificare un'app per un reparto. Non serve leggerlo tutto: basta scaricare il file e allegarlo alla richiesta, oppure installarlo come skill nell'agente. Il file inizia con nome e descrizione della skill (pyrite-app-it; quello inglese pyrite-app), così gli agenti che le supportano lo riconoscono da soli.
Lo standard di riferimento è quello in inglese (AGENT-STANDARD.md), la lingua principale del motore; questo è la sua traduzione italiana fedele. Insieme c'è un'app di esempio completa e collaudata, «Parts price list» (Listino ricambi), con le pagine in inglese e in italiano: l'agente la può usare come modello. Il capitolo sugli strumenti del motore viene dal motore stesso, quindi descrive esattamente quello che c'è, e ogni esempio di codice è collaudato nel motore.
Versione 0.7.7 (alpha), per Pyrite 0.7.7 con PocketBase 0.40.6. Quando il motore cambia, il capitolo sugli strumenti si ristampa con pyrite.exe tools --md --lang it (in inglese senza --lang); nella console del motore lo stesso catalogo è nella pagina «Strumenti». Lo zip dell'app di esempio si carica così com'è con «Carica app».
Versione dello standard: 0.7.7 (alpha). Motore: Pyrite 0.7.7 con PocketBase 0.40.6. Questa è la traduzione italiana dello standard; il testo di riferimento è quello inglese (AGENT-STANDARD.md) e i due dicono la stessa cosa.
Alcuni nomi tecnici usano il prefisso orch: $orch negli script, le raccolte e i cookie orch_* e le variabili ORCH_*; le API del motore stanno sotto /api/platform/.
Cosa cambia nella 0.7.5, per chi conosce lo standard della 0.7.4:
- all'app si arriva in più modi, e a lei non deve importare quale: il solito
http://SERVER:PORTA/, oppure con un nome suo (per esempiohttps://prenota.app.esempio.it/), oppure da internet se un Proprietario la apre (sezione 1.1). Niente indirizzo o porta scritti a mano nelle pagine, negli script o nelle impostazioni; - ogni app è «Solo rete locale» o «Anche internet»: lo decide un Proprietario, un Admin lo chiede. Non si scrive in
app.json(sezione 1.1); - il motore scrive l'indirizzo vero di chi chiama in
X-Forwarded-ForeX-Pyrite-Ip; le stesse intestazioni scritte dal client non arrivano mai (sezione 1.1); - gli eventi del motore, anche quelli che arrivano dalle app (gli strumenti, la scheda Dati), vanno nello storico del motore con chi li ha fatti; lo storico delle modifiche (
history) ora ha il nome della persona anche per le modifiche fatte da una rotta scritta dall'app. Le app non vanno cambiate (sezione 1.1).
Cosa cambia nella 0.7.7, per chi conosce lo standard della 0.7.5: la port di app.json è una richiesta (chi carica l'app può scegliere un'altra porta, e dopo l'installazione la porta è dell'IT: un aggiornamento non la cambia mai, e tornando alla versione di prima si rimette la porta che l'app aveva prima); un app.json con due chiavi uguali a meno delle maiuscole è rifiutato; un'app caricata da un Admin avvisa i Proprietari con un'email, e un Proprietario può rifiutarla con un motivo.
Questo documento è scritto per te, agente di IA che costruisce un'app per un reparto. Leggilo tutto prima di scrivere codice. Le regole numerate sono obbligatorie: se la richiesta del reparto le contraddice, non aggirarle, ma scrivilo nelle note di consegna (sezione 15). Gli esempi vengono dall'app di esempio «Parts price list» (Listino ricambi, parts-pricelist), collaudata nel motore e scaricabile insieme a questo documento; gli altri esempi di codice sono collaudati nello stesso motore. Il codice degli esempi è in inglese, come deve esserlo quello delle app (sezione 10).
01 /Il contesto
Pyrite è un programma per Windows (pyrite.exe) che gira su un server dell'azienda, in rete locale: le persone usano le app con indirizzi come http://SERVER:8105/, oppure https://SERVER:8105/ quando l'IT accende HTTPS (un certificato per il motore e per tutte le porte delle app), oppure con un nome per ogni app; l'IT può anche aprire alcune app a internet (sezione 1.1). Il motore:
- ospita le app dei reparti, ognuna nella sua cartella e, se ha dati, con il suo PocketBase 0.40.6 separato (processo e database propri);
- fa da porta d'ingresso: decide chi entra in ogni app (accesso unico dal portale aziendale), conta le richieste, mostra una pagina chiara se l'app è ferma;
- fa i backup ogni notte, li ripristina, riavvia le app che cadono, raccoglie misure e log;
- offre una cassetta di strumenti pronti per i lavori pesanti (import ed export di file Excel e CSV, documenti PDF, foto, numerazioni, storico delle modifiche, statistiche, lavori pianificati), usabili dagli script dell'app con
$orche, quando l'app lo permette intoolAccess, dalle sue pagine (sezione 11); - protegge i record dalle modifiche concorrenti e lascia raggiungere agli script solo gli indirizzi di rete che l'app dichiara;
- parla inglese e italiano: la console del motore, il portale e i messaggi del motore sono nelle due lingue, prima l'inglese.
Quindi non devi costruire: server, login, pagine di registrazione, gestione delle password, backup, HTTPS, avvio automatico, invio di log, librerie per Excel, PDF o immagini (negli script o nelle pagine: lo fanno gli strumenti del motore). Devi costruire solo l'app: dati, regole, script e pagine.
L'IT che gestisce il motore non è un programmatore: tutto quello che consegni deve funzionare senza interventi manuali, con le spiegazioni in parole semplici. L'IT installa la tua app da un file zip con la pagina «Carica app» (Upload app) della console, la controlla e la avvia. Nella console del motore le persone dell'IT hanno un livello: un Proprietario può fare tutto; un Admin gestisce solo le app che un Proprietario gli assegna, e un'app caricata da un Admin aspetta l'approvazione di un Proprietario prima di potersi avviare.
1.1 Come si arriva all'app
- L'indirizzo. Le persone arrivano all'app con
http://SERVER:PORTA/(ohttps://, quando l'HTTPS è acceso). Se l'IT sceglie i nomi, ogni app ha anche un nome suo, sotto il dominio di Pyrite (per esempiohttps://prenota.app.esempio.it/), e il portale ha il suo. Chi apre un'app con il nome senza essere entrato passa dal portale, mette la password una volta e torna da solo all'app; il motore non usa nessun cookie valido per tutto il dominio. L'IT può anche chiudere le porte delle app alla rete: allora le persone arrivano alle app solo passando dal motore, con i nomi. Quindi nelle pagine scrivi percorsi relativi (/api/…,/__platform/portal) e mai un indirizzo o una porta fissi, né nelle pagine né negli script. - Internet. Nel motore ogni app è «Solo rete locale» o «Anche internet» (scheda Esposizione dell'app). Le app nuove partono «Solo rete locale». La scelta è di un Proprietario; l'Admin dell'app la può togliere da internet da solo e, per aprirla, la chiede a un Proprietario (la richiesta compare in «Da guardare» e lo storico tiene chi ha chiesto e chi ha deciso). Un'app vista da internet si apre sempre dopo l'accesso al portale. Non è un campo di
app.json: se l'app è pensata per essere usata da fuori, scrivilo inDELIVERY.md, così l'IT sa che cosa decidere. - L'indirizzo di chi chiama. Il motore scrive l'indirizzo vero di chi chiama in
X-Forwarded-Fore inX-Pyrite-Ip, e il PocketBase dell'app crede solo aX-Forwarded-For(in una rotta,e.realIP()): dietro un tunnel o un proxy è l'indirizzo della persona, non quello del tunnel. Le intestazioniX-Forwarded-For,X-Forwarded-Proto,X-Pyrite-IpeX-Orch-Tokenscritte dal client non arrivano mai all'app.CF-Connecting-IP,X-Real-IPeTrue-Client-IPle scrive anche il motore, con l'indirizzo vero, e le altre intestazioni che portano l'indirizzo di un client (X-Client-IP,Fastly-Client-IP,Forwarded…) si tolgono: quello che il client scrive lì non arriva mai all'app. - I collegamenti nelle email. Per i collegamenti nelle email che l'app manda (verifica dell'indirizzo, password dimenticata) il motore scrive l'indirizzo giusto nelle impostazioni dell'app a ogni avvio, e di nuovo senza riavviarla quando cambiano i nomi: non scriverlo tu, verrebbe sostituito.
- Lo storico del motore. Gli eventi del motore (chi ha acceso un'app, chi ha dato un accesso, chi è entrato, un file caricato, un dato ripristinato…) stanno nello storico del motore, un file a parte che dalla console nessuno cancella o cambia. Un evento che arriva da un'app (uno strumento, la scheda Dati) dice chi l'ha fatto, se la persona esiste davvero e, in un'app con login, ha l'accesso. Lo storico delle modifiche dei record (
history, sezione 11) ora porta il nome della persona anche per le modifiche fatte da una rotta scritta dall'app, quando la chiama una persona entrata; i lavori pianificati dell'app e le chiamate senza login restano «script dell'app». Nelle app non c'è niente da cambiare.
02 /Com'è fatta un'app
Un'app è una cartella. Il nome della cartella è l'identificativo dell'app (id in app.json).
parts-pricelist\ la cartella dell'app (= id)
├── app.json la scheda: tipo, porta, accesso, ruoli, strumenti
├── DELIVERY.md note per l'IT (sezione 15)
└── release\ il codice: si sostituisce senza toccare i dati
├── pb_migrations\ schema del database e dati iniziali (JavaScript)
├── pb_hooks\ regole e rotte lato server (JavaScript, file *.pb.js)
│ └── lib\ codice comune, caricato con require()
├── pb_public\ le pagine: HTML, CSS, JS, immagini
│ └── lib\ l'SDK di PocketBase (pocketbase.umd.js), copia locale
├── templates\ modelli dei PDF (.json, .html) e le loro immagini, solo con lo strumento pdf
├── import\ ricette di import dell'app (`*.json`), solo con lo strumento import
└── seed\ file di dati iniziali (CSV, Excel, JSON), se servono
Il motore aggiunge da sé, accanto a release\, le cartelle state\ (i dati: state\pb_data) e logs\. Non fanno parte dell'app che consegni. Gli script dell'app scrivono file solo dentro state\: app.json, release\ e logs\ per loro sono in sola lettura (sezione 8). I backup dell'app (e la loro copia sulla cartella di rete) contengono solo state\pb_data: un file che deve durare va in una raccolta (campo file), non altrove in state\, dove un ripristino non lo rimette.
Un'app statica (solo pagine, senza database) ha solo app.json, le note di consegna e release\public\ (attenzione: public, non pb_public).
03 /La scheda app.json
Esempio completo (l'app di esempio):
{
"id": "parts-pricelist",
"name": "Parts price list",
"description": "Prices of the spare parts in the warehouse, updated from the supplier's Excel or CSV file.",
"kind": "pocketbase",
"access": "login",
"port": 8105,
"version": "0.1.0",
"owner": "Spare parts warehouse",
"auth": {
"collection": "users",
"roleField": "role",
"roles": ["manager", "storekeeper"],
"defaultRole": "storekeeper"
},
"tools": ["import"]
}
| campo | valori | significato |
|---|---|---|
id | da 3 a 32 caratteri: minuscole, numeri, trattini (non all'inizio né alla fine) | identificativo stabile; uguale al nome della cartella. Riservati: api, apps, console, admin, portale, accedi, archivio, control, platform, www, e i nomi dei dispositivi di Windows (con, prn, aux, nul, da com0 a com9, da lpt0 a lpt9) |
name | testo, obbligatorio | il nome che le persone vedono nel portale |
description | testo | una riga che spiega a cosa serve |
kind | static, pocketbase, pocketbase-cron; obbligatorio | il tipo (sezione 4) |
access | login oppure public | login: entra solo chi ha l'accesso dato dall'IT; public: chiunque nella rete aziendale. Maiuscole e minuscole non contano. Se manca vale login; un altro valore non è rifiutato: il motore avvisa e tratta l'app come login |
port | da 1024 a 64000, obbligatorio | la porta pubblica dell'app. La decide l'IT: usa quella indicata nella richiesta; se non c'è, metti 8199 e scrivilo nelle note di consegna. Il motore usa anche la porta + 1000. La porta è una richiesta: chi carica l'app può sceglierne un'altra se questa è occupata o chiusa dal firewall di Windows, e il motore scrive la porta scelta nell'app.json installato. Dopo l'installazione la porta è dell'IT: un aggiornamento dell'app non la cambia (l'app resta sulla porta che ha, anche se il nuovo app.json ne chiede un'altra; la porta si cambia dalla scheda Configurazione). Quindi né l'app né i suoi documenti (note di consegna, README, collegamenti, script) devono contare su un indirizzo con la porta fissa: prendi la porta dalla richiesta, non scriverla in altri file, e fai rispondere l'app sulla porta che le dà il motore. |
version | per esempio 0.1.0 | la versione del codice: aumentala a ogni consegna |
owner | testo | il reparto o la persona di riferimento |
color | colore esadecimale, per esempio #126a61 | facoltativo |
memoryLimitMB | numero intero | facoltativo; limite indicativo di memoria. Non metterlo se non te lo chiedono |
auth | oggetto | app PocketBase: utenti e ruoli (sezione 5). In un'app pubblica dichiara solo i ruoli che usa toolAccess |
auth.collection | nome di raccolta, di solito users | la raccolta degli utenti dell'app; se manca vale users |
auth.roleField | nome di campo | il campo dell'utente che contiene il ruolo. Obbligatorio se c'è auth.roles: non ha un valore predefinito, e senza di lui il motore non legge né scrive i ruoli (e avvisa l'IT). Chiamalo role, come fa il motore per le app che crea |
auth.roles | elenco | i ruoli, dal più al meno potente |
auth.defaultRole | uno dei ruoli | il livello base: il ruolo di chi entra per la prima volta. Se manca, il meno potente |
auth.extra | oggetto | campi fissi che il motore scrive sull'utente a ogni accesso, nuovo o già esistente (raro) |
tools | elenco, per esempio ["import"] | gli strumenti del motore usati dagli script e dalle pagine (sezione 11). Solo quelli usati davvero. |
toolAccess | oggetto, per esempio { "export": { "pages": true, "minRole": "editor", "collections": ["orders"] } } | quali strumenti possono usare le pagine, e chi (sezione 11, regola 8). Chiuso per difetto: senza una voce le pagine non usano nessuno strumento. Voci: pages, minRole, destructiveRole, collections, maxRows, guests (solo app pubbliche), recipes (import), snapshots (stats), templates e maxPages (pdf), keyFields (images: i campi che possono ricevere o confrontare il nome del file, per esempio ["products.code"]; senza, nessuno). L'IT le vede in «Carica app» e nella scheda «Strumenti per le pagine» |
toolJobs | { "heavy": 1 }, da 1 a 4 | quanti lavori lunghi partiti dalle pagine girano insieme; predefinito 1 |
concurrency | "strict" (predefinito), "warn", "off" (maiuscole e minuscole non contano; un altro valore vale strict) | la protezione dalle modifiche concorrenti (sezione 9, regola 10). In un'app nuova non metterlo: vale strict |
numbering | oggetto | i contatori dello strumento numbering (sezione 11) |
history | oggetto, per esempio { "collections": ["orders"] } | le raccolte che tengono lo storico delle modifiche (strumento history, sezione 11) |
images | oggetto, per esempio { "products.photo": { "maxSide": 1600 } } | le regole dello strumento images per i campi delle foto (sezione 11) |
network | oggetto, per esempio { "allow": ["api.openai.com"] } | gli indirizzi che gli script possono chiamare (sezione 8.1). allow: in https; allowHttp: anche in http semplice, solo per sistemi interni senza https. Senza network gli script non possono chiamare nessun indirizzo |
import | oggetto, per esempio { "consoleCollections": ["suppliers", "orders"] } | consoleCollections elenca le sole raccolte in cui la scheda «Importa» della console può importare (e annullare); [] = la console non importa; senza, tutte. Consigliato per un'app che tiene i dati in ordine con le sue rotte o i suoi hook di richiesta (spostamenti, storici, stati). Sezione 11, «Import» |
name e description sono un testo solo: scrivili nella lingua principale delle persone che usano l'app (il portale li mostra così come sono). I nomi dei ruoli: minuscole, numeri e trattino basso, da 2 a 30 caratteri (per esempio manager, workshop_head).
Il motore scarta la scheda (e quindi l'app) se non è JSON valido, se kind non è uno dei tre valori, se manca name, se port è fuori intervallo o già usata (anche la porta + 1000), se defaultRole non è tra i ruoli, se l'id non è valido, è riservato o è già registrato (se manca id vale il nome della cartella; in uno zip l'app deve allora stare in una cartella sola). «Carica app» fa gli stessi controlli prima di installare (sezione 15).
04 /Scegliere il tipo di app
| tipo | quando | cosa c'è |
|---|---|---|
static | solo pagine: un calcolatore, una guida, una lista che si legge da un file fornito con l'app | release\public\. Nessun database, nessuno script lato server. Con access: "login" la porta chiede l'accesso, ma la pagina non sa chi è la persona |
pocketbase | quasi sempre: dati da salvare, utenti con ruoli, regole | PocketBase 0.40.6 tutto per l'app: database SQLite, API REST e realtime, migrazioni, hook JS, pagine in pb_public |
pocketbase-cron | come pocketbase, con attività programmate (cronAdd) | come sopra; il tipo dice all'IT che l'app lavora anche da sola |
Se l'app deve ricordare qualcosa (anche solo preferenze condivise o un elenco modificabile), è pocketbase. Non salvare dati condivisi nel localStorage del browser.
05 /Utenti, accesso e ruoli
Il principio: chi entra lo decide l'IT, al centro, e le persone entrano solo dal portale; il ruolo dentro l'app lo gestisce il motore insieme all'app.
- Nelle app con
access: "login"la persona entra dal portale aziendale con le credenziali della rubrica centrale. Il motore crea nella raccoltausersdell'app un utente con l'email e il nome della persona e lo lega alla persona per ID: quando l'IT cambia l'email di una persona, o la persona cambia il nome visualizzato nel profilo, il motore allinea l'utente dell'app, che resta lo stesso record con lo stessoid. Il motore gli rilascia un accesso e lo mette nel browser (chiavepocketbase_authdellocalStorage, quella che l'SDK di PocketBase usa da solo). Quando la pagina si apre, l'utente è già autenticato:pb.authStore.record. - L'app non deve avere: pagine di login, registrazione, «password dimenticata», cambio password, pulsante «Esci». Le password sono della rubrica: il motore applica le regole delle password (maiuscola, minuscola, numero, carattere speciale, lunghezza minima decisa dall'IT) e il cambio obbligatorio al primo accesso. Per tornare al portale usa il collegamento
/__platform/portal; si esce dal portale. - L'identità è bloccata dal motore: dalle API dell'app non si possono creare utenti né cambiare email, nome o password. Non provarci e non mostrare campi per farlo. Per dire di chi è un record usa l'
iddell'utente (un camporelationversousers), mai l'email: l'email può cambiare, l'idresta. La persona ha una pagina del profilo nel motore (/profile/, dal portale): nome visualizzato, lingua, tema, password e le sessioni aperte, con «Esci da tutte le sessioni», che chiude anche le sessioni nelle app (la chiamata successiva della pagina risponde401: sezione 9, regola 4). L'app non deve offrire niente di tutto questo. - Il ruolo è un campo
selectdella raccolta utenti, chiamatorole(dichiarato inauth.roleField), con gli stessi valori diauth.roles; quando l'app gira nel motore, il campo accetta anche i valori diauth.roles. Alla prima entrata (e ogni volta che il ruolo dell'utente è vuoto) il motore scrive il livello base (auth.defaultRole); altrimenti non lo tocca. Il ruolo lo cambiano:- l'IT, dalla console del motore (pagina dell'app, scheda «Accessi e ruoli»: un Proprietario o un Admin che gestisce l'app);
- se serve, chi ha il ruolo più alto, da una pagina dell'app, se la regola
updateRulediusersglielo permette; quandotoolAccessusaminRoleodestructiveRole, nessuno può dare un ruolo più potente del proprio.
- Nelle app con login nessuno può cambiare il proprio ruolo: lo blocca il motore (non nella prova locale della sezione 12.1, dove il motore manca). Nelle app pubbliche questo blocco non c'è: solo quando
toolAccessusaminRoleodestructiveRoleil motore impedisce di dare un ruolo più potente del proprio (abbassarlo si può); altrimenti decide laupdateRulediusers. - Il campo del ruolo si crea con una migrazione (sezione 7.3). Le regole lo leggono come
@request.auth.role. - Nelle app con
access: "public"chiunque nella rete aziendale apre le pagine senza entrare: le richieste sono anonime. Nessuna app ha l'iscrizione libera, nemmeno le pubbliche: chi ha un utente in un'app lo decide l'IT: nelle app con login con le persone e i gruppi del motore, nelle app pubbliche creandoli nella scheda Dati dell'app (sezione 13). Se un'app pubblica ha bisogno di utenti (per esempio le poche persone che possono scrivere), li crea l'IT o li creano gli script dell'app (mai nelle migrazioni: regola 8), mai una pagina di registrazione: il motore rifiuta ogni richiesta API che crea un record in una qualunque raccolta di utenti (non solousers; anche con OAuth2 e dentro/api/batch): con403nelle app pubbliche, con400nella raccoltausersdi un'app con login; solo un superuser dell'app, cioè il pannello che apre il motore, può creare utenti e avvisa l'IT se una raccolta la chiede con la suacreateRule. Tieni lacreateRulediusersanull(sezione 6, regola 5). Non mettere dati riservati in un'app pubblica e non permettere scritture anonime se non sono davvero lo scopo dell'app (per esempio un modulo di segnalazione); in quel caso concordalo con l'IT e scrivilo nelle note. - Non creare utenti, account di prova o amministratori nelle migrazioni, e non scrivere password da nessuna parte.
06 /Regole di accesso alle API (obbligatorie)
PocketBase espone ogni raccolta come API REST. Chi può leggere e scrivere lo decidono solo le regole della raccolta (listRule, viewRule, createRule, updateRule, deleteRule), controllate dal server. Le pagine non proteggono niente: chiunque abbia accesso all'app può chiamare le API direttamente.
- Ogni raccolta ha le cinque regole scritte esplicitamente nella migrazione che la crea. Mai lasciarle al valore di partenza.
nullvuol dire «nessuno» (solo il motore e l'IT).""(testo vuoto) vuol dire «chiunque, anche senza login». Mai""sucreateRule,updateRule,deleteRule.""sulistRule/viewRulesolo in un'app pubblica e solo per dati davvero pubblici.- Ogni regola di un'app con login inizia con
@request.auth.id != ''. Senza login@request.auth.idvale'': una regola comerequester = @request.auth.iddiventa vera per tutti i record conrequestervuoto, e quei record diventano leggibili e modificabili da chiunque. La forma giusta è:
listRule: "@request.auth.id != '' && (@request.auth.role = 'buyer' || requester = @request.auth.id)",
- I campi che decide il server (autore, stato di approvazione, numeri progressivi, date di convalida) non si accettano dal client: o li imposta un hook (sezione 7.1), o la regola li vieta con
@request.body.campo:isset = false. - La raccolta
usersdell'app ha regole strette, in ogni app, anche pubblica:createRule: null(nessuna iscrizione),deleteRule: null, lettura del proprio utente (e, se serve, dei colleghi per chi ha il ruolo più alto),updateRulesolo per chi può cambiare i ruoli degli altri oppurenull. - Le rotte che scrivi tu (
routerAdd) non passano dalle regole delle raccolte: proteggile con$apis.requireAuth("users")e controlla il ruolo nel codice. Allo stesso modoe.app.save(...)negli script scrive senza regole: lo script è il server. Anche gli strumenti$orchchiamati dagli script scrivono come il server; chiamati dalle pagine (/api/tools/) applicano le regole dell'app per la persona e i permessi ditoolAccess(sezione 11, regole 6 e 8). - Prova le regole davvero: senza login, con ogni ruolo, chiamando le API direttamente (sezione 12).
Regole tipiche (ruoli manager e storekeeper, campo role):
| cosa | regola |
|---|---|
| leggono tutti quelli che entrano | @request.auth.id != '' |
scrive solo il responsabile (manager) | @request.auth.id != '' && @request.auth.role = 'manager' |
| ognuno vede i propri record | @request.auth.id != '' && author = @request.auth.id |
| il proprio record finché è in bozza, senza toccare lo stato | @request.auth.id != '' && author = @request.auth.id && status = 'draft' && @request.body.status:isset = false |
| nessuno da API (solo script e IT) | null |
La migrazione dell'app di esempio che crea il campo del ruolo, le raccolte e le loro regole:
/// <reference path="../pb_data/types.d.ts" />
// Parts price list: the users' role, the price list and the files uploaded by the manager.
// In migrations the constants written out here can be used (in hook handlers they can't).
const AUTH = "@request.auth.id != ''";
const MANAGER = "@request.auth.id != '' && @request.auth.role = 'manager'";
migrate((app) => {
// users: the engine creates them (same email as the company directory); here only the role field and strict rules
const users = app.findCollectionByNameOrId("users");
if (!users.fields.getByName("role")) {
users.fields.add(new SelectField({ name: "role", maxSelect: 1, values: ["manager", "storekeeper"] }));
}
users.listRule = AUTH + " && (id = @request.auth.id || @request.auth.role = 'manager')";
users.viewRule = AUTH + " && (id = @request.auth.id || @request.auth.role = 'manager')";
users.createRule = null; // no sign-up: IT decides who gets in
users.updateRule = MANAGER; // the manager changes the colleagues' role (not their own: the engine blocks it)
users.deleteRule = null;
app.save(users);
const parts = new Collection({
type: "base",
name: "parts",
listRule: AUTH,
viewRule: AUTH,
createRule: MANAGER,
updateRule: MANAGER,
deleteRule: MANAGER,
fields: [
{ name: "code", type: "text", required: true, max: 40, presentable: true },
{ name: "description", type: "text", required: true, max: 300 },
{ name: "price", type: "number", min: 0 },
{ name: "supplier", type: "text", max: 120 },
{ name: "updated_by", type: "text", max: 200 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_parts_code ON parts (code)"],
});
app.save(parts);
// the supplier's files: the manager uploads them, then the route /api/pricelist/import/{id} imports them
const uploads = new Collection({
type: "base",
name: "uploads",
listRule: MANAGER,
viewRule: MANAGER,
createRule: MANAGER + " && @request.body.import_id:isset = false && @request.body.result:isset = false && @request.body.undone:isset = false",
updateRule: null,
deleteRule: null,
fields: [
{ name: "file", type: "file", required: true, maxSelect: 1, maxSize: 20971520 },
{ name: "import_id", type: "text", max: 40 },
{ name: "result", type: "json", maxSize: 2000000 },
{ name: "undone", type: "bool" },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
app.save(uploads);
}, (app) => {
for (const name of ["uploads", "parts"]) {
try { app.delete(app.findCollectionByNameOrId(name)); } catch (_) {}
}
const users = app.findCollectionByNameOrId("users");
users.fields.removeByName("role");
app.save(users);
});
07 /Script JS lato server (PocketBase 0.40.6)
Gli script girano dentro il PocketBase dell'app, con il motore JavaScript goja: JavaScript moderno, ma senza Node.js, senza npm, senza fetch e senza async/await (gli handler sono sincroni). Usa solo le API di PocketBase 0.40.6; non copiare esempi di versioni vecchie (prima della 0.23 le API JS erano diverse: niente $app.dao(), niente onRecordBeforeCreateRequest).
7.1 Hook e rotte (release\pb_hooks\*.pb.js)
PocketBase carica solo i file che finiscono con .pb.js, in ordine alfabetico, all'avvio dell'app: dopo una modifica l'app va riavviata.
- Ogni handler gira in una macchina JS separata: non vede variabili, costanti o funzioni scritte fuori dall'handler, nello stesso file. Il codice comune va in un modulo (per esempio
pb_hooks\lib\rules.js, senza.pb.js) e si carica dentro l'handler conrequire(__hooks + "/lib/rules.js"). Dentro il modulo, le costanti e le funzioni del modulo stesso si vedono. - Negli hook dei record e delle richieste chiama sempre
e.next()per proseguire; per bloccare, lancia un errore:throw new BadRequestError("messaggio per la persona")(ancheForbiddenError,NotFoundError,UnauthorizedError). - Usa
e.appdentro gli handler (è l'app giusta anche dentro una transazione);$appva bene nelle rotte e neicronAdd. Gli strumenti$orchusano da soli l'app giusta (sezione 11, regola 5). e.authè l'utente che fa la richiesta (oppurenull):e.auth.getString("role"),e.auth.email(),e.auth.id.- Dati della richiesta:
e.requestInfo().body,e.request.pathValue("id"),e.request.url.query().get("x"),e.request.header.get("Accept-Language"). Risposte:e.json(200, oggetto),e.string(200, testo),e.noContent(204). - SQL solo con parametri legati, mai concatenando testi:
e.app.db().newQuery("... WHERE x = {:x}").bind({ x: valore }). Per leggere le righe:arrayOf(new DynamicModel({ text: "", whole: 0, decimal: -0, flag: false }))(per i numeri con decimali la forma è-0, non0). - Cerca i record con i filtri e i parametri:
e.app.findRecordsByFilter("suppliers", "company_name ~ {:s}", "-created", 100, 0, { s: search }),e.app.findFirstRecordByFilter("suppliers", "account = {:a}", { a: account }),e.app.findRecordById("parts", id). Scorri i risultati confor (const r of records). - Più scritture che devono riuscire insieme:
e.app.runInTransaction((txApp) => { ... txApp.save(r) ... }), usandotxAppdentro. - Date: si salvano in UTC (
new DateTime()è adesso); il formato si sistema nella pagina. console.logfinisce nell'output del processo, che l'IT vede nella console del motore (scheda Stato → «Output del processo»). Non scriverci dati personali.- Il prefisso
/api/tools/è del motore (gli strumenti dalle pagine): non registrarci mai rotte. «Carica app» rifiuta un pacchetto nuovo che registra una rotta scritta sotto/api/tools/(una rotta con un segnaposto che potrebbe coincidere, come/api/{coll}/{id}/approve, è solo un avviso, e all'avvio lascia le pagine dell'app senza gli strumenti); un'app già installata che le ha tiene le sue rotte, ma le sue pagine non possono usare gli strumenti del motore finché le rotte non si spostano. Metti le rotte dell'app sotto un nome dell'app, per esempio/api/pricelist/…come nell'esempio. - I salvataggi degli script non passano dalla protezione dalle modifiche concorrenti (sezione 9, regola 10): se una rotta cambia un record per conto di una persona dopo averglielo mostrato, confronta tu la versione oppure usa le pagine.
Esempio (l'app di esempio): campi decisi dal server, rotta protetta che usa uno strumento del motore, annullamento e riepilogo con SQL.
/// <reference path="../pb_data/types.d.ts" />
// Parts price list: who changed a part, and the import of the supplier's file.
// Every handler runs in a separate JS machine: it doesn't see constants or functions written out here.
// The shared code is in lib/rules.js and is loaded with require() inside the handler.
onRecordCreateRequest((e) => {
e.record.set("updated_by", e.auth ? (e.auth.getString("name") || e.auth.email()) : "");
e.next();
}, "parts");
onRecordUpdateRequest((e) => {
e.record.set("updated_by", e.auth ? (e.auth.getString("name") || e.auth.email()) : "");
e.next();
}, "parts");
// imports the file of an "uploads" record: first the trial run, then the real load
routerAdd("POST", "/api/pricelist/import/{id}", (e) => {
const rules = require(__hooks + "/lib/rules.js");
rules.managerOnly(e);
const rec = e.app.findRecordById("uploads", e.request.pathValue("id"));
if (rec.getString("import_id")) {
throw new BadRequestError(rules.text(e, "already_loaded"));
}
const file = $filepath.join(e.app.dataDir(), "storage", rec.collection().id, rec.id, rec.getString("file"));
const recipe = rules.pricelistRecipe(e, file);
const trial = $orch.import.load(file, recipe, { dryRun: true, app: e.app });
if (trial.rejected > 0) {
return e.json(400, { message: rules.text(e, "rows_to_fix", { n: trial.rejected }), result: trial });
}
const result = $orch.import.load(file, recipe, { by: e.auth.email(), app: e.app });
rec.set("import_id", result.id);
rec.set("result", result);
e.app.save(rec);
return e.json(200, result);
}, $apis.requireAuth("users"));
// undoes the import of a file: removes the parts it created and restores the ones it updated
routerAdd("POST", "/api/pricelist/undo/{id}", (e) => {
const rules = require(__hooks + "/lib/rules.js");
rules.managerOnly(e);
const rec = e.app.findRecordById("uploads", e.request.pathValue("id"));
if (!rec.getString("import_id")) {
throw new BadRequestError(rules.text(e, "not_loaded"));
}
if (rec.getBool("undone")) {
throw new BadRequestError(rules.text(e, "already_undone"));
}
const result = $orch.import.undo(rec.getString("import_id"), { by: e.auth.email(), app: e.app });
rec.set("undone", true);
e.app.save(rec);
return e.json(200, result);
}, $apis.requireAuth("users"));
// summary per supplier: an SQL query with the parameters always bound, never concatenated
routerAdd("GET", "/api/pricelist/summary", (e) => {
const min = parseFloat(e.request.url.query().get("min") || "0") || 0;
// shape of a row: "" text, 0 whole number, -0 number with decimals
const rows = arrayOf(new DynamicModel({ supplier: "", parts: 0, average_price: -0 }));
e.app.db()
.newQuery("SELECT supplier, COUNT(*) AS parts, AVG(price) AS average_price FROM parts WHERE price >= {:min} GROUP BY supplier ORDER BY supplier")
.bind({ min: min })
.all(rows);
return e.json(200, rows);
}, $apis.requireAuth("users"));
Il modulo comune pb_hooks\lib\rules.js, con i testi nelle due lingue dell'app e la ricetta costruita dal file del fornitore:
// Shared rules of the price list: loaded inside the handlers with require(__hooks + "/lib/rules.js").
// The name doesn't end with .pb.js, so PocketBase doesn't load it on its own as a hook file.
// Inside a module, the constants and functions written here are visible to the module's functions.
// texts for people, in the two languages of the app (the page tells its language with Accept-Language)
const TEXTS = {
en: {
manager_only: "Only the warehouse manager can do this.",
already_loaded: "This file has already been loaded.",
not_loaded: "This file hasn't been loaded.",
already_undone: "This import has already been undone.",
rows_to_fix: "{n} rows need fixing: nothing was loaded.",
missing_columns: "The file doesn't have the columns {list}.",
},
it: {
manager_only: "Solo il responsabile del magazzino può farlo.",
already_loaded: "Questo file è già stato caricato.",
not_loaded: "Questo file non è stato caricato.",
already_undone: "Questo caricamento è già stato annullato.",
rows_to_fix: "Ci sono {n} righe da correggere: non è stato caricato niente.",
missing_columns: "Nel file mancano le colonne {list}.",
},
};
// the columns of the supplier's file: field of "parts" → accepted column titles (English or Italian)
const COLUMNS = {
code: ["Code", "Codice"],
description: ["Description", "Descrizione"],
price: ["Price", "Prezzo"],
supplier: ["Supplier", "Fornitore"],
};
const REQUIRED = ["code", "description", "price"];
// lang: the language of the request, "it" if the page asks for Italian, otherwise "en"
function lang(e) {
return /^it\b/i.test(e.request.header.get("Accept-Language")) ? "it" : "en";
}
// text: a text for the person, in the language of the request, with its {placeholders} filled in
function text(e, key, vars) {
let t = TEXTS[lang(e)][key];
for (const name in vars || {}) {
t = t.replace("{" + name + "}", String(vars[name]));
}
return t;
}
// managerOnly: only the warehouse manager (field "role" of the users, app.json → auth.roleField)
function managerOnly(e) {
if (!e.auth || e.auth.getString("role") !== "manager") {
throw new ForbiddenError(text(e, "manager_only"));
}
}
// pricelistRecipe: the import recipe for the supplier's file (Excel or CSV, one row per part). The file is
// analyzed first: the CSV separator and the column titles (in English or in Italian) come from the file itself.
function pricelistRecipe(e, file) {
let a = $orch.import.analyze(file, { header: "titles" });
if (a.format === "csv" && a.suggestedSeparator && a.suggestedSeparator !== a.separator) {
a = $orch.import.analyze(file, { header: "titles", separator: a.suggestedSeparator });
}
const titles = a.columns.map((c) => c.name);
const fields = {};
const missing = [];
for (const field of Object.keys(COLUMNS)) {
const title = titles.find((t) => COLUMNS[field].some((name) => name.toLowerCase() === t.trim().toLowerCase()));
if (title) {
fields[title] = field;
} else if (REQUIRED.includes(field)) {
missing.push(COLUMNS[field][lang(e) === "it" ? 1 : 0]);
}
}
if (missing.length) {
throw new BadRequestError(text(e, "missing_columns", { list: missing.join(", ") }));
}
const options = { format: a.format, header: "titles" };
if (a.format === "csv") {
options.separator = a.separator;
}
return { name: "Supplier price list", collection: "parts", options: options, fields: fields, key: ["code"] };
}
module.exports = { lang, text, managerOnly, pricelistRecipe };
Attività programmate (kind: "pocketbase-cron"), in un file .pb.js:
cronAdd("overdue-reminders", "0 7 * * 1-5", () => {
const late = $app.findRecordsByFilter("cases", "due != '' && due < {:today} && closed = false", "due", 500, 0, { today: new DateTime() });
for (const c of late) {
c.set("overdue", true);
$app.save(c);
}
});
Il motore registra ogni esecuzione di ogni lavoro (inizio, durata, esito, errore), lascia all'IT pausa, ripresa e «Avvia ora» dalla console, e scrive un evento quando un lavoro comincia a fallire. Quindi:
- dai a ogni lavoro un nome stabile e leggibile (il primo argomento di
cronAdd, minuscole, cifre e trattini): è la chiave dello storico e della pausa; - la pianificazione è in UTC, come in PocketBase (
"0 7 * * 1-5"sono le 07:00 UTC, le 08:00 o le 09:00 in Italia); - lascia uscire gli errori dalla funzione (o lancia il tuo
new Error("…")): uncatchvuoto fa sembrare a posto un lavoro fallito; - un riavvio dell'app interrompe le esecuzioni in corso: quello chiesto dall'IT e anche quello automatico dopo una modifica di porta interna, limite di memoria,
access, ruoli/raccolta/campo diautho modo della rete (le modifiche anetwork,toolAccess,concurrencysi rileggono senza riavvio). L'esecuzione resta nello storico come «interrotta». Scrivi i lavori lunghi in modo che si possano rifare, a pezzi, mai tutto o niente.
I dettagli sono nella sezione 11, «Lavori pianificati».
Rotte lente. Il motore aspetta al massimo 4 minuti e 50 secondi la risposta di una rotta dei tuoi script (il limite di PocketBase stesso è di 5 minuti). Se la rotta non ha risposto entro quel tempo, chi chiama riceve un 504 con un messaggio chiaro; se l'app chiude la connessione senza rispondere (anche di colpo, per esempio se il processo dell'app si ferma per un guasto), un 502. Un'app ferma o in riavvio dà 503. Il motore non ripete mai una richiesta già inviata: la rotta può aver finito il lavoro lo stesso, quindi controlla prima di riprovare (un secondo clic non deve creare un secondo record). Se chi chiama se ne va prima della risposta, il motore conta la richiesta ma non come riuscita né come errore (stato 499 nei suoi contatori). Un 504 o un 502 lascia un avviso negli eventi del motore. Quindi non scrivere rotte che durano minuti: per un lavoro lungo avvia un lavoro (sezione 11, jobs) e rispondi subito.
7.2 Migrazioni (release\pb_migrations\)
- Lo schema del database cambia solo con le migrazioni: file
<numero>_<nome>.js, applicati una volta sola in ordine di nome. Usa come numero un timestamp Unix (per esempio1793600000_parts.js) e fai crescere il numero a ogni nuovo file. - Una migrazione già consegnata non si modifica, non si rinomina e non si toglie mai (sezione 15, punto 8): per cambiare lo schema aggiungi un nuovo file. Scrivi sempre anche la funzione che torna indietro (secondo argomento di
migrate). - Nelle migrazioni usa l'
appche ricevi (migrate((app) => { ... })), non$app. Le costanti scritte fuori damigratesi possono usare. - Raccolte con
new Collection({ type: "base", name, listRule, viewRule, createRule, updateRule, deleteRule, fields: [...], indexes: [...] })eapp.save(collection). Tipi di campo:text,editor,number,bool,email,url,date,autodate,select,file,relation,json,geoPoint. Aggiungi semprecreatedeupdateddi tipoautodate(l'ordinamento per-createdne ha bisogno). - Non toccare le raccolte di sistema (
_superusers,_mfas,_otps,_externalAuths,_authOrigins) né quelle del motore (orch_*). Della raccoltauserscambia solo le regole e aggiungi campi tuoi (il ruolo, il reparto…). - Dati iniziali: da file in
release\seed\(CSV o Excel con lo strumentoimport, sezione 11; JSON con$os.readFileeJSON.parse(toString(...))). Mai dati personali reali senza accordo con l'IT, mai utenti né password.
Esempio (l'app di esempio): dati iniziali da un CSV consegnato con l'app.
/// <reference path="../pb_data/types.d.ts" />
// Initial price list, delivered with the app in release/seed (engine tool "import").
migrate((app) => {
$orch.import.load("release/seed/initial_parts.csv", {
name: "Initial price list",
collection: "parts",
options: { format: "csv", separator: ";" },
fields: { "Code": "code", "Description": "description", "Price": "price", "Supplier": "supplier" },
key: ["code"],
}, { app: app, by: "initial data" });
}, (app) => {
app.db().newQuery("DELETE FROM parts").execute();
});
Esempio: dati iniziali da un file JSON.
/// <reference path="../pb_data/types.d.ts" />
// Departments delivered with the app as JSON in release/seed (for JSON no engine tool is needed).
migrate((app) => {
const list = JSON.parse(toString($os.readFile("release/seed/departments.json")));
const departments = app.findCollectionByNameOrId("departments");
for (const d of list) {
const rec = new Record(departments);
rec.set("code", d.code);
rec.set("name", d.name);
app.save(rec);
}
}, (app) => {
app.db().newQuery("DELETE FROM departments").execute();
});
7.3 Il campo del ruolo
Se l'app ha ruoli, la prima migrazione aggiunge alla raccolta utenti il campo dichiarato in auth.roleField (role), con gli stessi valori di auth.roles, e mette le regole di users (vedi l'esempio nella sezione 6).
7.4 I dati delle persone nel pacchetto
Un pacchetto porta con sé solo i dati che servono a far partire l'app. I dati delle persone (clienti, dipendenti, fornitori con nome e indirizzo) sono un'altra cosa e hanno regole loro.
- Nel pacchetto vanno solo gli elenchi, le tabelle di servizio e pochi esempi inventati (nell'app di esempio: il listino iniziale e i reparti, sezione 7.2, punto 6).
- Un archivio vero con dati di persone non si chiude nello zip. Lo carica l'IT dopo l'installazione, con lo strumento di import del motore (scheda «Importa» della console, o
$orch.import), da un file che resta dove l'IT decide. Nel pacchetto metti al massimo la ricetta di import (release\import\*.json) e, inDELIVERY.md, la descrizione delle colonne attese. - I dati iniziali si importano con lo strumento del motore (
$orch.import.load, come nell'esempio della sezione 7.2), mai con SQL diretto né con un salvataggio riga per riga fatto a mano. Così ogni import resta nello storico degli import del motore e passa dai controlli (prova a secco, chiavi, relazioni). Per un piccolo elenco di servizio in JSON l'esempio della sezione 7.2 va bene. DELIVERY.mdha una riga obbligatoria «Dati personali»: quali dati di persone ci sono, da dove vengono e quali ruoli li vedono. Se non ce ne sono, scrivi «nessuno».- Nella lista di controllo finale (sezione 14) c'è la riga che lo verifica.
08 /Cosa uno script non può fare (il recinto)
Gli script di ogni app sono chiusi nella cartella dell'app. Il motore blocca, con un errore in inglese:
- avviare programmi (
$os.cmd,$os.exec) e chiudere il processo ($os.exit); - leggere file fuori dalla cartella dell'app (
$os.readFile,$filepath.walk,$filepath.glob,$filesystem.fileFromPath,$template.loadFiles,requirecon percorsi che escono, anche dentro un modulo); - scrivere, rinominare o cancellare file fuori da
state\($os.writeFile,$os.mkdir,$os.rename,$os.removee simili):app.json,release\elogs\gli script li possono solo leggere. Se l'app deve salvare un file suo (per esempio un file temporaneo o un export), lo mette instate\; i file caricati dalle persone vanno in un campo file di una raccolta; - usare il database per aprire o scrivere altri file:
ATTACH,DETACH,VACUUM INTOfuori dastate\, estensioni (load_extension); - usare nomi che Windows legge come un altro: una parte del percorso che finisce con un punto o uno spazio (
state.), o che contiene:(i flussi NTFS,state::$DATA), è rifiutata; - passare da collegamenti: un percorso che attraversa un collegamento simbolico (symlink) o una giunzione di Windows dentro la cartella dell'app è rifiutato, in lettura e in scrittura, anche se porta dentro la cartella (per esempio a
app.jsono astate\). Fa eccezione solo la cartellastate\stessa: l'IT la può sostituire con una giunzione per tenere i dati dell'app su un altro disco, e per gli script resta la solitastate\; i collegamenti più in basso, dentrostate\, sono rifiutati. Gli script non possono creare collegamenti e l'app non ne ha bisogno: non metterne nel pacchetto («Carica app» li rifiuta comunque); - raggiungere le porte interne delle altre app e le chiavi del motore (non sono nelle variabili d'ambiente);
- chiamare indirizzi di rete che non sono dichiarati in
network(sezione 8.1), e chiamare il motore o un'altra app dello stesso server (sezione 8.1); $apis.staticsu una cartella fuori dalla cartella dell'app;$app.onServe(),$app.restart(),$app.restoreBackup(),$app.rootCmd,unbind,unbindAll,bindsugli agganci dell'app,onSenddel client di posta e$app.unsafeWithoutHooks()(questi nomi per gli script non esistono: usarne uno dà un errore); percorsi che cominciano con una barra (/release/x.jsonè la radice del disco, rifiutato: scrivirelease/x.json).
La scheda app.json la cambia solo l'IT (un Proprietario, dalla scheda Configurazione della console del motore, oppure a mano): uno script non può darsi da solo un altro accesso, altri ruoli o altri strumenti.
Inoltre, per regola:
- Niente chiamate a servizi esterni o a Internet se l'IT non le chiede. Ogni indirizzo che gli script chiamano si dichiara in
app.json, camponetwork(sezione 8.1): il motore blocca gli altri. Un sistema aziendale in rete locale si usa solo se l'IT lo chiede. - Niente scambio di dati con altre app attraverso i loro file o le loro porte: se serve, chiedilo all'IT.
- Se ti serve un lavoro pesante (leggere Excel, creare PDF, mandare email, ridurre immagini…), usa lo strumento del motore (sezione 11). Se non c'è, non inventarne uno con trucchi: scrivi nelle note di consegna cosa servirebbe.
8.1 Rete in uscita (network)
Ogni chiamata di rete degli script ($http.send, $filesystem.fileFromURL, $filesystem.s3, e quelle che PocketBase fa per l'app: archivio S3, backup, OAuth2) passa dal motore, che permette solo gli indirizzi dichiarati in app.json:
"network": {
"allow": ["api.openai.com", "*.example.com", "erp.example.local:8443"],
"allowHttp": ["old-erp.example.local"]
}
- Una voce è un nome o un indirizzo IP, con la porta se serve (senza: 443 per https, 80 per http).
*.example.comvale per i nomi sottoexample.come solo per indirizzi pubblici (almeno due pezzi dopo l'*:*.comnon vale). Una voce sbagliata è ignorata e segnalata da «Carica app». Niente*da solo, nientehttps://, niente percorsi. - Solo https; l'http semplice solo per
allowHttp. - Il motore controlla l'indirizzo IP vero dopo la risoluzione DNS: un server della rete dell'azienda si raggiunge solo con una voce esatta (senza
*); la macchina stessa (127.0.0.1,::1) e169.254.xsolo con una voce scritta come indirizzo IP, mai attraverso un nome comelocalhost; il motore stesso e le altre app di questo server non si raggiungono mai da uno script, nemmeno con una voce (403, codiceapp_origin). - Anche il server di posta dell'app (SMTP:
$app.newMailClient(),$mails.*) è un indirizzo: dichiaralo con la porta, per esempio"smtp.example.com:587". - Redirect solo verso indirizzi dichiarati (al massimo 9); risposte fino a 32 MB; al massimo 300 secondi per chiamata (
timeout, senza: 120). - Una chiamata non permessa dà un errore in inglese che dice cosa dichiarare. Il motore registra ogni chiamata (mai il contenuto) nella scheda «Rete in uscita» dell'app; l'IT approva gli indirizzi in «Carica app» e li può cambiare da quella scheda.
- Il modo della porta lo decide l'IT, nel registro del motore (non in
app.json): enforce (Blocca, il modo di partenza) ferma quello che non è dichiarato, warn (Avvisa) lo registra soltanto. - Scrivi in
DELIVERY.mda cosa serve ogni indirizzo.
Modelli AI. Le app chiamano i modelli AI (OpenAI, Anthropic, Gemini, Mistral, Azure OpenAI, OpenRouter, Groq, DeepSeek…) solo dagli script, con $http.send e l'indirizzo del fornitore in network, mai dal browser: in una pagina la chiave sarebbe visibile a chiunque. La pagina chiama una rotta dell'app (routerAdd con $apis.requireAuth()), che chiama il modello. La chiave non sta mai nel codice, in app.json o nelle pagine: chiedi all'IT dove la tiene. Il motore legge dalle risposte il modello e i token (con OpenAI Chat Completions in streaming, chiedi stream_options: { include_usage: true }) e mostra consumi e costi stimati nella pagina «AI» della console; non registra mai i messaggi. Il testo completo, con un esempio, è nel capitolo «Strumenti del motore» (pyrite.exe tools --md --lang it).
Gli script non chiamano il motore né le altre app. Uno script non può chiamare con $http.send (o con $filesystem.fileFromURL) il motore (console, portale, /api/platform/…) né un'altra app dello stesso server, nemmeno dichiarandone l'indirizzo in network: il motore riconosce le chiamate che partono dalle app e risponde 403 con "code": "app_origin", e l'IT lo vede negli eventi. Resta permesso chiamare la propria app (la sua porta o il suo nome): la chiamata vale come una richiesta arrivata dalla rete, quindi senza i permessi di chi siede al server (chiamata come http://127.0.0.1:PORTA è servita in chiaro anche con l'HTTPS acceso; chiamata con il nome o l'indirizzo di rete del server riceve il rimando a https). Resta permesso chiamare un altro programma che gira sullo stesso server (per esempio un modello AI locale), dichiarato in network come indirizzo IP con la porta: riceve l'intestazione X-Pyrite-App, che può ignorare. Un'intestazione X-Pyrite-App scritta dallo script si toglie sempre. Se due app devono scambiarsi dati, chiedilo all'IT e scrivilo in DELIVERY.md.
Agganci. Negli script non esistono unbind, unbindAll e bind degli agganci dell'app, onSend del client di posta e $app.unsafeWithoutHooks(): i gestori del motore non si tolgono e non si saltano. Per aggiungere un gestore usa le funzioni di sempre (onRecordCreate, onMailerSend…) o bindFunc.
Il limite del recinto. Tutto questo è una difesa dentro il programma: lo script gira nello stesso processo del motore dell'app, con lo stesso utente di Windows, quindi riduce molto quello che uno script può fare ma non è una garanzia piena contro uno script scritto apposta per uscirne. La garanzia piena per le app di terzi (un utente di Windows per ogni app) è prevista per una versione futura. Fino ad allora non contare solo sul recinto per script scritti da qualcuno di cui l'IT non si fida.
09 /Le pagine (release\pb_public\)
Il motore serve le pagine sulla porta dell'app, dallo stesso indirizzo delle API: http://SERVER:8105/ per le pagine e http://SERVER:8105/api/... per i dati. In un'app PocketBase un percorso che non ha un file in pb_public risponde con index.html (le app a pagina unica funzionano con qualunque percorso).
- Percorsi sempre relativi (
style.css,lib/pocketbase.umd.js,/api/...). Mai indirizzi o porte scritti nel codice, e maihttp://scritto a mano: la stessa app può essere servita in http o in https (lo decide l'IT, dalla pagina Certificati del motore). - Nessuna risorsa da Internet: niente CDN, Google Fonts, librerie remote, immagini esterne. Tutto quello che serve sta in
pb_public(o inpublicper le app statiche). Caratteri: quelli di sistema (system-ui, "Segoe UI", sans-serif). - SDK di PocketBase: una copia locale di
pocketbase.umd.jsinpb_public\lib\, la stessa dell'app di esempio (è dentro l'esempio scaricabile, con la sua licenza). Si crea conconst pb = new PocketBase("/");e trova da solo l'accesso consegnato dal motore. - Il motore inserisce da sé, nelle pagine HTML delle app con login, lo script dell'accesso unico (
/__platform/sso.js) e, nelle pagine di ogni app PocketBase, lo script della protezione dalle modifiche concorrenti (/__platform/concurrency.js, regola 10): non aggiungerli e non toglierli. Se una chiamata risponde401, ricarica la pagina (location.reload()): il motore rifà l'accesso. Vale per tutte le chiamate, anche per quelle dei moduli (salvataggi, caricamenti di file), non solo per l'elenco. - Collegamento per tornare al portale:
<a href="/__platform/portal">← Portale aziendale</a>(nella lingua dell'app). Nessun pulsante «Esci». - I percorsi che iniziano con
/__platform/,/_/,/api/platform/e/api/tools/sono del motore: non usarli per altro. Sulla porta dell'app/_/rimanda alla console del motore e/api/collections/_superusers/è chiuso (403): il pannello di PocketBase e l'accesso da superuser lì non ci sono./__platform/inforestituisce in JSON l'identificativo dell'app, il nome e l'indirizzo del portale ({ app, name, portal });/__platform/tools.jsè l'aiuto per gli strumenti dalle pagine (regola 11). - Mostra i messaggi d'errore che arrivano dal server (
err.response.messagecon l'SDK): le rotte dell'app li scrivono nella lingua della richiesta (sezione 10); quelli di PocketBase stesso (validazione dei campi) sono in inglese. - Nascondere un pulsante a chi non ha il ruolo è solo comodità: la protezione vera sono le regole (sezione 6) e le rotte.
- Pagine semplici, leggibili su schermo piccolo: a 390 px di larghezza la pagina non scorre di lato, e una tabella larga sta in un contenitore che scorre da solo (
overflow-x: auto). Tema chiaro e scuro (color-scheme: light darkeprefers-color-scheme), un'etichetta su ogni campo (<label for="…">, anche sul campo del file), contrasto sufficiente. - Modifiche concorrenti. Ogni modifica di un record (
PATCH, o unPUTche aggiorna, anche dentro/api/batch) dichiara la versione che cambia (If-Match: *vuol dire sovrascrivi comunque; unaDELETEè controllata solo se portaIf-Match, lo script non lo aggiunge): lo script/__platform/concurrency.jsaggiunge da sé l'intestazioneIf-Matchper i record che la pagina ha letto e, se intanto qualcun altro ha salvato, la risposta è409e lo script mostra un avviso con «Ricarica»; una modifica senza nessuna versione riceve428. Quindi: quando usifields, aggiungi$version(fields: "id,title,$version"); una pagina che cambia i record da un elenco che si aggiorna da solo (modifica in riga, kanban) manda il"$version"del record che mostra; prima di cambiare il record dell'utente stesso rileggilo (getOneoauthRefresh), perchépb.authStore.recordnon ha$version. Mostra il409alla persona in parole semplici («L'ha cambiato qualcun altro: ricarica»). I dettagli sono nella sezione 11, regola 9. - Strumenti dalle pagine. Export, PDF, foto, statistiche, numerazioni, storico e import si possono usare direttamente dalle pagine, con
/__platform/tools.js(PyriteTools.call,run,upload,download…), solo per gli strumenti e le persone chetoolAccesspermette.GET /api/toolsdice che cosa può usare questa persona: usalo per mostrare o nascondere i pulsanti, ma ricorda che il motore controlla comunque ogni chiamata. Le operazioni lunghe diventano lavori in sottofondo con l'avanzamento; i file prodotti si scaricano con un collegamento che vale una volta sola (sezione 11, regola 8).
Esempio (l'app di esempio, pb_public\index.html e style.css, estratti):
<form id="fUpload">
<label for="file" data-t="file_label">Supplier's Excel or CSV file</label>
<input id="file" type="file" name="file" accept=".xlsx,.csv" required>
<p class="muted small" data-t="file_hint">Columns: Code, Description, Price and, if there is one, Supplier (titles in English or in Italian).</p>
<button type="submit" data-t="upload_button">Upload and import</button>
</form>
.table { overflow-x: auto; } /* a wide table scrolls sideways inside its box: the page stays still */
Lo script della pagina (pb_public\app.js, estratti): l'accesso consegnato dal motore, il caricamento del file in una raccolta e la chiamata alla rotta dell'app, con il 401 che ricarica la pagina.
const pb = new PocketBase("/");
const $ = (id) => document.getElementById(id);
const me = pb.authStore.record; // { id, email, name, role, ... }
let lastUpload = ""; // the "uploads" record of the last import, for the Undo button
async function upload(ev) {
ev.preventDefault();
const btn = ev.target.querySelector("button");
btn.disabled = true;
$("undo").hidden = true;
$("result").textContent = t("importing");
try {
const rec = await pb.collection("uploads").create(new FormData(ev.target)); // the file goes into the collection
const res = await pb.send("/api/pricelist/import/" + rec.id, { method: "POST" }); // the app's route imports it
$("result").textContent = t("done", res);
lastUpload = rec.id;
$("undo").hidden = false;
ev.target.reset();
list();
summary();
} catch (err) {
if (err?.status === 401) return location.reload(); // session expired: the engine signs in again from the portal
$("result").textContent = err.response?.message || err.message;
const res = err.response?.result; // the rows to fix, with row number and reason
if (res?.errors?.length) {
const ul = document.createElement("ul");
for (const r of res.errors) ul.appendChild(document.createElement("li")).textContent = `${t("row", r)}${r.column ? " (" + r.column + ")" : ""}: ${r.reason}`;
$("result").appendChild(ul);
}
} finally {
btn.disabled = false;
}
}
10 /Lingue e testi
- Un'app può essere in qualsiasi lingua: quella delle persone che la usano, o più d'una se la richiesta lo dice. Il motore stesso parla prima inglese e accanto italiano. I testi per le persone sono semplici, senza gergo tecnico, anche negli stati vuoti («Nessun articolo»).
- I nomi tecnici e i commenti del codice sono in inglese, qualunque sia la lingua dell'app: raccolte, campi, ruoli, rotte, file, variabili, funzioni, commenti. I nomi che vedono le persone si traducono nella pagina (il ruolo
managersi mostra come «responsabile» a chi usa l'italiano). - Quale lingua mostrare. Segui la scelta fatta nel portale: il selettore della lingua del portale scrive il cookie
orch_lang(enoit) in http e__Host-orch_lang(Secure,Path=/) in https, valido su tutto il motore e anche sulle porte delle app; poi la lingua del browser; poi la lingua principale dell'app. Un'app legge tutti e due i nomi (prima quello con__Host-) e, se ha un suo selettore, scrive quello giusto per lo schema della pagina (orch_lang=it; path=/; max-age=31536000; SameSite=Laxin http,__Host-orch_lang=it; path=/; max-age=31536000; SameSite=Lax; Securein https), così la scelta è la stessa dappertutto. Nell'app di esempio:
function pickLang() {
const ck = (n) => (document.cookie.match(new RegExp('(?:^|;\\s*)' + n + '=(en|it)\\b')) || [])[1];
const chosen = ck('__Host-orch_lang') || ck('orch_lang');
const browser = (navigator.languages || [navigator.language || ""]).map((l) => l.slice(0, 2).toLowerCase()).find((l) => TEXTS[l]);
return chosen || browser || "en";
}
- Messaggi del server nella lingua della pagina. La pagina dice la sua lingua all'SDK di PocketBase (
pb.lang = lang), che la manda nell'intestazioneAccept-Languagedi ogni richiesta; le rotte dell'app la leggono cone.request.header.get("Accept-Language")e rispondono in quella lingua (vedilib/rules.jsnella sezione 7.1). - Numeri e date nel formato della lingua:
Intl.NumberFormat(lang === "it" ? "it-IT" : "en-US", …),toLocaleDateString(…). - I messaggi d'errore dicono cosa fare: «Solo il responsabile del magazzino può farlo», non «403 Forbidden».
- I messaggi che gli script ricevono dal motore (strumenti
$orch, recinto) sono in inglese: sono per chi scrive l'app. Se la persona li deve vedere, l'app scrive un suo messaggio nella lingua della persona.
11 /Strumenti del motore ($orch)
Il motore ha una cassetta di strumenti standard scritti in Go: componenti pronti per i lavori pesanti, che le app usano dagli script JS e, quando l'IT lo permette, dalle loro pagine, senza servizi esterni, senza programmi da avviare e senza librerie da aggiungere. Prima di scrivere a mano un lettore di file, un generatore di documenti o un invio di email, guarda se lo strumento c'è.
Regole comuni a tutti gli strumenti:
- Si attivano per app, in
app.json:"tools": ["import"]. Un Proprietario li può attivare anche dalla console del motore (pagina dell'app, scheda Configurazione). Uno strumento non attivo chiamato da uno script dà un errore che spiega come attivarlo. Elenca solo gli strumenti che l'app usa davvero. I nomi degli strumenti, delle funzioni, delle opzioni e dei risultati sono in inglese, come in questo capitolo: i nomi italiani come$orch.importanegli script non valgono. - Si usano dagli script dell'app (
pb_hooksepb_migrations) attraverso l'oggetto globale$orch:$orch.<strumento>.<funzione>(…). Le funzioni segnate «dalle pagine dell'app» si possono chiamare anche dal frontend inpb_public, con le API del motore sotto/api/tools/(regola 8); per tutto il resto il frontend chiama una rotta dell'app (routerAdd) che controlla chi è l'utente e poi usa lo strumento. - I percorsi dei file partono dalla cartella dell'app (quella che contiene
app.json), per esempio"release/seed/parts.xlsx". Un percorso che esce dalla cartella è rifiutato, e così uno che passa da un collegamento simbolico o da una giunzione di Windows dentro la cartella, anche se porta dentro: fa eccezione solo la cartellastatestessa, che l'IT può spostare su un altro disco con una giunzione. - Risultati e errori. Le funzioni restituiscono oggetti JS semplici (si possono passare a
e.jsoncosì come sono). Gli errori arrivano come eccezioni con il messaggio in inglese (la lingua principale del motore: i messaggi per chi scrive le app non si traducono): gestiscili contry { … } catch (err) { … err.message … }; se non li gestisci, la rotta risponde 400 con lo stesso messaggio. Se l'app deve mostrarli alla persona, scrive il suo messaggio nella lingua dell'app. - L'app su cui lavorare. Negli hook, nelle rotte e nei
cronAddle funzioni che scrivono nel database usano da sole il$appdel momento, che negli hook e nelle rotte PocketBase fa coincidere cone.app, anche quando l'hook gira dentro una transazione: non serve passare niente, e{ app: e.app }nelle opzioni dà lo stesso risultato. L'app va passata nelle opzioni in due casi: nelle migrazioni, sempre{ app: app }(lì è obbligatoria, perché la migrazione tiene aperta la sua transazione), e dentrorunInTransaction((txApp) => { … }), con{ app: txApp }: con un'altra app la scrittura resterebbe in attesa della transazione. - Gli strumenti scrivono come il server, non come l'utente: le regole API delle raccolte non si applicano alle scritture. Dagli script il controllo di chi può lanciare l'operazione lo fa la rotta che chiama lo strumento; dalle pagine lo fa il motore con
toolAccess(regola 8). Quando uno strumento legge dati per conto di una persona (export, statistiche) li legge invece con le regole dell'app, come le API di PocketBase: la regola per l'elenco della raccolta, niente campi nascosti, le email solo a chi le può vedere, gli hookonRecordsListRequestdell'app e il limite di richieste della raccolta (<raccolta>:list). - In arrivo. Gli strumenti segnati «in arrivo» sono decisi ma non ancora nel motore:
$orch.<id>esiste, ma ogni sua funzione risponde che non è disponibile. Non costruire niente che ne dipenda. - Dalle pagine dell'app. Il motore offre le funzioni segnate «dalle pagine dell'app» anche come API sulla porta dell'app, sotto il prefisso riservato
/api/tools/(un pacchetto i cui script registrano rotte lì non si carica; un'app che le ha comunque, per esempio con il percorso costruito mentre lo script gira, le tiene, e le sue pagine non hanno gli strumenti finché non le sposta; lo stesso per le rotte con un segnaposto che rispondono anche lì, come/api/{coll}/{id}/approve: le rotte delle app vanno sotto/api/<id>/…). Si chiamano con il login dell'app (il token dell'utente dell'app inAuthorization, come le API di PocketBase) e sono chiuse per difetto: servono lo strumento in"tools"e il permesso in"toolAccess". Il limite di richieste dell'app vale anche qui (per esempio una regola su/api/tools/)."toolAccess": { "<strumento>": { "pages": true, "minRole": "editor", "destructiveRole": "admin", "collections": ["parts"], "recipes": ["Parts price list"], "maxRows": 5000, "guests": false } }.pagesapre lo strumento alle pagine;minRoleè il ruolo minimo (dai ruoli diauth.roles, dal più al meno potente, letti dal campoauth.roleField; senzaminRolebasta aver fatto l'accesso); le funzioni che tolgono dati (effettodestructive) vogliono in piùdestructiveRole, e senza quella voce nessuno le può usare;collectionssono le sole raccolte su cui lo strumento lavora dalle pagine;recipes(import) le ricette salvate che le pagine possono usare;snapshots(stats) le istantanee salvate dagli script che le pagine possono leggere;maxRowsle righe al massimo per chiamata;guests: trueapre a tutti, con o senza accesso e con qualunque ruolo, solo le funzioni di sola lettura brevi; il caricamento dei file, i lavori e le funzioni che scrivono o tolgono dati restano agli utenti dell'app conminRole. Per questoguestseminRolevanno insieme:guestsper leggere,minRoleper il resto. SenzaminRolele funzioni che scrivono sono di chiunque abbia un utente dell'app (con o senzaguests): in un'app pubblica sono gli utenti che crea l'IT e quelli che creano gli script dell'app (vedi «Utenti e ruoli nelle app pubbliche», qui sotto); se gli script dell'app creano utenti, lì serveminRole. Nelle app con loginguestsnon vale (lì chi arriva alle pagine è già entrato dal motore). Una voce scritta male (un nome che non c'è, un ruolo che non è inauth.roles,guestsin un'app con login) tiene lo strumento chiuso, con un messaggio che dice cosa correggere. Il motore rileggeapp.jsonquando cambia: non serve riavviare l'app. Nelle app pubbliche, appenatoolAccessusaminRoleodestructiveRole, il motore protegge il campo del ruolo: con le API dell'app nessuno si dà né dà un ruolo più potente del proprio; i ruoli più alti li dà l'IT dal pannello dell'app.- Utenti e ruoli nelle app pubbliche. Le iscrizioni le decide l'IT: nessuna app ha l'iscrizione libera, nemmeno le pubbliche. Il motore rifiuta (403, con un messaggio nella lingua della persona) ogni richiesta API che crea un record in una raccolta di tipo auth (tutte tranne
_superusers) se non viene da un superuser dell'app, cioè dal pannello che apre il motore: anche dentro/api/batche con OAuth2 quando l'accesso creerebbe un utente nuovo. UnacreateRulesu una raccolta auth non cambia niente: il motore la blocca e lo dice all'IT (un avviso in «Carica app», poi un evento all'avvio e una riga nei «Controlli consigliati» della pagina dell'app; per la raccolta degli utenti di un'app con login, che il motore tiene chiusa comunque, solo una riga d'informazione); lo schema dell'app non si tocca. Quindi non costruire pagine d'iscrizione: lasciacreateRuleanull. Gli utenti di un'app pubblica li crea l'IT dal pannello dell'app (la scheda «Dati», uno alla volta: ogni utente creato, cambiato o tolto lì lascia un evento con chi l'ha fatto, e da lì nessuno entra come un utente dell'app); se l'app deve far entrare persone da sola (per esempio un invito con un codice), lo fa uno script dell'app con$app.savein una rotta sua, che controlla il codice e dà il ruolo giusto: lì il motore non si mette in mezzo, e tocca a quella rotta non diventare un'iscrizione libera. In un hookonRecordAuthWithOAuth2Requestlo script può trovare o creare da sé l'utente e metterlo ine.recordprima die.next(); see.recordresta vuoto l'accesso si rifiuta. Nelle app con login gli utenti sono la copia della rubrica del motore (gruppi e accessi della console): lì non si creano utenti nemmeno dal pannello dell'app. GET /api/toolsdice quali strumenti le pagine possono usare e, per ogni funzione, se questa persona la può chiamare (allowed, e se noreason): serve per mostrare o nascondere i pulsanti.POST /api/tools/<strumento>/<pezzo>chiama la funzione (metodo e percorso sono nel catalogo, vocimethodepath; il corpo è l'oggetto JSON descritto dainputSchema). Le funzioni lunghe (long) diventano un lavoro in sottofondo: la risposta è202 { job };GET /api/tools/jobs/<id>?wait=1dà lo stato (queued,running,finished,failed,cancelled, conprocessed,total,result,error,files) e aspetta fino a 5 secondi che finisca;DELETE /api/tools/jobs/<id>lo annulla. Un lavoro lungo («pesante») alla volta per app ("toolJobs": { "heavy": 2 }inapp.jsonper averne di più, fino a 4): gli altri aspettano in coda. Un lavoro dura al più un'ora, poi si ferma (failed); se non si ferma da solo entro due minuti (dalla fine dell'ora o dall'annullamento) il motore lo chiude e il suo posto nella coda si libera (data.tools.code:foundations.job_stuckofoundations.job_stuck_cancelled); la sua funzione però può ancora finire in sottofondo (per le funzioni che scrivono lo dice un evento nel registro del motore), quindi prima di riprovare si controllano i dati. Il risultato di un lavoro (result) è al più di 8 MB in JSON: oltre, il lavoro risultafailed(foundations.result_too_big); per tanti dati si usa una funzione che produce un file da scaricare. I lavori stanno in memoria: quando l'app si ferma o riparte si perdono, anche quelli a metà; per questo un riavvio dell'app chiesto da una modifica della sua scheda aspetta che i lavori delle pagine in corso finiscano, e intanto quelli in coda non partono (al riavvio si perdono). L'IT li vede e li annulla nella console, scheda «Strumenti per le pagine», riquadro «Lavori partiti dalle pagine» (GET /api/platform/apps/<app>/tools/jobs,DELETE …/tools/jobs/<id>). Con 3 lavori dell'app che il motore ha chiuso dopo i due minuti e che vanno ancora avanti in sottofondo, i lavori nuovi si rifiutano (503,foundations.jobs_stuck) finché l'app non riparte.- I file:
POST /api/tools/files(multipart, prima il campotool, poifile) li mette nel deposito dell'app per quello strumento e risponde{ id, name, size, expires }; estensioni e dimensione le decide lo strumento (vocefilesdel catalogo). Un file è solo di chi l'ha caricato (per gli altri non c'è: 404), resta due ore dall'ultimo uso e si perde se l'app riparte. Ogni persona tiene al più 10 file e 500 MB: oltre si tolgono i suoi file più vecchi, e se sono ancora in uso il file nuovo si rifiuta. I file prodotti da uno strumento si scaricano con un gettone:POST /api/tools/files/<id>/tokendàurl, valido 10 minuti e una volta sola (per un file ne valgono al più 5 insieme: uno nuovo prende il posto del più vecchio), da usare in un normale<a href>; la risposta è un allegato, mai una pagina. Una domandaHEADsullo stesso indirizzo (la fanno alcuni gestori di download) non consuma il gettone. - Gli errori sono le risposte di PocketBase (
status,messagenella lingua della pagina,data.tools.codecon la chiave del messaggio). Ogni operazione dalle pagine che scrive o esporta (anche una funzione di sola lettura che produce file da scaricare) lascia un evento nel registro del motore (categoria «strumenti»). Nelle app con login la porta d'ingresso rifiuta (403,foundations.cross_origin) le richieste che scrivono (POST,PUT,PATCH,DELETE, per tutte le API dell'app e non solo per gli strumenti) partite dal browser da un'altra origine: da un'altra app del motore, da un altro sito o da una pagina in sandbox. Le pagine dell'app verso la sua porta, il portale del motore e i programmi fuori dal browser passano normalmente.
- Il motore serve un piccolo aiuto per le pagine,
/__platform/tools.js, che fa tutto questo con il token dell'app (PyriteTools.list,call,run,upload,job,waitJob,cancelJob,download):
<script src="/__platform/tools.js"></script>
<script>
async function loadPriceList(input) {
const file = await PyriteTools.upload("import", input.files[0]);
const trial = await PyriteTools.run("import", "jobs", { file: file.id, recipe: "Parts price list", dryRun: true },
{ onProgress: (job) => console.log(job.processed + " / " + job.total) });
if (trial.status !== "finished" || trial.result.rejected > 0) {
return trial;
}
return PyriteTools.run("import", "jobs", { file: file.id, recipe: "Parts price list" });
}
</script>
- Modifiche concorrenti (vale per le pagine, non è uno strumento). Il motore protegge ogni app PocketBase dalle modifiche concorrenti, di serie: ogni record delle API ha la voce
"$version"(e la lettura di una scheda anche l'intestazioneETag), e ogni modifica (PATCH, anche dentro/api/batch) dichiara la versione che cambia, con l'intestazioneIf-Matcho con"$version"nel corpo. Se il record è cambiato nel frattempo la risposta è 409, con chi e quando indata["$version"]; senza versione è 428. Il motore inserisce da solo nelle pagine dell'app lo script/__platform/concurrency.js, che aggiungeIf-Matchalle modifiche dei record letti dalla pagina e mostra un avviso con «Ricarica»; la cosa più sicura resta salvare l'oggetto letto, con le modifiche, perché porta con sé il suo"$version". Inapp.json,"concurrency":"strict"(predefinito),"warn"(non rifiuta mai: le modifiche senza versione o con una versione vecchia passano e si contano negli eventi del motore, così l'IT vede cosa strict rifiuterebbe),"off". Gli script dell'app ($app.save,e.app.save) e la console del motore non sono soggetti. Cose da sapere: confieldsaggiungi$version(per esempiofields: "id,title,$version"), altrimenti la voce non c'è e in strict la modifica è 428. Lo script ricorda per prime le versioni delle schede lette da sole e dei salvataggi; quelle degli elenchi, del tempo reale e delle risposte di accesso le usa solo se non ne ha una di quelle: una pagina che modifica i record da un elenco che si aggiorna da solo (tabella con modifica in riga, kanban) deve mandare nel corpo il"$version"del record che mostra (oppure chiamarepyriteConcurrency.remember(record)quando lo mostra), altrimenti dopo il primo salvataggio dello stesso record può ricevere 409 anche se mostra già i dati nuovi. In"warn"lo script fa come in strict, ma nessuna modifica si ferma. Il record dipb.authStorenon basta per modificare il profilo: nelle app con login arriva dall'accesso unico del motore senza"$version"(in strict la modifica è 428), nelle app pubbliche ha la versione del momento dell'accesso; prima di modificarlo rileggilo (getOneoauthRefresh) e salva l'oggetto riletto. Il motore apre la sua transazione solo intorno alla scrittura nel database, dopo gli hook dell'app e dopo il caricamento dei file; negli hook scrivi comunque con l'app dell'hook (e.app), che dentro un lotto/api/batchè una transazione.
Il catalogo completo, sempre allineato al motore che hai davanti, è in GET /api/platform/tools (solo con una chiave della console: Proprietari e Admin) e nella pagina «Strumenti» della console: per ogni funzione dice anche l'effetto (effect: read, write, destructive), se è lunga, se si usa dalle pagine e gli schemi JSON di ingresso e di uscita (inputSchema, outputSchema). Questo capitolo si stampa con pyrite.exe tools --md --lang it (senza --lang, in inglese).
| strumento | app.json | stato | cosa fa |
|---|---|---|---|
| Export Excel e CSV | export | pronto | Crea dai record di una raccolta (con filtri e ordine) un file Excel o CSV da scaricare, con colonne, numeri e date nel formato giusto, oppure riempie un modello Excel già impaginato. |
| Immagini | images | pronto | Le foto senza programmi esterni: riduce, comprime, converte in JPEG o PNG, raddrizza le foto del telefono (l'orientamento EXIF applicato ai pixel), toglie la posizione e i dati personali dell'EXIF tenendo il profilo colore. Una regola per campo in app.json lo fa da sola a ogni caricamento; import in blocco delle foto da una cartella o da uno zip, con prova, doppioni e annullamento; un selettore di foto per le pagine dell'app. |
| Import da Excel, CSV e JSON | import | pronto | Legge file Excel (.xlsx e i vecchi .xls), CSV e JSON come fa Power Query: riconosce colonne e tipi (numeri all'italiana o all'inglese, date, codici con zeri iniziali, celle unite) e carica le righe in una raccolta dell'app secondo una «ricetta» che si riusa, oppure tutto il file in più raccolte (un foglio per raccolta, anche più raccolte da un foglio solo, tutto o niente). Con una chiave, per esempio la targa, un nuovo import aggiorna i record invece di duplicarli; ogni caricamento si può annullare. |
| Lavori pianificati | jobs | pronto | I lavori che gli script dell'app pianificano con cronAdd, sotto controllo: ogni esecuzione registrata (quando è partita, quanto è durata, com'è finita, l'errore), pausa e ripresa che restano dopo un riavvio, «Avvia ora», un evento del motore quando un lavoro fallisce. Nella console, la pagina «Lavori pianificati» con tutte le app insieme. |
| Numerazioni | numbering | pronto | Numeri progressivi sicuri anche quando più persone salvano insieme (per esempio il numero di una richiesta d'acquisto per anno), nel formato che scegli: niente doppioni e niente buchi, perché il numero si prende nella stessa transazione del salvataggio. |
pdf | pronto | Crea documenti PDF, e gli stessi documenti come un file HTML unico, da un documento fatto di blocchi (titoli, testi, campi, tabelle con i totali, immagini, codici QR e a barre) o dai modelli dell'app riempiti con i dati, nello stile di stampa di Pyrite con i caratteri IBM Plex dentro. Legge anche i PDF che hanno il testo (informazioni, testo con le posizioni, campi dei moduli, una pagina come immagine) e unisce più PDF. Niente OCR. | |
| Statistiche e stime | stats | pronto | I conti «oltre Excel» sulle raccolte dell'app, in una sola query SQL: gruppi per campi o per periodi di una data (giorno, settimana ISO, mese, trimestre, anno, anche in un fuso orario), somme, medie, minimi, massimi, conteggi dei valori diversi, mediane e percentili, con il filtro di PocketBase. Istantanee salvate che i lavori pianificati rifanno, e funzioni per le serie piccole: mediana, percentili, media mobile, tendenza lineare, variazione rispetto al periodo prima. |
| Storico delle modifiche | history | pronto | Tiene traccia di chi ha cambiato cosa e quando nei record dell'app, con il valore di prima e quello di dopo, e riporta una versione di prima (anche di un record eliminato). |
| Email e notifiche | notifications | in arrivo | Invio di email da modelli e avvisi dentro le app, con il server di posta configurato dall'IT. |
| Prenotazioni | bookings | in arrivo | Calendario di risorse condivise (sale, auto di cortesia, postazioni) con il controllo delle sovrapposizioni. |
Export Excel e CSV (export)
Crea dai record di una raccolta (con filtri e ordine) un file Excel o CSV da scaricare, con colonne, numeri e date nel formato giusto, oppure riempie un modello Excel già impaginato.
Si attiva con "tools": ["export"] in app.json. Categoria: Dati.
Funzioni
$orch.export.csv(spec)
Un file CSV dai record di una raccolta (o dalle righe che dà lo script): separatore a scelta, virgolette come nella RFC 4180, numeri e date scritti con il profilo (italian: ; e la virgola nei decimali, gg/mm/aaaa, UTF-8 con il BOM; international: , e il punto, date ISO). Una cella di testo che comincia con = + - @ riceve un apostrofo davanti (formule iniettate). Dagli script legge come il server, al più 1.000.000 di righe; il file va nel deposito dell'app (o in state/ con path).
Restituisce: { file: { id, name, size, expires, tool, downloadable }, path, name, size, format, rows, sheets: [{ name, collection, rows }] }.
file è il file nel deposito: mandalo con $orch.export.send, o dallo a una persona con { for: e.auth } (la pagina lo scarica con PyriteTools.download). Con path, path dice dov'è il file.
routerAdd("GET", "/api/orders/export.csv", (e) => {
// scripts read AS THE SERVER (no API rules): the filter decides what this person gets
const r = $orch.export.csv({
collection: "orders",
filter: "owner = {:me} && status = {:status}",
params: { me: e.auth.id, status: "paid" },
sort: "-created",
columns: [
{ field: "code", title: "Order" },
{ field: "created", title: "Date", format: "date" },
{ field: "total", title: "Total", format: "number", total: "sum" },
],
profile: "italian", // ; between the fields, comma in the decimals, dates dd/mm/yyyy
});
return $orch.export.send(e, r);
}, $apis.requireAuth("users"));
Dalle pagine dell'app: POST /api/tools/export/csv (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.export.xlsx(spec)
Un file Excel scritto una riga alla volta (anche un milione di righe): intestazione in grassetto con i colori scelti, larghezza delle colonne, formati dei numeri e delle date di Excel, prima riga bloccata, filtri, più fogli (sheets), riga dei totali (columns[].total). Nelle celle sempre valori, mai formule che vengono dai dati; i totali sono formule SUBTOTAL scritte dal motore, con il valore già calcolato.
Restituisce: { file: { id, name, size, expires, tool, downloadable }, path, name, size, format, rows, sheets: [{ name, collection, rows }] }.
const r = $orch.export.xlsx({
name: "orders-2026",
sheets: [
// scripts read AS THE SERVER: only this person's orders
{ name: "Paid", collection: "orders", filter: "owner = {:me} && status = 'paid'", params: { me: e.auth.id }, columns: [{ field: "code" }, { field: "total", format: "number", total: "sum" }] },
{ name: "Open", collection: "orders", filter: "owner = {:me} && status = 'open'", params: { me: e.auth.id }, columns: [{ field: "code" }, { field: "total", format: "number" }] },
],
header: { fill: "#1F4E79", color: "#FFFFFF" },
for: e.auth, // the file goes to this person's store: the page downloads it with PyriteTools.download(r.file.id)
});
Dalle pagine dell'app: POST /api/tools/export/xlsx (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.export.fill(template, data, options?)
Riempie un modello .xlsx dell'app: le celle con il solo segnaposto prendono il valore com'è (i numeri restano numeri, le date restano date), le righe vanno nella tabella di Excel con quel nome, che cresce; totali, grafici e tabelle pivot del modello si aggiornano all'apertura del file.
Restituisce: { file: { id, name, size, expires, tool, downloadable }, path, name, size, format, rows, sheets: [{ name, collection, rows }], missing }.
missing elenca i segnaposto del modello senza valore (lasciati vuoti).
const r = $orch.export.fill("monthly-report.xlsx", {
values: { title: "Orders of March", month: "2026-03-01", target: 25000 },
// scripts read AS THE SERVER: the filter decides which orders go in the report
tables: { Data: { collection: "orders", filter: "created >= '2026-03-01' && created < '2026-04-01'", sort: "created" } },
}, { name: "orders-march" });
return $orch.export.send(e, r);
Dalle pagine dell'app: POST /api/tools/export/fill (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.export.send(e, result)
Manda il file del risultato come download (allegato, senza cache) dalla rotta di uno script; un file del deposito fatto da uno script senza «for» si toglie dopo l'invio. Usala come ultima cosa della rotta: return $orch.export.send(e, result).
Restituisce: null.
return $orch.export.send(e, $orch.export.csv({ collection: "parts" }));
$orch.export.templates()
I modelli dell'app in un elenco unico con l'origine: quelli del pacchetto dell'app (release/templates/) e quelli caricati dall'IT dalla console (state/export/templates/, che sostituiscono un modello del pacchetto con lo stesso nome), con tabelle e segnaposto.
Restituisce: [{ name, origin, size, modified, overrides, tables, placeholders, error }].
const names = $orch.export.templates().filter((t) => !t.error).map((t) => t.name);
Dalle pagine dell'app: POST /api/tools/export/templates (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.export.recent(options?)
Gli export di una persona ancora da scaricare (lavori e file delle ultime 2 ore); senza «for», i file fatti dagli script.
Restituisce: [{ job, fn, status, rows, error, createdAt, finishedAt, files: [{ id, name, size, expires }] }].
const mine = $orch.export.recent({ for: e.auth });
Dalle pagine dell'app: POST /api/tools/export/recent (effetto: solo lettura), con i permessi di toolAccess in app.json.
API HTTP del motore
Non le chiama l'app: servono alla console e al portale. Sono elencate perché l'app non le rifaccia.
| metodo | percorso | chi | stato | cosa fa |
|---|---|---|---|---|
| GET | /api/platform/apps/{slug}/export/collections | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Scheda «Esporta» di un'app: le raccolte e i loro campi. |
| POST | /api/platform/apps/{slug}/export/jobs | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Esporta una raccolta in CSV o in Excel con l'accesso dell'IT (tutti i record), nella coda comune dei lavori dell'app. |
| GET | /api/platform/apps/{slug}/export/jobs/{id} | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Avanzamento ed esito dell'export (lo stesso risultato di $orch.export.csv). |
| GET | /api/platform/apps/{slug}/export/jobs/{id}/file | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Il file esportato, come allegato (resta 2 ore). |
| GET | /api/platform/apps/{slug}/export/templates | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | I modelli Excel dell'app, con l'origine (come $orch.export.templates). |
| POST | /api/platform/apps/{slug}/export/templates | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Carica un modello .xlsx per l'app, dopo i controlli (niente macro, niente collegamenti esterni, dimensione): va in state/export/templates/. |
| GET | /api/platform/apps/{slug}/export/templates/{name} | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Il file del modello, per cambiarlo in Excel. |
| DELETE | /api/platform/apps/{slug}/export/templates/{name} | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Toglie un modello caricato dall'IT (quelli del pacchetto dell'app restano). |
| GET | /api/platform/apps/{slug}/export/history | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Gli ultimi export dell'app e i cambi dei modelli (eventi del motore). |
Come si usa
Quando usarlo. Ogni volta che le persone vogliono i dati dell'app in un foglio di calcolo: l'elenco degli ordini del mese in Excel, un CSV per un altro programma, un rapporto già impaginato con il logo e i grafici. Non scrivere a mano CSV o file Excel negli script e non portare librerie nelle pagine: il motore scrive il file (anche un milione di righe senza tenerle in memoria), con numeri e date nel formato giusto e con le difese contro le formule nascoste nei dati.
Che cosa esportare (lo spec di csv e xlsx):
| voce | significato |
|---|---|
collection | la raccolta da leggere; dagli script in alternativa rows, un elenco di oggetti già pronti |
filter, params | il filtro di PocketBase (come ?filter= delle API) con i segnaposto {:nome} e i loro valori |
sort | come ?sort= delle API, per esempio "-created,code" |
columns | le colonne in ordine: { field, title, format, width, total }; senza, i campi visibili della raccolta (con rows: le chiavi, in ordine di nome) |
sheets | solo Excel: più fogli, ognuno { name, collection, filter, params, sort, columns } (oppure rows) |
name | il nome del file; l'estensione la mette il motore |
profile | "italian" (predefinito: ; tra i campi, virgola nei decimali, date gg/mm/aaaa, CSV in UTF-8 con il BOM) oppure "international" (, e punto, date ISO, senza BOM) |
separator | CSV: ";", ",", "tab" o "|" (predefinito: quello del profilo); bom: true/false per scegliere il BOM |
timezone | il fuso delle date con l'ora, per esempio "Europe/Rome" (predefinito: quello del server) |
header, freeze, autoFilter, sheet | solo Excel: i colori dell'intestazione { fill: "#1F4E79", color: "#FFFFFF" }, la prima riga bloccata e i filtri (tutti e due accesi se non li spegni con false), il nome del foglio quando è uno solo |
maxRows | solo script: le righe al massimo (al più 1.000.000); path: il file su disco, dentro state/; for: la persona a cui lasciare il file (e.auth) |
format di una colonna: text, integer, number (due decimali), percent, date, datetime, time, bool, oppure un formato dei numeri di Excel scritto per intero ("#,##0.000", "0.0%", "dd/mm/yyyy hh:mm"); nel CSV i decimali vengono dal formato. Senza format decide il campo: un campo numerico dà numeri, un campo data dà date, un campo di testo resta testo anche se sembra un numero. Una data a mezzanotte UTC esatta è una data senza ora e resta quel giorno; le altre si scrivono nel fuso dell'export. Nelle righe date da uno script un testo con una data ISO ("2026-01-31", e le date di JavaScript, che arrivano così) vale come data. total: "sum", "count" (le celle piene) o "avg", in una riga dei totali in fondo.
Il CSV. Virgolette come nella RFC 4180 (un campo con il separatore, le virgolette o un a capo va tra virgolette, e le virgolette dentro si raddoppiano), righe che finiscono con CRLF. Una cella di testo che comincia con =, +, -, @, con una tabulazione o con un ritorno a capo riceve un apostrofo davanti: così un foglio di calcolo non la prende per una formula (la «CSV injection» dell'OWASP). I numeri restano numeri, anche negativi, e sono scritti senza separatori delle migliaia; i sì/no sono VERO/FALSO nel profilo italiano (le parole che l'Excel in italiano legge come valori logici) e true/false in quello internazionale. Un CSV ha una tabella sola: per più fogli usa xlsx.
L'Excel. Le righe si scrivono una alla volta (lo StreamWriter di excelize: sopra i 16 MB vanno su un file temporaneo in una cartella del lavoro dentro state/tmp, tolta alla fine del lavoro o, se l'app si ferma a metà, al suo avvio successivo), con l'intestazione in grassetto e i colori scelti, la larghezza delle colonne (width, o stimata dalle prime righe), i formati dei numeri e delle date, la prima riga bloccata e i filtri. Nelle celle si scrivono sempre valori: un testo che comincia con = resta un testo. La riga dei totali la scrive il motore con una formula SUBTOTAL sulle righe dei dati (109 la somma, 103 il conto, 101 la media) e con il valore già calcolato: il totale resta giusto se si corregge un valore e segue i filtri. Un foglio di Excel tiene 1.048.576 righe: con il tetto di 1.000.000 per export non si riempie, e se capitasse l'export si rifiuta invece di spezzare i dati su un altro foglio.
I modelli (fill). Un modello è una cartella di lavoro .xlsx già impaginata che l'app riempie. Come lo prepari in Excel:
- Scrivi
{{nome}}nelle celle che prendono un valore (values.nome; con{{cliente.nome}}una voce dentro un oggetto). Una cella con il solo segnaposto prende il valore com'è: un numero resta numero, una data resta data con il formato della cella (o con quello del profilo se la cella è «Generale»), un sì/no resta sì/no; una cella in formato Testo lo scrive come testo. Dentro un testo più lungo («Ordini di {{mese}}») il valore diventa testo. Un segnaposto senza valore si vuota e compare inmissing. - Metti le righe in una tabella di Excel (Inserisci › Tabella) e dalle un nome (Struttura tabella › Nome tabella, per esempio
Data). Lascia l'intestazione e una riga di esempio: il suo stile si copia su tutte le righe, e una colonna con una formula (una colonna calcolata, per esempio=[@Quantità]*[@Prezzo]) la tiene su tutte. Ogni colonna della tabella prende il campo con lo stesso nome dell'intestazione (senza badare a maiuscole, spazi e_), oppure la colonna dicolumnscon queltitle. - Per i totali usa la riga dei totali della tabella o formule con i riferimenti strutturati (
=SUM(Data[Totale])): Excel li scrive da solo quando, scrivendo la formula, fai clic sulle celle della tabella. Seguono la tabella quando cresce. Anche i grafici sulle righe della tabella, i nomi definiti che le coprono e le tabelle pivot fatte dalla tabella si allungano; il file chiede il ricalcolo all'apertura e le pivot l'aggiornamento all'apertura. - Salva come «Cartella di lavoro di Excel (*.xlsx)». Sono rifiutati i modelli con le macro (
.xlsm,vbaProject, i fogli macro di Excel 4.0), con collegamenti a file o dati esterni (altri libri, connessioni ai dati, query web, oggetti OLE e ActiveX), con formule che vanno in internet o ad altri programmi (WEBSERVICE,IMAGE,RTD,STOCKHISTORY,TRANSLATE,DETECTLANGUAGE,COPILOT,PY,CALL,REGISTER: il file riempito chiede il ricalcolo all'apertura e manderebbe fuori i dati della tabella), oltre 20 MB o oltre 50 MB una volta aperti (20 MB per parte), con più di 500.000 celle o 200.000 righe una volta aperti (Excel tiene, in ogni riga, tutte le colonne fino all'ultima usata: niente valori o formattazione lontani dal contenuto) e con più di 1.000 righe di esempio in una tabella. Un modello riempie al più 100.000 righe (1.000.000 di celle, 64 MB di testo): per gli elenchi più lunghi usaxlsx. Due tabelle con righe in comune (affiancate nello stesso foglio) non si riempiono: le righe che una guadagna o perde sposterebbero l'altra; mettile una sotto l'altra o in fogli diversi. - Fai il modello con le sole righe di esempio, non partendo da un export vero. Nel file riempito il motore toglie la copia dei dati che le tabelle pivot tengono (le righe da cui sono state fatte) e i valori tenuti nei grafici, che Excel rilegge all'apertura; le etichette e i numeri che una pivot mostrava quando hai salvato il modello restano però scritti nel foglio finché Excel non la aggiorna, cioè quando la persona apre il file e abilita la modifica. La formattazione condizionale e le convalide dei dati della riga di esempio valgono anche per le righe nuove.
Dove stanno i modelli, in un elenco unico con l'origine ($orch.export.templates()): quelli del pacchetto dell'app in release/templates/ (arrivano con una versione dell'app) e quelli caricati dall'IT dalla scheda «Esporta» della console, che vanno in state/export/templates/ (mai in release/) e sostituiscono un modello del pacchetto con lo stesso nome finché non si tolgono.
Dove va il file. Dagli script nel deposito dell'app (lo stesso dei file delle pagine: resta 2 ore, si perde quando l'app riparte): $orch.export.send(e, result) lo manda come download dalla rotta e poi lo toglie; con { for: e.auth } resta alla persona, che lo scarica dalla pagina con PyriteTools.download(result.file.id). Con { path: "state/exports/…" } va su disco, dentro state/ (release/ e il resto della cartella restano in sola lettura).
Dalle pagine dell'app (regola 8 delle regole comuni), senza scrivere rotte: lo strumento in "tools" e in app.json un permesso come questo.
"toolAccess": {
"export": { "pages": true, "minRole": "viewer", "collections": ["orders", "customers"], "maxRows": 50000 }
}
POST /api/tools/export/csvePOST /api/tools/export/xlsxcon lospec(senzarows,path,for,maxRows) ePOST /api/tools/export/fill{ template, values, tables: { Data: { collection, filter, params, sort, columns } }, name, profile }→202 { job }. Il lavoro sta nella coda comune (un lavoro pesante alla volta per app, salvo"toolJobs"), dura al più 10 minuti e lascia il file neifilesdel lavoro; si scarica con il gettone (PyriteTools.download) per 2 ore.- La lettura è sempre quella della persona, con le regole dell'app: la regola d'elenco della raccolta, il filtro con i suoi diritti, gli hook
onRecordsListRequest, i campi nascosti e le email come nelle API. Si esporta solo quello che la persona vede già nell'app, e solo dalle raccolte dicollections; i campi nascosti non si chiedono. Quando il lavoro parte (anche molto dopo, se era in coda) la persona si rilegge: se intanto l'utente è stato tolto, ha cambiato la password o ha perso il ruolo diminRole, il lavoro non parte. Le righe escono dalla stessa istantanea dei dati, una volta sola ciascuna, anche se intanto qualcuno aggiunge o toglie record. - Al più
maxRowsrighe (senza: 100.000; per un modello al più 100.000): oltre, l'export si rifiuta con il numero, non si taglia. Il conto è quello delle righe che la persona vede con il filtro. POST /api/tools/export/templates{}: i modelli dell'app, con tabelle e segnaposto.POST /api/tools/export/recent{}: i miei export ancora da scaricare.- Ogni export lascia un evento nel registro del motore, con chi l'ha fatto, il file e quante righe; anche quelli non riusciti o annullati (anche mentre erano in coda). Gli export dell'IT dalla console e i modelli caricati o tolti si vedono nello storico della scheda «Esporta».
Regole per questo strumento:
- Dagli script il motore legge come il server: niente regole, anche i campi nascosti se li elenchi. Prima di esportare per una persona da una rotta, controlla tu che cosa può vedere, oppure lascia l'export alle pagine (
toolAccess), che leggono con le sue regole. La password e la chiave dei token degli utenti non si esportano mai. sendscrive la risposta della rotta: usalo come ultima cosa,return $orch.export.send(e, r), senzae.jsondopo.- Un export grande dalle pagine è un lavoro: usa
PyriteTools.run, che aspetta la fine, e poiPyriteTools.downloadcon l'id del file. - Nei modelli metti le formule sui riferimenti strutturati della tabella, non su intervalli scritti a mano: crescono con la tabella.
Esempio: un CSV per un altro programma, con la virgola e le date ISO, salvato ogni notte in state/.
cronAdd("export-orders-csv", "30 2 * * *", () => {
$orch.export.csv({
collection: "orders",
filter: "created >= {:from}",
params: { from: new Date(Date.now() - 86400000).toISOString().slice(0, 10) },
columns: [{ field: "code" }, { field: "created", format: "datetime" }, { field: "total", format: "number" }],
profile: "international",
path: "state/exports/orders-yesterday.csv",
});
});
Esempio: il pulsante «Esporta in Excel» in una pagina di riepilogo dell'app, con /__platform/tools.js (la persona scarica i suoi ordini, con le regole dell'app).
<button id="excel">Esporta in Excel</button>
<span id="stato"></span>
<script src="/__platform/tools.js"></script>
<script>
document.getElementById("excel").addEventListener("click", async () => {
const stato = document.getElementById("stato");
try {
const job = await PyriteTools.run("export", "xlsx", {
collection: "orders",
filter: "status = 'paid'",
sort: "-created",
columns: [
{ field: "code", title: "Ordine" },
{ field: "created", title: "Data", format: "date" },
{ field: "total", title: "Totale", format: "number", total: "sum" },
],
name: "ordini-pagati",
}, { onProgress: (j) => (stato.textContent = j.processed + " / " + j.total) });
if (job.status !== "finished") throw new Error(job.error || job.status);
await PyriteTools.download(job.files[0].id);
stato.textContent = "";
} catch (err) {
stato.textContent = err.message;
}
});
</script>
Immagini (images)
Le foto senza programmi esterni: riduce, comprime, converte in JPEG o PNG, raddrizza le foto del telefono (l'orientamento EXIF applicato ai pixel), toglie la posizione e i dati personali dell'EXIF tenendo il profilo colore. Una regola per campo in app.json lo fa da sola a ogni caricamento; import in blocco delle foto da una cartella o da uno zip, con prova, doppioni e annullamento; un selettore di foto per le pagine dell'app.
Si attiva con "tools": ["images"] in app.json. Categoria: File.
Funzioni
$orch.images.info(source)
Le informazioni di una foto senza decodificarla: formato, misure come è salvata e come si vede, orientamento EXIF, se ha la posizione (GPS), l'EXIF, un profilo colore o altri metadati, la qualità stimata del JPEG. La sorgente è un percorso nella cartella dell'app o il file di un record ({ collection, id, field, name } oppure { record, field }: senza name la prima foto nuova del campo, poi la prima salvata).
Restituisce: { format, width, height, displayWidth, displayHeight, orientation, needsStraightening, hasGps, hasExif, hasColorProfile, colorProfile, hasOtherMetadata, alpha, quality, bytes, camera, taken, maxPixels }.
const i = $orch.images.info({ record: e.record, field: "photo" });
if (i.needsStraightening || i.hasGps) console.log("the photo will be straightened and cleaned");
Dalle pagine dell'app: POST /api/tools/images/info (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.process(source, target, options?)
Lavora una foto e scrive il risultato nella destinazione: un percorso in state/ o il campo file di un record ({ collection, id, field }, oppure { record, field, save: false } per metterla senza salvare). Opzioni: { maxSide, width, height, mode: fit | fill | crop, crop: { x, y, width, height }, quality, format: keep | jpeg | png, straighten, stripGps }. Senza opzioni la foto resta com'è; le misure non crescono mai; JPEG e PNG riavranno il profilo colore e un EXIF pulito.
Restituisce: { format, width, height, bytes, changes, path | record, field, name }.
$orch.images.process("release/seed/logo.png", "state/public/logo.jpg",
{ maxSide: 800, format: "jpeg", quality: 80, straighten: true, stripGps: true });
Dalle pagine dell'app: POST /api/tools/images/process (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.resize(source, target, options)
Come process, per cambiare le misure: maxSide (il lato più lungo), width e height (una scatola), mode fit (dentro la scatola, il predefinito), fill (esattamente la scatola, tagliando i bordi) o crop (il rettangolo di crop, sulla foto come si vede). Non ingrandisce mai.
Restituisce: { format, width, height, bytes, changes, path | record, field, name }.
$orch.images.resize({ record: e.record, field: "photo" }, { record: e.record, field: "thumb", save: false },
{ mode: "fill", width: 300, height: 300 });
Dalle pagine dell'app: POST /api/tools/images/resize (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.compress(source, target, options?)
Come process, ricomprimendo un JPEG alla qualità quality (predefinita 75). Un JPEG già compresso così, o un risultato che non pesa meno, resta com'è: la foto non peggiora a ogni passaggio. Il PNG è senza perdite: per alleggerirlo lo si converte in JPEG.
Restituisce: { format, width, height, bytes, changes, path | record, field, name }.
const r = $orch.images.compress("state/upload/big.jpg", "state/upload/small.jpg", { quality: 70 });
Dalle pagine dell'app: POST /api/tools/images/compress (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.convert(source, target, options)
Come process, per cambiare il formato: format jpeg o png (WebP arriverà più avanti). I pixel trasparenti in un JPEG diventano bianchi.
Restituisce: { format, width, height, bytes, changes, path | record, field, name }.
$orch.images.convert("state/scan/page.tif", "state/scan/page.png", { format: "png" });
Dalle pagine dell'app: POST /api/tools/images/convert (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.straighten(source, target)
Gira i pixel come dice l'orientamento EXIF e lo porta a 1: la foto è dritta per tutti i programmi, anche quelli che l'etichetta non la leggono. Una foto già dritta resta com'è.
Restituisce: { format, width, height, bytes, changes, path | record, field, name }.
$orch.images.straighten({ collection: "cars", id: id, field: "photo" }, { collection: "cars", id: id, field: "photo" });
Dalle pagine dell'app: POST /api/tools/images/straighten (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.stripGps(source, target)
Toglie la posizione e i dati personali dell'EXIF (GPS, XMP, IPTC, numeri di serie, proprietario, note del produttore, miniatura, altre immagini o video dopo la foto), tenendo fotocamera, data, esposizione e profilo colore. Quando i pixel non cambiano, la foto si riscrive senza perdere qualità.
Restituisce: { format, width, height, bytes, changes, path | record, field, name }.
$orch.images.stripGps("state/upload/visit.jpg", "state/upload/visit.jpg");
Dalle pagine dell'app: POST /api/tools/images/strip-gps (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.images.fingerprint(source)
L'impronta SHA-256 dei byte (file identici) e l'impronta percettiva, 16 cifre esadecimali: due foto quasi uguali (ridotte, ricompresse, girate) differiscono di pochi bit.
Restituisce: { sha256, phash }.
const a = $orch.images.fingerprint("state/a.jpg"), b = $orch.images.fingerprint("state/b.jpg");
$orch.images.duplicates(sources, options?)
I gruppi di foto uguali (stessa SHA-256) e quasi uguali (impronte a 10 bit o meno, o distance nelle opzioni), al più 1000 foto. Le foto che non si leggono sono in errors.
Restituisce: { identical: [[source]], similar: [[source]], errors: [{ source, reason }] }.
const d = $orch.images.duplicates(["state/a.jpg", "state/b.jpg", "state/c.jpg"]);
if (d.similar.length) console.log("nearly identical: " + JSON.stringify(d.similar));
$orch.images.import(path, options)
Carica foto in blocco da una cartella dell'app o da uno .zip (al più 5000 file): mode new (una foto = un record nuovo; keyField riceve il nome del file, values gli altri campi) o match (la foto va al record il cui keyField è il nome del file senza estensione, senza badare a maiuscole e minuscole; separator taglia il nome: ART001_2.jpg con «_» è ART001). Vale la regola images del campo. dryRun dice cosa succederebbe a ogni foto, con le foto uguali e quasi uguali; il caricamento vero va nel registro dei caricamenti e si può annullare. Opzioni by e app come in $orch.import.load.
Restituisce: { id, source, collection, field, mode, dryRun, photos, created, attached, skipped, rejected, duplicates, identical, similar, items: [{ name, code, action, record, reason, sameAs }], ruleApplied, duration }.
action di ogni foto: create, attach, replace, skip, duplicate (uguale a sameAs, non caricata), reject (con reason). Le foto sostituite non tornano con l'annullamento.
const trial = $orch.images.import("state/incoming/photos.zip",
{ collection: "products", field: "photo", mode: "match", keyField: "code", dryRun: true });
if (trial.rejected === 0) $orch.images.import("state/incoming/photos.zip",
{ collection: "products", field: "photo", mode: "match", keyField: "code", app: e.app });
Dalle pagine dell'app: POST /api/tools/images/import (effetto: scrive dati), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.images.undo(id, options?)
Annulla un caricamento di foto (l'id nel risultato di import): toglie i record che ha creato e le foto nuove dai record che ha aggiornato. Opzioni by e app.
Restituisce: { id, file, collection, read, created, updated, errors, errorCount, warnings, duration }.
$orch.images.undo(load.id, { by: e.auth.email(), app: e.app });
Dalle pagine dell'app: POST /api/tools/images/undo (effetto: toglie dati), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
Come si usa
Quando usarlo. Ogni volta che un'app riceve foto: le foto dei veicoli, dei prodotti, dei sopralluoghi, gli avatar. Non aggiungere librerie e non chiedere programmi sul server: il motore riduce, comprime, converte, raddrizza e pulisce le foto da solo. Nella maggior parte delle app non serve codice: basta una regola per campo in app.json e ogni foto caricata in quel campo esce già a posto, da qualunque strada arrivi.
Perché le foto del telefono escono storte. Il sensore della fotocamera è montato in un verso solo. Quando giri il telefono per fare una foto in verticale, il telefono non gira i pixel: li salva come li ha letti il sensore (coricati) e scrive nella foto un'etichetta, l'«orientamento EXIF», che dice «per vederla dritta girala di 90°». Il telefono, la galleria e i browser di oggi leggono l'etichetta e la mostrano dritta; molti altri programmi (gestionali, PDF, email, stampe, anteprime di qualche server, programmi vecchi) l'etichetta non la leggono e mostrano la foto coricata o capovolta. Raddrizzare (straighten) gira davvero i pixel e porta l'etichetta a 1: la foto è dritta per tutti. Le stesse foto portano spesso la posizione (GPS: dove è stata scattata, a pochi metri) e altri dati personali (numero di serie del telefono, nome del proprietario, a volte un video o una seconda foto attaccati dopo la prima): stripGps li toglie e tiene quello che serve (fotocamera, data, esposizione, profilo colore).
La regola di un campo. Con lo strumento attivo, in app.json:
"tools": ["images"],
"images": {
"products.photo": { "maxSide": 2560, "quality": 85, "format": "jpeg", "straighten": true, "stripGps": true, "keepOriginal": "photo_original" },
"people.avatar": { "mode": "fill", "width": 400, "height": 400 }
}
| voce | valori | significato |
|---|---|---|
maxSide | pixel | il lato più lungo: la foto sta in un quadrato di questo lato (mai ingrandita) |
width, height | pixel | una scatola; con mode: "fill" la foto la riempie esattamente, tagliando i bordi (avatar, miniature quadrate) |
mode | "fit" (predefinito), "fill" | fit: dentro la scatola con la stessa forma; fill: esattamente la scatola |
quality | 1-100 | la qualità dei JPEG riscritti (senza: 85); un JPEG già compresso così non si ricomprime |
format | "keep" (predefinito), "jpeg", "png" | il formato delle foto salvate; keep tiene JPEG e PNG, e quando deve cambiare un formato che il motore sa leggere ma non scrivere lo scrive in JPEG se era con perdite (WebP fotografico) e in PNG se era senza perdite o con trasparenza (BMP, TIFF, GIF, WebP senza perdite) |
straighten | true (predefinito), false | raddrizza i pixel e porta l'orientamento a 1 |
stripGps | true (predefinito), false | toglie la posizione e i dati personali dell'EXIF |
keepOriginal | nome di un altro campo file | tiene anche la foto com'è arrivata (stessi pixel e qualità, ma senza la posizione se stripGps); senza, l'originale non si tiene |
- La regola vale da sola a ogni caricamento nel campo: API dell'app (pagine, SDK di PocketBase), scheda Dati della console, import in blocco, script che salvano un record con una foto. Il controllo della dimensione del campo (
maxSize) vale per la foto già ridotta: una foto da 12 MB in un campo da 5 MB passa, se ridotta ci sta (il corpo della richiesta resta nei limiti di PocketBase:maxSizeper il numero di file più 32 MB). Il campo dikeepOriginalha i suoi controlli: dagli unamaxSizegrande abbastanza. - Senza perdite quando si può: se i pixel non cambiano (foto già dritta e già piccola) e c'è solo da togliere la posizione, la foto si riscrive con gli stessi dati dell'immagine e i metadati puliti. Quando i pixel cambiano, si riscrive in JPEG o PNG con il profilo colore (ICC, per esempio Display P3 degli iPhone) e un EXIF pulito rimessi a mano.
- Non peggiora a ogni passaggio: riduce solo le foto più grandi, ricomprime solo i JPEG più pesanti della qualità chiesta, e se ricomprimere non alleggerisce tiene la foto com'era. I file che non sono foto passano come sono, le GIF anche (salvo un
formatchiesto: allora il primo fotogramma); constripGpsalle GIF si tolgono XMP (che può avere la posizione), dati dei programmi e commenti, senza toccare i fotogrammi. - Si rifiutano con un errore di validazione sul campo, nella lingua della persona: HEIC e AVIF (vedi sotto), foto rovinate, foto troppo grandi per la memoria dell'app. Il codice dell'errore è
validation_images_<motivo>(per esempiovalidation_images_heic), il messaggio dice cosa fare. - Una regola scritta male (voce che non c'è, valore fuori dai limiti,
keepOriginalche non è un campo file) rifiuta i caricamenti in quel campo con il motivo, finché non la si corregge: meglio un errore che una foto con la posizione che doveva essere tolta. Senza"images"in"tools"la regola non vale (il worker lo scrive nel log). - Una modifica del record che non tocca il campo non lavora la foto. La protezione dalle modifiche concorrenti (
$version,If-Match) controlla la versione prima: con una versione vecchia la foto non si decodifica nemmeno.
HEIC. Gli iPhone fotografano di serie in HEIC (o HEIF), un formato che il motore non sa leggere (nessuna libreria libera in Go puro). Una foto HEIC si riconosce dal contenuto (non dall'estensione) e si rifiuta con un messaggio chiaro: sull'iPhone Impostazioni › Fotocamera › Formati › «Più compatibile» (le foto nuove saranno in JPEG), oppure esporta la foto in JPEG e caricala di nuovo. Il selettore delle pagine (/__platform/images.js) se ne accorge prima di caricarla e dice la stessa cosa. Lo stesso vale per AVIF.
Limiti di memoria. Prima di decodificare una foto il motore ne legge solo l'intestazione (misure e formato) e la confronta con i tetti delle miniature dell'app, che seguono memoryLimitMB: i pixel al più (almeno circa 52 milioni, le foto da 48-50 Mpx dei telefoni) e la memoria stimata (un JPEG progressivo o un PNG a 16 bit ne chiedono di più). Una foto oltre il tetto si rifiuta senza decodificarla, con le misure nel messaggio; una foto pesa al più 100 MB. Le foto si lavorano poche alla volta (quante le miniature).
Dagli script. La sorgente è un percorso nella cartella dell'app oppure il file di un record: { collection, id, field, name? } o { record: e.record, field, name? } (senza name: la prima foto non ancora salvata del campo, se c'è, poi la prima salvata). La destinazione è un percorso in state/ oppure il campo file di un record ({ collection, id, field } salva il record; { record, field, save: false } mette la foto nel record senza salvarlo, per esempio in onRecordCreateRequest prima di e.next()). Le funzioni con le opzioni (process, resize, compress, convert) di serie non raddrizzano e non tolgono la posizione: si chiede con straighten: true, stripGps: true, o con le funzioni straighten e stripGps. Il risultato dice cosa è cambiato in changes (straightened, resized, cropped, compressed, converted, locationRemoved, metadataCleaned; vuoto = la foto è rimasta com'era).
// pb_hooks/products.pb.js: a square thumbnail next to the photo, before saving
onRecordCreateRequest((e) => {
if (e.record.getUnsavedFiles("photo").length) {
$orch.images.resize({ record: e.record, field: "photo" }, { record: e.record, field: "thumb", save: false },
{ mode: "fill", width: 300, height: 300, straighten: true, stripGps: true });
}
e.next();
}, "products");
Import in blocco. $orch.images.import(path, options) carica le foto di una cartella dell'app (fino a 4 livelli di sottocartelle) o di uno .zip, al più 5.000 file:
mode: "new": una foto = un record nuovo;keyFieldriceve il nome del file senza estensione (se c'è già un record con quel codice la foto si salta, e così le altre foto dello stesso blocco con lo stesso codice: un record per codice),valuesi valori degli altri campi;mode: "match": la foto va al record con lo stesso codice:ART001.jpg→ il record conkeyField=ART001, senza badare a maiuscole e minuscole (anche con le lettere accentate:caffè.jpgtrovaCAFFÈ; i nomi dei file del Mac, che scrivono l'accento a parte, valgono come gli altri); conseparator: "_"ancheART001_2.jpg. In un campo con una foto sola, un record che ne ha già una si salta (replace: truela sostituisce: la foto sostituita è cancellata e l'annullamento non la riporta); in un campo multiplo la foto si aggiunge (fino amaxSelect).dryRun: truedice cosa succederebbe a ogni foto (items[].action:create,attach,replace,skip,duplicate,rejectconreason), senza salvare niente, e trova i doppioni: uguali byte per byte (identical, stessa SHA-256: conduplicates: "skip", il predefinito, si carica solo la prima; nel modomatchsi salta solo una foto uguale con lo stesso codice, perché una uguale con un altro codice va a un altro record e si carica, elencata tra leidentical) e quasi uguali (similar: la stessa foto ridotta, ricompressa o girata, con l'impronta percettiva; solo nella prova, e si caricano lo stesso).- Ogni foto passa dalla regola del campo. Il caricamento vero va nel registro dei caricamenti dell'import (
orch_imports, scheda «Importa» della console) e si annulla con$orch.images.undo(id)(toglie i record creati, toglie le foto nuove dai record aggiornati); lo storico delle modifiche lo segna con origine «import». - La prova e il caricamento vero dicono la stessa cosa: anche nel caricamento vero un campo multiplo conta le foto che aveva prima del blocco più quelle che il blocco gli attacca. L'annullamento riporta ogni record com'era prima di tutto il blocco.
- Zip: i nomi che provano a uscire dalla cartella («..», percorsi assoluti, unità) si rifiutano e si elencano, una voce che si espande troppo per essere una foto (zip bomb) si rifiuta, e si legge al più 4 GB in tutto. Prima di aprire lo zip se ne contano le voci: oltre 5.000 voci, o un indice più grande di 16 MB, lo zip si rifiuta senza aprirlo. I file che non sono foto si elencano come saltati.
const opz = { collection: "products", field: "photo", mode: "match", keyField: "code", dryRun: true };
const trial = $orch.images.import("state/incoming/photos.zip", opz);
console.log(trial.attached + " photos for existing products, " + trial.rejected + " rejected, " + trial.similar.length + " groups of similar photos");
Dalle pagine dell'app (regola 8 delle regole comuni), senza scrivere rotte: lo strumento in "tools" e un permesso in app.json.
"toolAccess": {
"images": { "pages": true, "minRole": "editor", "destructiveRole": "admin", "collections": ["products"], "maxRows": 1000,
"keyFields": ["products.code"] }
}
POST /api/tools/filescontool=imagese una foto (.jpg,.png,.gif,.webp,.bmp,.tif; HEIC si rifiuta subito con il messaggio) o uno.zip(al più 500 MB).POST /api/tools/images/info{ file }: comeinfo.POST /api/tools/images/process{ file, maxSide, width, height, mode, crop, quality, format, straighten, stripGps }(eresize,compress,convert,straighten,strip-gps): la foto lavorata come file nuovo del deposito, da scaricare conPyriteTools.download(result.file.id). Lavorano solo sui file della persona: il file di un altro non c'è (404).POST /api/tools/images/import{ file, collection, field, mode, keyField, separator, replace, duplicates, dryRun }→202 { job }: l'import in blocco di uno zip in una raccolta dicollections, al piùmaxRowsfoto (senza: 500). Il caricamento vero solo dopo una prova riuscita della stessa persona con lo stesso zip e le stesse opzioni;replacevuoledestructiveRole;valuesdalle pagine no. Chi carica resta scritto per id, come per l'import di Excel.- Il campo chiave lo decide chi costruisce l'app (
keyFields, chiuso per difetto): dalle paginekeyFieldsi può usare solo se"raccolta.campo"è nell'elencotoolAccess.images.keyFields, e l'IT lo vede nel riepilogo di «Carica app» e nella scheda «Strumenti per le pagine». Senza l'elenco, dalle pagine il modomatchsi rifiuta e il modonewfunziona solo senzakeyField(i record nuovi non ricevono il nome del file). Ogni voce dell'elenco deve essere un campo di testo, numero, email o indirizzo web, non nascosto e non di sistema, di una raccolta dicollections. Una voce di forma sbagliata (non"raccolta.campo", o di una raccolta che non è incollections) chiude alle pagine tutto lo strumento,infoeprocesscompresi, come le altre voci ditoolAccessscritte male; una voce su un campo che non c'è o di un altro tipo chiude solo l'import dalle pagine, con un messaggio che dice cosa correggere, e lo scrive anche nel log dell'app (all'avvio e a ogni import rifiutato). La scheda della console mostra le voci come sono scritte: il tipo dei campi lo controlla l'app, con il suo schema. Gli script e i superuser scelgonokeyFieldliberamente. - Dalle pagine ogni foto segue le regole dell'app per la persona, come se facesse lei le chiamate alle API: il codice si cerca solo tra i record che la persona vede (la
listRule: un record che non vede non c'è, nemmeno per dire «c'è già»); un record nuovo passa dallacreateRulee uno da cambiare dallaupdateRule, con in@request.bodyi campi che l'import scrive davvero (il campo chiave e il campo della foto).keyFielddalle pagine è solo un campo di testo, numero, email o indirizzo web, non nascosto e non di sistema: il nome del file diventa il suo valore, e con una relazione o un sì/no la persona sceglierebbe da sola proprietario o stato. Gli hook di richiesta dell'app (onRecordCreateRequest…) non partono: l'import salva come il server, nelle raccolte che l'IT ha messo incollections; se l'app compila un campo in un hook (per esempio il proprietario), i record creati dalle pagine non lo hanno. POST /api/tools/images/undo{ id }→202 { job }: annulla un caricamento di foto fatto dalle pagine, solo condestructiveRoleditoolAccess.images(l'annullamento dell'import di Excel non tocca i caricamenti di foto).
Il selettore delle foto per le pagine. /__platform/images.js (meno di 10 KB, nessuna libreria) mette in una pagina una zona dove scegliere o trascinare le foto, accessibile da tastiera (Invio o Spazio la aprono), con le anteprime piccole caricate solo quando si vedono (miniature di PocketBase ?thumb=), lo stato del caricamento letto dai lettori di schermo e i testi nella lingua della persona. Con collection, record e field carica le foto nel record (con la versione per la protezione dalle modifiche: la regola del campo vale come sempre); senza, dà i file alla pagina con onChange. HEIC si riconosce prima di caricarlo. maxFiles (predefinito 20, 1 senza multiple) conta anche le foto che il record ha già: le foto in più non partono e il messaggio resta scritto dopo il caricamento delle altre.
<div id="photos"></div>
<script src="/__platform/images.js"></script>
<script>
PyriteImages.picker("#photos", { collection: "products", record: productId, field: "photo", multiple: true,
onUploaded: (record) => console.log(record.photo) });
</script>
Import da Excel, CSV e JSON (import)
Legge file Excel (.xlsx e i vecchi .xls), CSV e JSON come fa Power Query: riconosce colonne e tipi (numeri all'italiana o all'inglese, date, codici con zeri iniziali, celle unite) e carica le righe in una raccolta dell'app secondo una «ricetta» che si riusa, oppure tutto il file in più raccolte (un foglio per raccolta, anche più raccolte da un foglio solo, tutto o niente). Con una chiave, per esempio la targa, un nuovo import aggiorna i record invece di duplicarli; ogni caricamento si può annullare.
Si attiva con "tools": ["import"] in app.json. Categoria: Dati.
Funzioni
$orch.import.analyze(path, options?)
Legge il file e dice com'è fatto: colonne con nome, tipo ed esempi, prime righe così come sono e già interpretate, avvisi. Non scrive niente. Serve a preparare la ricetta. Nell'anteprima i testi sono accorciati a 300 caratteri e le prime righe mostrano al massimo 200 colonne.
Restituisce: { file, format, sheets, sheet, encoding, separator, suggestedSeparator, header, titleRow, firstDataRow, rows, blankRows, columns: [{ index, letter, name, title, field, type, wholeNumbers, values, blanks, examples, notes }], rawRows: [{ number, cells }], merges: [{ row1, col1, row2, col2 }], sample: [{ number, values: [{ type, text, number, date, bool }] }], warnings }.
const a = $orch.import.analyze("release/seed/parts.xlsx", { header: "titles" });
console.log(a.rows + " rows: " + a.columns.map((c) => c.name + " (" + c.type + ")").join(", "));
Dalle pagine dell'app: POST /api/tools/import/preview (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.import.load(path, recipe, options?)
Carica le righe del file nella raccolta della ricetta. Con { dryRun: true } controlla tutto (conversioni, regole dei campi, chiavi) senza salvare niente. Le righe con errori sono scartate e descritte, le altre caricate a lotti in transazione. Opzioni: dryRun, by (chi ha lanciato il caricamento), app (l'app che lo script ha in mano). Una ricetta con le parti (recipe.parts, come quella di plan) carica più raccolte nell'ordine giusto, tutto o niente: prima controlla tutto senza scrivere, poi scrive in una transazione sola, al più 50.000 righe e 60 secondi (intanto le altre scritture dell'app aspettano: waitSeconds lo stima nella prova).
Restituisce: { id, file, collection, dryRun, read, created, updated, unchanged, skipped, rejected, duplicates, errors: [{ row, column, field, value, reason, sheet, collection }], errorCount, warnings, duration, tables: [{ sheet, collection, read, created, updated, …, skippedReason }], rolledBack, waitSeconds }.
id è il numero del caricamento: serve per annullarlo.
const recipe = {
collection: "parts",
options: { format: "excel", header: "titles" },
fields: { "Code": "code", "Description": "description", "Price": "price" },
key: ["code"],
};
const trial = $orch.import.load("release/seed/parts.xlsx", recipe, { dryRun: true, app: e.app });
if (trial.rejected === 0) {
const result = $orch.import.load("release/seed/parts.xlsx", recipe, { by: e.auth.email(), app: e.app });
}
Dalle pagine dell'app: POST /api/tools/import/jobs (effetto: scrive dati), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.import.undo(id, options?)
Annulla un caricamento vero, tutto o niente: toglie i record creati e riporta come prima quelli aggiornati. Si ferma senza cambiare niente se record di altri sono collegati a record creati dal caricamento, o se dei record sono cambiati dopo (dice quali, in blocked ed errors); con anyway: true annulla anche i record cambiati dopo. Opzioni: by, anyway, app.
Restituisce: { id, file, collection, read, created, updated, errors, errorCount, warnings, duration, rolledBack, blocked }.
Lo stesso esito di load; qui created = record tolti, updated = record riportati com'erano.
const result = $orch.import.undo(id, { by: e.auth.email(), app: e.app });
Dalle pagine dell'app: POST /api/tools/import/undo (effetto: toglie dati), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.import.history(options?)
Gli ultimi caricamenti dell'app (al massimo 50), dal più recente; con by solo quelli lanciati da quella persona. Opzioni: by, limit (quanti), app.
Restituisce: [{ id, file, collection, status, by, undoneBy, recipe, createdAt, updatedAt, read, created, updated, unchanged, skipped, rejected, errorCount, duration }].
const mine = $orch.import.history({ by: e.auth.email(), limit: 10 });
Dalle pagine dell'app: POST /api/tools/import/history (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.import.plan(path, options?)
Legge tutto il file (tutti i fogli di un Excel, tutti gli elenchi di un file JSON) e propone dove vanno i dati: per ogni foglio la raccolta dell'app più simile (nome del foglio e titoli delle colonne, con un grado di somiglianza da 0 a 1), le colonne → campi, la chiave, il campo con cui trovare i record collegati, l'ordine di caricamento (prima le raccolte a cui le altre si collegano) e, per un foglio con tutto dentro, la raccolta collegata riempita con le sue colonne (un record per chiave). recipe è pronta per load; missing dice, a parole, cosa non trova posto (fogli senza raccolta, colonne senza campo). Non scrive niente. Opzioni: le opzioni di lettura e app.
Restituisce: { recipe: { name, options, parts: [{ sheet, collection, fields, key, onlyNew, ignoreBlank, relations, distinct }], partial } (a whole recipe: its other entries are empty), sheets: [{ sheet, label, rows, columns, hidden, error, collection, similarity, ambiguous, weak, alternatives: [{ collection, similarity }], linked, unmatchedColumns, notAllowed }], order, missing, warnings }.
const plan = $orch.import.plan("release/seed/old_system.xlsx", { app: e.app });
console.log("load order: " + plan.order.join(", ") + "; missing: " + plan.missing.join(" "));
const trial = $orch.import.load("release/seed/old_system.xlsx", plan.recipe, { dryRun: true, app: e.app });
Dalle pagine dell'app: POST /api/tools/import/plan (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.import.rows(path, options?, range?)
Tutte le righe di dati del file, a pezzi: gli stessi valori dell'esempio di analyze, con i testi interi. Si passa { offset, limit } (al più 5.000 righe per pezzo, meno per i file molto larghi) e si va avanti con next finché non è null. Ogni pezzo rilegge il file.
Restituisce: { file, format, sheet, columns, rows: [{ number, values: [{ type, text, number, date, bool }] }], offset, limit, total, next, warnings }.
let next = 0;
while (next !== null) {
const piece = $orch.import.rows("release/seed/parts.xlsx", { header: "titles" }, { offset: next, limit: 1000 });
piece.rows.forEach((r) => console.log(r.number + ": " + r.values[0].text));
next = piece.next;
}
API HTTP del motore
Non le chiama l'app: servono alla console e al portale. Sono elencate perché l'app non le rifaccia.
| metodo | percorso | chi | stato | cosa fa |
|---|---|---|---|---|
| POST | /api/platform/import/files | persone entrate dal portale, e Proprietari e Admin dalla console | pronta | «Prova import»: carica un file Excel, CSV o JSON in un deposito temporaneo del motore (mai nella cartella di un'app). |
| GET | /api/platform/import/files/{id} | chi ha caricato il file | pronta | Dati del file caricato (mai il contenuto). |
| POST | /api/platform/import/files/{id}/preview | chi ha caricato il file | pronta | Anteprima: legge il file con le opzioni scelte, senza importare niente (lo stesso risultato di $orch.import.analyze). |
| DELETE | /api/platform/import/files/{id} | chi ha caricato il file | pronta | Toglie il file dal deposito temporaneo. |
| GET | /api/platform/import/whoami | persone entrate dal portale, e Proprietari e Admin dalla console | pronta | Chi sta usando la finestra di import. |
| GET | /api/platform/import/app/{slug}/collections | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Scheda «Importa» di un'app: le raccolte in cui si può importare, con i campi. |
| POST | /api/platform/import/app/{slug}/propose | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Abbinamento proposto tra le colonne del file e i campi della raccolta. |
| POST | /api/platform/import/app/{slug}/jobs | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Prova o caricamento vero nel database dell'app, in sottofondo. |
| GET | /api/platform/import/app/{slug}/jobs/{id} | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Avanzamento ed esito di un lavoro (lo stesso esito di $orch.import.load). |
| DELETE | /api/platform/import/app/{slug}/jobs/{id} | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Ferma un lavoro in corso: un import di più raccolte non lascia niente, uno di una raccolta sola tiene i lotti già salvati (si tolgono con $orch.import.undo). |
| GET | /api/platform/import/app/{slug}/history | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Le ultime importazioni dell'app (registro orch_imports). |
| POST | /api/platform/import/app/{slug}/history/{id}/undo | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Annulla un'importazione (come $orch.import.undo). |
| GET | /api/platform/import/app/{slug}/recipes | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Le ricette salvate dalla console per l'app (raccolta di sistema orch_import_recipes). |
| POST | /api/platform/import/app/{slug}/recipes | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Salva una ricetta (stesso nome = sostituisce). |
| DELETE | /api/platform/import/app/{slug}/recipes/{id} | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Elimina una ricetta salvata. |
| POST | /api/platform/import/files/{id}/book | chi ha caricato il file | pronta | Tutti i fogli del file caricato (tutti gli elenchi di un file JSON), ognuno con le sue colonne e i tipi, senza importare niente. |
| POST | /api/platform/import/app/{slug}/plan | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Scheda «Importa», tutto il file: dove va ogni foglio, con le colonne, le chiavi e l'ordine di caricamento (lo stesso risultato di $orch.import.plan). |
| GET | /api/platform/import/app/{slug}/settings | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Scheda «Importa»: cosa dice app.json (le raccolte in cui la console può importare, import.consoleCollections) e quante ricette arrivano con il pacchetto dell'app. |
Come si usa
Quando usarlo. Ogni volta che l'app deve leggere un file Excel (.xlsx, anche il vecchio .xls), CSV o JSON: dati iniziali consegnati con l'app, lo storico di un vecchio gestionale, l'export mensile di un altro programma. Non scrivere un lettore CSV a mano e non aggiungere librerie: usa questo strumento. Si leggono anche i vecchi .xls (Excel 97-2003), tranne quelli protetti da una password e quelli di Excel 95 e precedenti: vanno aperti in Excel e salvati come .xlsx. Se il file si carica una volta sola o lo carica l'IT, non serve codice: nella console del motore la pagina dell'app ha la scheda «Importa» (per i Proprietari e per gli Admin che gestiscono l'app), con anteprima, prova, storico e annullamento. Quando sono le persone a caricare i file dalle pagine dell'app basta un permesso in app.json (più sotto, «Dalle pagine dell'app»); il codice serve per i dati iniziali in una migrazione o per un caricamento con regole proprie dell'app.
Dove stanno i file. I percorsi partono dalla cartella dell'app (quella con app.json); un percorso che esce dalla cartella, o che passa da un collegamento simbolico o da una giunzione dentro la cartella, è rifiutato (regola 3 delle regole comuni).
- File consegnati con l'app: in
release/seed/, per esempio"release/seed/suppliers.csv". - File caricati dalle persone in un campo file di un record: stanno nei dati dell'app e il percorso si costruisce così:
const p = $filepath.join($app.dataDir(), "storage", rec.collection().id, rec.id, rec.getString("file"));
Opzioni di lettura (secondo argomento di analyze, voce options della ricetta):
| voce | valori | significato |
|---|---|---|
format | "csv", "excel" | vuoto = deciso dal contenuto del file |
separator | ";" (predefinito), ",", "\t", "|" | solo CSV; analyze suggerisce quello giusto in suggestedSeparator |
encoding | "utf-8", "utf-16le", "windows-1252" | solo CSV; vuoto = automatica |
sheet | nome del foglio | solo Excel; vuoto = il primo foglio visibile |
header | "none", "titles" (predefinito), "preamble" | none: i dati partono dalla prima riga e le colonne si chiamano A, B, C…; titles: la prima riga dà i nomi delle colonne; preamble: si scartano le prime skipRows righe, la successiva dà i nomi, i dati partono da quella dopo |
skipRows | numero | solo con "preamble" |
titleRows | da 1 a 10 | titoli su più righe ("Purchase" sopra "Price" diventa "Purchase Price") |
skipFooterRows | numero, al massimo 100 | righe finali da scartare, per esempio i totali |
englishNumbers | true, false | nei casi dubbi 1.234 vale uno virgola due tre quattro |
mergedAsEmpty | true, false | Excel: non copiare il valore nelle celle unite |
timeZone | per esempio "Europe/Rome" (predefinito) | per le date con ora |
Tipi delle colonne riconosciuti da analyze (voce type di columns[] e dei valori in sample[].values[]): text, number, date, datetime, time, boolean, empty. I numeri si leggono all'italiana e all'inglese (1.234,56 e 1,234.56): la convenzione si decide per colonna dai suoi valori, una colonna che le mescola resta testo (con una nota), e i casi dubbi (solo valori come 1.234) valgono all'italiana, salvo englishNumbers. Le date si leggono con il giorno prima (31/10/2026) o in formato ISO (2026-10-31); con il mese prima solo se i valori della colonna lo dimostrano (con una nota). I codici con zeri iniziali (00123) e i numeri con più di 15 cifre (IBAN, telai) restano testo; n.d., - e gli errori di Excel valgono come cella vuota. Il tipo è il più preciso che vale per tutti i valori della colonna: nel dubbio, testo. Per i numeri, wholeNumbers dice se sono tutti interi.
L'anteprima di analyze è accorciata: in rawRows, sample, columns[].examples e columns[].title ogni testo ha al massimo 300 caratteri (i più lunghi finiscono con «…»), rawRows e sample mostrano al massimo le prime 200 colonne e solo le prime 200 colonne hanno gli esempi. columns le elenca comunque tutte, e load legge tutte le colonne con i testi interi (un titolo più lungo di 300 caratteri vale accorciato anche come nome della colonna nella ricetta).
Limiti dei testi di un file Excel. Excel salva una volta sola i testi uguali, in un elenco di testi condivisi. Il motore legge al massimo 5.000.000 di testi diversi e 256 MB di testo in tutto; oltre, analyze e load (come «Prova import» e la scheda «Importa» della console) si fermano con un errore che dice quale limite è superato: negli script, in inglese, «the Excel file contains too much text (more than 5,000,000 distinct texts)» oppure «(more than 256 MB of text)»; nella console, nella lingua di chi guarda (in italiano comincia con «Il file Excel contiene troppo testo» e dice «più di 5.000.000 testi diversi» oppure «più di 256 MB di testi»). Sono tetti molto oltre i file veri (bastano per un foglio pieno, 1.048.576 righe, con quasi cinque colonne di testi tutti diversi) e servono a non far esaurire la memoria del motore con un file costruito apposta. Se un file vero li supera, dividilo in più file oppure salvalo come CSV, che non ha questi limiti.
La ricetta (secondo argomento di load):
| voce | significato |
|---|---|
collection | la raccolta dell'app in cui caricare (non di sistema, non una vista) |
options | come leggere il file (tabella sopra) |
fields | colonna del file → campo della raccolta, per esempio { "Plate": "plate" }. La colonna si indica con il nome, la lettera ("A") o il campo proposto da analyze (columns[].field); "" vuol dire non importarla |
key | campi che riconoscono un record già presente, per esempio ["plate"]: con la chiave un nuovo import aggiorna invece di duplicare, e i record che non cambiano non si toccano |
onlyNew | con la chiave: i record già presenti non si toccano |
ignoreBlank | le celle vuote non cancellano i valori già presenti |
relations | campo relazione → campo della raccolta collegata con cui cercare, per esempio { "site": "name" }; senza, il valore della cella è l'id |
batchSize | righe per transazione (predefinito 500) |
name | nome della ricetta, facoltativo: resta nel registro dei caricamenti |
parts | più fogli in più raccolte (vedi «Tutto il file in più raccolte»); allora collection, fields e le altre voci qui sopra stanno in ogni parte |
partial | con parts: con righe scartate si caricano lo stesso le altre; senza, tutto o niente |
Una voce che la ricetta o le opzioni non prevedono (per esempio un nome scritto male) è un errore che la nomina.
Campi che si possono riempire: text, editor, email, url, number, bool, date, select (se multipla, valori separati da ;, , o |), relation, json. Non si importano file allegati, password né date automatiche (autodate); l'id lo decide il database.
Opzioni di load (terzo argomento): dryRun (true = controlla tutto e non salva niente), by (chi ha lanciato il caricamento, resta nel registro), app (vedi le regole comuni). undo prende by, anyway (vedi sotto) e app.
L'esito. Le righe con errori sono scartate e le altre caricate: controlla sempre rejected ed errors (al massimo 500 errori descritti con row, column, field, value e reason; errorCount li conta tutti). Gli altri conteggi: read (righe lette), created, updated, unchanged, skipped (già presenti, con onlyNew), duplicates (righe con una chiave già vista nello stesso file); warnings sono gli avvisi, duration quanto è durato. id è il numero del caricamento, da conservare per poterlo annullare; in prova (dryRun) è vuoto. L'esito di undo ha la stessa forma: lì created sono i record tolti e updated quelli riportati com'erano.
Annullare. undo è tutto o niente: toglie i record che il caricamento ha creato e riporta come prima quelli aggiornati, dal più recente (lo stesso record aggiornato due volte torna com'era prima della prima volta). Prima di cambiare qualcosa controlla e si ferma senza toccare niente in due casi, e dice quali record sono (in errors e nel messaggio, al più 20): record di altri collegati a un record creato dal caricamento (togliendolo, il collegamento si romperebbe o il record dell'altro se ne andrebbe a cascata: blocked: "links"; prima vanno tolti quei collegamenti) e record cambiati dopo il caricamento (blocked: "changed": annullando, quei cambiamenti andrebbero persi). Con { anyway: true } annulla anche i record cambiati dopo; i collegamenti di altri fermano sempre. La scheda «Importa» chiede la conferma con «Annulla comunque». Dalla console, undo vale solo se tutte le raccolte del caricamento sono tra quelle di import.consoleCollections (vedi sotto).
File JSON. analyze, load e gli altri leggono anche i file JSON (.json, .jsonl, .ndjson): un elenco di oggetti ([ {…}, {…} ]) è una tabella; un oggetto con più elenchi ({ "suppliers": [ … ], "orders": [ … ] }) è un file con più fogli, uno per elenco (sheets, e sheet per sceglierne uno; le chiavi senza un elenco di oggetti si saltano, con un avviso); un oggetto per riga (JSON Lines) è una tabella. I nomi delle colonne sono le chiavi, nell'ordine in cui compaiono la prima volta; le parti annidate diventano colonne con il punto (supplier.name), fino a 8 livelli (più giù, testo JSON); gli elenchi dentro un oggetto restano testo JSON (["a","b"]), che i campi a scelta multipla e le relazioni multiple sanno dividere. Numeri, sì/no e null hanno già il loro tipo (i numeri con più di 15 cifre restano testo); i testi passano dal riconoscimento di sempre (date ISO, numeri). La riga dei titoli è la riga 0: l'n-esimo oggetto è la riga n. Le opzioni sulle prime e sulle ultime righe non valgono. Limiti: 128 MB per file, 16 MB per oggetto (un elemento dell'elenco), 64 livelli di annidamento, 16.384 colonne, 1.048.576 oggetti per elenco, 1 MB di testo per valore. In un JSON i numeri scritti come testo ("2.500") si leggono all'inglese, con il punto per i decimali: è il modo dei programmi che scrivono JSON (la colonna ha la nota).
Tutto il file in più raccolte. $orch.import.plan(path, options?) legge tutti i fogli di un Excel (o tutti gli elenchi di un JSON) e propone dove vanno: per ogni foglio la raccolta più simile, con un grado di somiglianza da 0 a 1 (il nome del foglio e i titoli delle colonne, senza badare a maiuscole, accenti, spazi, singolare e plurale, con un piccolo vocabolario italiano e inglese dei gestionali: fornitore = supplier, partita IVA = VAT number…), le colonne → campi, la chiave (un indice unico, poi un campo come codice, partita IVA, targa, email, poi un campo «presentable» o un nome; la chiave automatica c'è solo con un indice unico che ha tutte le colonne (un indice con WHERE vale solo se esclude soltanto i valori vuoti dei suoi campi), oppure in una raccolta senza collegamenti abbinati a una colonna del file: lì un codice (anche email, partita IVA, codice fiscale, targa, telaio), un campo «presentable» o un nome, con valori tutti diversi, pieni in ogni riga (lo 0 di un numero vale vuoto) e che nel database trovano al più un record. Una raccolta con un collegamento abbinato a una colonna, obbligatorio o no, singolo o a più valori (le aziende di un contatto), non ha chiave automatica: una chiave proposta non aggiorna mai la riga di un altro genitore (per esempio, per i veicoli legati a una sede serve un indice unico sulla targa). L'avviso suggerisce quella da scegliere a mano, per esempio ordine + articolo; la chiave scelta a mano, anche composta, funziona come sempre. Al caricamento una chiave che trova più record scarta la riga, con il motivo; con onlyNew la riga si salta, ma non se il record trovato ha un altro collegamento; un valore di un collegamento che trova più record (nel database o tra quelli dell'import) scarta la riga, con il motivo; con partial una riga che cerca il record di una riga scartata dello stesso import si scarta anche lei («il record collegato … non si carica»), senza cercarlo nel database; in un foglio con tutto dentro una riga con il numero dell'ordine e senza il cliente vale come la riga piena con lo stesso numero, se nel file c'è) e il campo con cui trovare i record collegati. Un foglio con dentro i dati di più raccolte (ogni riga ha l'ordine e anche nome e partita IVA del fornitore) si divide: le colonne del fornitore («Fornitore», «P.IVA fornitore», supplier.vat) vanno nella sua raccolta, un record per chiave (distinct: le righe dopo la prima con la stessa chiave non contano; ignoreBlank), e l'ordine si collega a lui. recipe è pronta per load; sheets dice per ogni foglio la raccolta scelta, la somiglianza, le alternative, le raccolte collegate e le colonne che non trovano un campo; order è l'ordine di caricamento; missing dice a parole cosa non trova posto (fogli senza raccolta, colonne senza campo): si salta, non ferma niente. Si importa solo in raccolte che esistono già: la struttura la decide l'app con le sue migrazioni.
load con una ricetta con le parti (recipe.parts: ognuna con sheet e le voci di una ricetta: collection, fields, key, onlyNew, ignoreBlank, relations, distinct, options proprie se servono) carica le raccolte nell'ordine delle relazioni (prima quelle a cui le altre si collegano), così ogni collegamento trova il suo record, anche quello appena creato dallo stesso import. È tutto o niente, in due tempi: prima controlla tutto senza scrivere (conversioni, collegamenti, regole dei campi; i record nuovi hanno già il loro id e le scritture da fare vanno in un file di appoggio, non in memoria), poi scrive in una transazione sola per tutte le raccolte. Con una riga scartata (senza partial) si ferma già al controllo; con un errore durante le scritture, con il lavoro fermato o oltre il tempo massimo non resta niente (rolledBack: true, nel registro «fallito»). Mentre scrive, le altre scritture dell'app aspettano (le letture no): per questo un import di più raccolte prende al più 50.000 righe (di tutti i fogli; oltre, si divide il file o si importa una raccolta alla volta) e le scritture durano al più 60 secondi. La prova (dryRun) controlla tutto senza salvare, anche i collegamenti verso i record che l'import creerebbe, e in waitSeconds stima per quanti secondi le altre scritture aspetteranno. I collegamenti alla stessa raccolta (il capo di un dipendente) e quelli che girano in tondo (reparto → responsabile → reparto) si riempiono in una seconda passata, quando tutti i record ci sono (serve una chiave nella parte, e il campo non obbligatorio; se no un avviso lo dice). Una parte che non si può caricare (foglio che il file non ha, raccolta che non c'è, colonna della chiave che manca) ha il motivo in skippedReason: senza partial non si carica niente, perché il tutto o niente varrebbe solo a metà; con partial si caricano le altre. Una colonna della ricetta che il file non ha si salta con un avviso. Nella proposta, un foglio con tutto dentro riempie l'anagrafica collegata solo con i record nuovi (onlyNew: quelli che l'app ha già non si cambiano), e la stessa chiave con valori diversi nelle righe ha un avviso (vale la prima riga); una proposta debole o quasi alla pari con un'altra raccolta ha un avviso in warnings (e nella scheda non è verde), una chiave che si ripete nel file non si propone. Il campo con cui cercare i record collegati (relations) dev'essere id o un campo di testo, numero, email, indirizzo o scelta della raccolta collegata. L'esito ha i totali e, in tables, l'esito di ogni parte nell'ordine di caricamento (con sheet); gli errori in errors hanno anche sheet e collection. undo toglie l'intero import, al contrario dell'ordine di caricamento. Dalla scheda «Importa» un lavoro in corso si ferma con «Ferma» (un import di più raccolte fermato non lascia niente).
Tutte le righe, a pezzi. analyze mostra solo le prime 20 righe; per leggerle tutte da uno script c'è $orch.import.rows(path, options?, { offset, limit }): gli stessi valori di sample (con i testi interi), al più 5.000 righe per pezzo (meno se il file è molto largo o i testi sono lunghi). Si va avanti con next finché non è null. Ogni pezzo rilegge il file.
Celle vuote nei campi numero. PocketBase non ha il numero vuoto (un campo numero vale 0). Una cella vuota in un campo numero vale «nessun valore»: in un record già presente non cambia il numero che c'è (anche senza ignoreBlank); in un record nuovo il campo resta com'è (lo decide l'app, per esempio con un hook); se il campo è obbligatorio, nel record nuovo la riga si scarta con il motivo.
Ricette nel pacchetto dell'app. Le ricette che servono all'app si consegnano con il pacchetto, in release/import/*.json (lo stesso JSON di una ricetta, anche con parts; name facoltativo: senza, il nome del file). La scheda «Importa» della console le offre accanto a quelle salvate dall'IT, in sola lettura (si usano, si salvano con un altro nome, non si cancellano; una scritta male si vede con il motivo e non si usa). Dalle pagine si usano per nome come quelle salvate (toolAccess.import.recipes); una ricetta salvata dall'IT con lo stesso nome vale al posto di quella del pacchetto.
Cosa può importare la console. La scheda «Importa» della console importa come il server: le regole API e le rotte dell'app non valgono. Un'app che tiene i dati in ordine con le sue rotte (spostamenti, storici, stati) dice in app.json le sole raccolte in cui la console può importare:
"import": { "consoleCollections": ["suppliers", "orders", "order_lines"] }
Con la voce la scheda offre e accetta solo quelle (anche con «Tutto il file»); una lista vuota la chiude del tutto; una voce scritta male la chiude, con il motivo. Senza la voce la console importa in tutte le raccolte, e la scheda lo dice. Gli script dell'app non ne sono toccati; le pagine seguono toolAccess.import.
Regole per questo strumento:
- Prima
{ dryRun: true }, poi il caricamento vero. Mostra alla persona gli errori con il numero di riga e il motivo. - Il caricamento passa dalle validazioni dei campi e dagli hook dei record (
onRecordCreate,onRecordValidate…) ma non dalle regole API della raccolta né dagli hook delle richieste (onRecordCreateRequest…): scrive il server. Per questo la rotta che lancia l'import controlla da sé chi la chiama:$apis.requireAuth("users")e il ruolo dell'utente. - I caricamenti veri si registrano nelle raccolte di sistema
orch_importseorch_import_rows, create dal motore al primo caricamento; le ricette salvate dalla console stanno inorch_import_recipes. Non toccarle e non crearle nelle migrazioni. - Un file grande può richiedere secondi o minuti: lancia l'import da una rotta (
routerAdd) chiamata dalla pagina, non dentro l'hook del salvataggio di un altro record. - Per un file caricato dalle persone: un record in una raccolta tipo
uploadscon un campo file, poi una rottaPOSTche riceve l'id del record, costruisce il percorso come sopra e lancia prima la prova e poi il caricamento.
Dalle pagine dell'app (regola 8 delle regole comuni), senza scrivere rotte: lo strumento in "tools" e in app.json un permesso come questo.
"toolAccess": {
"import": { "pages": true, "minRole": "editor", "destructiveRole": "admin", "collections": ["parts"], "recipes": ["Parts price list"], "maxRows": 5000 }
}
POST /api/tools/filescontool=importe il file (.xlsx,.xlsm,.xls,.csv,.txt,.tsv,.json,.jsonl,.ndjson, al massimo 100 MB) →{ id, … }.POST /api/tools/import/preview{ file, options }: l'anteprima, comeanalyze(non salva niente), piùwarningCodes.POST /api/tools/import/plan{ file, options }: la proposta di tutto il file, comeplan, solo con le raccolte ditoolAccess.import.collections(non carica niente).POST /api/tools/import/jobs{ file, recipe, dryRun }→202 { job }: prova o caricamento vero in sottofondo; lo stato del lavoro ha l'esito inresult, come quello diload. La ricetta è il nome di una ricetta salvata dalla scheda «Importa» della console (raccoltaorch_import_recipes), elencata inrecipes, su una raccolta elencata incollections: la pagina non manda mai una ricetta sua. Il caricamento vero si accetta solo dopo una prova riuscita della stessa persona con lo stesso file e la stessa ricetta (se intanto l'IT cambia la ricetta, si rifà la prova). Un file con più righe dimaxRows(senza: 10.000) si rifiuta. Annullare il lavoro lo ferma tra una riga e l'altra: i lotti già salvati restano e si tolgono con l'annullamento.POST /api/tools/import/undo{ id, anyway? }→202 { job }: annulla un caricamento fatto dalle pagine (mai uno della console o di uno script), solo condestructiveRolee con tutte le raccolte del caricamento incollections;anywaycome inundo.POST /api/tools/import/history{}: i caricamenti fatti dalle pagine da chi chiede (nel registrostarted_byè «nome <email> [pages: <raccolta>/<id utente>]» e lo storico cerca la parte[pages: …]: il legame è per id, cambiare nome o email non toglie niente; di chi ha annullato un caricamento si vede solo il nome). Una prova annullata non lascia eventi. Dagli script:$orch.import.history({ by, limit }).- Ogni caricamento vero e ogni annullamento lascia un evento nel registro del motore; la prova no.
Esempio: dati iniziali consegnati con l'app, in una migrazione.
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
$orch.import.load("release/seed/suppliers.csv", {
collection: "suppliers",
options: { format: "csv", separator: ";" },
fields: { "Account": "account", "Company name": "company_name", "VAT number": "vat_number" },
key: ["account"],
}, { app: app, by: "initial data" });
}, (app) => {});
Esempio: import lanciato da una pagina dell'app, solo per il responsabile (ruolo manager).
routerAdd("POST", "/api/pricelist/import/{id}", (e) => {
if (e.auth.getString("role") !== "manager") {
throw new ForbiddenError("Only the manager can import the price list.");
}
const rec = e.app.findRecordById("uploads", e.request.pathValue("id"));
const file = $filepath.join(e.app.dataDir(), "storage", rec.collection().id, rec.id, rec.getString("file"));
const recipe = {
collection: "parts",
options: { format: "excel", header: "titles" },
fields: { "Code": "code", "Description": "description", "Price": "price" },
key: ["code"],
};
const trial = $orch.import.load(file, recipe, { dryRun: true, app: e.app });
if (trial.rejected > 0) {
return e.json(400, { message: "Some rows need fixing.", result: trial });
}
const result = $orch.import.load(file, recipe, { by: e.auth.email(), app: e.app });
rec.set("import_id", result.id);
e.app.save(rec);
return e.json(200, result);
}, $apis.requireAuth("users"));
Lavori pianificati (jobs)
I lavori che gli script dell'app pianificano con cronAdd, sotto controllo: ogni esecuzione registrata (quando è partita, quanto è durata, com'è finita, l'errore), pausa e ripresa che restano dopo un riavvio, «Avvia ora», un evento del motore quando un lavoro fallisce. Nella console, la pagina «Lavori pianificati» con tutte le app insieme.
Si attiva con "tools": ["jobs"] in app.json. Categoria: Automazione.
Funzioni
$orch.jobs.list()
I lavori pianificati dell'app (non quelli di PocketBase), con la pianificazione (espressione cron e in parole, in inglese, UTC), la pausa, la prossima e l'ultima esecuzione. failing = l'ultima esecuzione finita è fallita.
Restituisce: [{ name, schedule, scheduleText, paused, pausedBy, pausedAt, running, nextRun, lastRun: { id, job, source, startedBy, status, startedAt, finishedAt, duration, error } | null, failing }].
routerAdd("GET", "/api/myapp/jobs", (e) => {
const jobs = $orch.jobs.list();
return e.json(200, jobs.map((j) => ({ name: j.name, next: j.nextRun, ok: !j.failing })));
}, $apis.requireSuperuserAuth());
$orch.jobs.run(name)
Avvia subito il lavoro dell'app, in sottofondo (anche se è in pausa). Se è già in corso lancia un errore. L'esecuzione resta registrata con l'origine «script».
Restituisce: { name, started }.
cronAdd("nightly-totals", "0 2 * * *", () => {
// … recalculations …
});
routerAdd("POST", "/api/myapp/recalculate", (e) => {
return e.json(202, $orch.jobs.run("nightly-totals"));
}, $apis.requireSuperuserAuth());
API HTTP del motore
Non le chiama l'app: servono alla console e al portale. Sono elencate perché l'app non le rifaccia.
| metodo | percorso | chi | stato | cosa fa |
|---|---|---|---|---|
| GET | /api/platform/apps/{slug}/crons | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | I lavori pianificati di un'app (la pianificazione in parole nella lingua della richiesta). |
| GET | /api/platform/apps/{slug}/crons/{job}/runs | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Le ultime esecuzioni di un lavoro (se ne tengono al più 50 per lavoro). |
| POST | /api/platform/apps/{slug}/crons/{job}/run | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Avvia ora il lavoro, in sottofondo; 409 se è già in corso. |
| POST | /api/platform/apps/{slug}/crons/{job}/pause | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Mette in pausa il lavoro: non parte da solo, anche dopo un riavvio dell'app. |
| POST | /api/platform/apps/{slug}/crons/{job}/resume | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Riprende un lavoro in pausa. |
Come si usa
Quando usarlo. L'app pianifica i suoi lavori come in PocketBase, con cronAdd(nome, pianificazione, funzione) in pb_hooks: non cambia niente in come si scrivono. Il motore registra ogni esecuzione di ogni lavoro (quando è partita, quanto è durata, com'è finita, il messaggio d'errore), tiene lo stato di pausa di ogni lavoro e mostra tutto all'IT nella console, pagina «Lavori pianificati» e scheda nella pagina dell'app, con «Avvia ora», «Pausa» e «Riprendi». Vale per tutte le app PocketBase, anche senza lo strumento attivo: attiva lo strumento ("tools": ["jobs"]) solo se gli script dell'app hanno bisogno di $orch.jobs.list() o di $orch.jobs.run(name), per esempio per un pulsante «Ricalcola adesso» in una pagina dell'app.
Regole per i lavori.
- Dai a ogni lavoro un nome stabile e leggibile (il primo argomento di
cronAdd): è il nome che vede l'IT, la chiave dello storico e della pausa. Cambiare il nome di un lavoro fa partire uno storico nuovo e ne perde la pausa. Usa minuscole, cifre e trattini, per esempio"nightly-totals". - La pianificazione è in UTC, come in PocketBase:
"0 2 * * *"parte alle 02:00 UTC (alle 03:00 o alle 04:00 in Italia, secondo l'ora legale). La console scrive la pianificazione in parole con «UTC» e la prossima esecuzione nell'ora locale. PocketBase controlla i cinque campi tutti insieme: giorno del mese e giorno della settimana devono andare bene entrambi (non l'uno o l'altro come nel cron di Unix). - Un lavoro fallito lancia un errore. Lascia uscire l'errore dalla funzione (o lancia il tuo
new Error("…")con un messaggio chiaro): l'esecuzione resta registrata come fallita con quel messaggio, il motore scrive un evento e la panoramica della console mostra il lavoro in rosso. Gli eventi non sono uno per esecuzione: uno quando il lavoro comincia a fallire, poi al più un promemoria ogni 6 ore se continua (con quante esecuzioni sono fallite dall'ultimo avviso), e uno quando torna a posto. Ogni esecuzione, con il suo errore, resta nello storico del lavoro. Non ingoiare gli errori con uncatchvuoto: il lavoro sembrerebbe a posto. - Un lavoro in pausa non parte da solo, anche dopo un riavvio dell'app. «Avvia ora» (dalla console o con
$orch.jobs.run) lo fa partire lo stesso, una volta; anche il pulsante «Run» del pannello di PocketBase dell'app vale come «Avvia ora» (nello storico: «pannello di PocketBase» e chi l'ha premuto). Scrivi i lavori in modo che un'esecuzione in più, o una saltata, non faccia danni: calcola dai dati, non contare su «ieri è girato». - Lavori lunghi. Un lavoro gira in sottofondo in una delle macchine JS dell'app; PocketBase può far partire un'esecuzione nuova mentre la precedente è ancora in corso. Se sarebbe un problema, controlla all'inizio (per esempio con un record con lo stato del lavoro). «Avvia ora» non fa partire un lavoro che è già in corso. Un riavvio dell'app interrompe le esecuzioni in corso: quello chiesto dall'IT («Riavvia») e anche quello automatico dopo una modifica della scheda dell'app (app.json, ruoli, porta, memoria, modo della rete in uscita). Il riavvio automatico aspetta i lavori partiti dalle pagine (regola 8), non i lavori pianificati: l'esecuzione resta nello storico come «interrotta». Scrivi un lavoro lungo in modo che si possa rifare (a pezzi, ricordando in un record fin dove è arrivato), non tutto o niente.
- Il motore tiene le ultime 50 esecuzioni di ogni lavoro nella raccolta di sistema nascosta
orch_job_runse gli stati di pausa inorch_jobs: l'app non deve scriverci. I lavori di PocketBase (__pb…, pulizia dei log, backup automatici) e quelli che il motore mette da sé in ogni app (le pulizie del deposito degli strumenti e dello storico) non sono dell'app: non compaiono e non si mettono in pausa.
// pb_hooks/jobs.pb.js
cronAdd("nightly-totals", "0 2 * * *", () => {
const orders = $app.findRecordsByFilter("orders", "status = 'paid'", "", 0, 0);
// … recalculations; on a problem: throw new Error("totals not computed: …")
});
Numerazioni (numbering)
Numeri progressivi sicuri anche quando più persone salvano insieme (per esempio il numero di una richiesta d'acquisto per anno), nel formato che scegli: niente doppioni e niente buchi, perché il numero si prende nella stessa transazione del salvataggio.
Si attiva con "tools": ["numbering"] in app.json. Categoria: Dati.
Funzioni
$orch.numbering.next(name, options?)
Prende il numero successivo di un contatore di app.json (il contatore avanza). Usala dentro la tua runInTransaction, con l'app della transazione e salvando il record con quella stessa app, altrimenti un salvataggio che non riesce lascia un buco (negli hook dei record l'app dell'hook di solito non è in una transazione): per i numeri di una raccolta è meglio l'assegnazione automatica di app.json. Opzioni: app.
Restituisce: { name, number, seq, period }.
number è il numero scritto con il formato, seq il numero progressivo nel periodo.
// in your own transaction, saving with the same app: if the save fails, the number goes back too
e.app.runInTransaction((txApp) => {
rec.set("code", $orch.numbering.next("parts", { app: txApp }).number);
txApp.save(rec);
});
$orch.numbering.peek(name, options?)
Il contatore com'è adesso, con il prossimo numero, senza prenderlo.
Restituisce: { name, collection, field, format, reset, period, current, next, nextSeq, periods: [{ period, value, updated }] }.
const p = $orch.numbering.peek("rda"); // p.next is the number the next request will get
Dalle pagine dell'app: POST /api/tools/numbering/peek (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.numbering.list(options?)
Tutti i contatori di app.json, con il valore attuale e il prossimo numero.
Restituisce: [{ name, collection, field, format, reset, period, current, next, nextSeq, error, errorKey, errorArgs, periods }].
const counters = $orch.numbering.list();
Dalle pagine dell'app: POST /api/tools/numbering/list (effetto: solo lettura), con i permessi di toolAccess in app.json.
API HTTP del motore
Non le chiama l'app: servono alla console e al portale. Sono elencate perché l'app non le rifaccia.
| metodo | percorso | chi | stato | cosa fa |
|---|---|---|---|---|
| POST | /api/platform/apps/{slug}/numbering/{name} | Proprietari della console | pronta | Scheda «Numerazioni» di un'app nella console: correzione a mano di un contatore (scritta negli eventi). |
Come si usa
Quando usarlo. Ogni volta che i record di una raccolta hanno un numero progressivo che le persone leggono e citano: il numero di una richiesta d'acquisto, di un ordine, di un verbale, di un ticket. Non calcolare il numero a mano negli hook (per esempio contando i record o leggendo l'ultimo): due salvataggi insieme prenderebbero lo stesso numero, e un salvataggio che non riesce lascerebbe un buco. Il motore assegna il numero dentro la stessa transazione del salvataggio: niente doppioni e niente buchi, anche con molte persone che salvano insieme.
Configurazione in app.json, con lo strumento attivo:
"tools": ["numbering"],
"numbering": {
"rda": { "collection": "requests", "field": "number", "format": "RDA-{YYYY}-{seq:4}", "reset": "yearly" }
}
| voce | significato |
|---|---|
chiave ("rda") | il nome del contatore: lettere, cifre, - e _, al massimo 40 |
collection, field | la raccolta e il campo in cui il motore scrive il numero alla creazione dei record; insieme, oppure nessuno dei due per un contatore usato solo dagli script. Il campo è di testo, oppure numerico (con il solo numero progressivo, e allora reset deve essere "never": un campo numerico non distingue gli anni) |
format | testo con {YYYY}, {YY}, {MM}, {DD} (data della creazione) e un solo {seq} o {seq:N} (il numero progressivo, con almeno N cifre: {seq:4} dà 0001) |
reset | "yearly" (riparte da capo ogni anno: il formato deve avere {YYYY} o {YY}), "monthly" (ogni mese: anche {MM}), "never" (predefinito) |
start | il primo numero di ogni periodo (predefinito 1) |
timeZone | il fuso per la data e per il cambio d'anno, per esempio "Europe/Rome"; senza, quello del server |
Come funziona. Alla creazione di un record della raccolta, da qualunque strada (pagine dell'app, script, import, console), se il campo è vuoto il motore ci scrive il numero successivo, subito prima della scrittura nel database: dopo gli hook dell'app (in onRecordCreate e onRecordCreateExecute il numero c'è dopo e.next(); un numero che l'hook scrive prima di e.next() resta). Durante le validazioni il campo vuoto ha il numero che il contatore darebbe adesso, così un campo obbligatorio non ferma la creazione (anche nella prova dell'import e con $app.validate()); negli hook onRecordValidate dell'app il campo è ancora vuoto. Dalle API delle pagine il campo non si sceglie né si cambia: il motore toglie il valore mandato dalla pagina alla creazione e lo rimette com'era a ogni modifica. Uno script, l'import o la console possono invece scrivere un numero preciso (per esempio i numeri di un vecchio gestionale): resta, e il contatore lo salta quando ci arriva. Un contatore scritto male in app.json ferma la creazione con un errore che spiega cosa manca (alle persone dell'app nella lingua della pagina) e si vede nella scheda «Numerazioni». Metti comunque un indice unico sul campo, come rete di sicurezza.
Dove stanno i contatori. Nella raccolta di sistema orch_counters dell'app, creata dal motore: viaggia nei backup, non si vede dalle API (nemmeno dai superuser) né nella scheda Dati. Non toccarla e non crearla nelle migrazioni. Nella console del motore la pagina dell'app ha la scheda «Numerazioni», con l'ultimo numero dato e il prossimo; un Proprietario può correggere un contatore (per esempio per continuare dall'ultimo numero di un vecchio programma), e la correzione resta negli eventi.
Regole per questo strumento:
- Per i numeri dei record usa l'assegnazione automatica di
app.json: non serve codice. $orch.numbering.next(name)prende un numero e fa avanzare il contatore subito: usala dentro la tuarunInTransaction, passando l'app della transazione ({ app: txApp }) e salvando il record con quella stessa app, altrimenti un salvataggio che non riesce lascia un buco. Negli hook dei record l'app dell'hook di solito non è in una transazione (lo è dentro un lotto/api/batch).peekelistleggono soltanto.- Per mostrare alla persona il numero che avrà la richiesta prima di salvarla,
peekdà il prossimo numero: è un'indicazione, perché un'altra persona può salvare prima. - Il numero si assegna nella transazione della sola scrittura, dopo gli hook dell'app e dopo il caricamento dei file: negli hook scrivi con l'app dell'hook (
e.app), che dentro un lotto/api/batchè una transazione.
Esempio: lo stesso contatore usato per un codice diverso da quello automatico, da una rotta dell'app, nella stessa transazione del salvataggio.
routerAdd("POST", "/api/parts", (e) => {
const body = e.requestInfo().body;
const rec = new Record(e.app.findCollectionByNameOrId("parts"));
e.app.runInTransaction((txApp) => {
rec.set("name", body.name);
rec.set("code", $orch.numbering.next("parts", { app: txApp }).number);
txApp.save(rec); // if the save fails, the number goes back too
});
return e.json(200, rec);
}, $apis.requireAuth("users"));
Esempio: il prossimo numero per la pagina di una nuova richiesta.
routerAdd("GET", "/api/requests/next-number", (e) => {
return e.json(200, { next: $orch.numbering.peek("rda").next });
}, $apis.requireAuth("users"));
Dalle pagine dell'app (regola 8 delle regole comuni), solo in lettura: lo strumento in "tools" e un permesso in app.json.
"toolAccess": {
"numbering": { "pages": true, "collections": ["requests"] }
}
POST /api/tools/numbering/peek{ name }: il contatore e il prossimo numero, comepeek, senza prenderlo.POST /api/tools/numbering/list{}: comelist, solo i contatori che la persona può leggere.- Quali contatori: quelli con
collectiontra lecollectionsdel permesso, e solo se l'app mostra a qualcuno i record di quella raccolta con le sue API (regola di elenco o di lettura non null): un contatore dice quanti record ci sono, e una raccolta che l'app tiene solo per i superuser non lo deve dire alle pagine. I contatori senza raccolta sono solo degli script. Attenzione: il contatore è quello di tutta la raccolta (quanti numeri sono stati assegnati, cioè più o meno quanti record ci sono), e lo legge chiunque abbia il permesso, anche chi con le regole dell'app non vede nessun record di quella raccolta. Se il totale non va detto a tutti, metti unminRole(o non aprirenumberingalle pagine). - Perché
nextnon si offre alle pagine. Il numero di un record lo assegna il motore da solo alla creazione del record, dentro la transazione del salvataggio: la pagina non lo deve chiedere. Un numero preso da una pagina starebbe fuori da ogni transazione: se poi la pagina non salva (scheda chiusa, errore, validazione), quel numero è perso e la sequenza ha un buco; e chiunque abbia il permesso potrebbe consumare i numeri. Per mostrare il numero che avrà il prossimo record si usapeek: è un'indicazione, perché un'altra persona può salvare prima; il numero vero è nel record che le API restituiscono dopo la creazione.
Esempio: la pagina di una richiesta nuova mostra il numero che avrà probabilmente.
<script src="/__platform/tools.js"></script>
<script>
async function nextNumberHint() {
const c = await PyriteTools.call("numbering", "peek", { name: "rda" });
return c.next; // for example "RDA-2026-0043": the request saved now will probably get it
}
</script>
PDF (pdf)
Crea documenti PDF, e gli stessi documenti come un file HTML unico, da un documento fatto di blocchi (titoli, testi, campi, tabelle con i totali, immagini, codici QR e a barre) o dai modelli dell'app riempiti con i dati, nello stile di stampa di Pyrite con i caratteri IBM Plex dentro. Legge anche i PDF che hanno il testo (informazioni, testo con le posizioni, campi dei moduli, una pagina come immagine) e unisce più PDF. Niente OCR.
Si attiva con "tools": ["pdf"] in app.json. Categoria: Documenti.
Funzioni
$orch.pdf.render(doc | { template, data }, options?)
Fa un PDF (o, con format: "html", un file HTML unico) da un documento a blocchi, oppure da un modello di release/templates/ riempito con i dati: { template, data }, dove @pyrite è il modello standard del motore. Il file va dove dicono le opzioni, altrimenti resta in memoria per send. Opzioni: format, name, lang, path, record, field, owner, app, maxPages.
Restituisce: { format, type, name, size, pages, id? | path? | record?, field? | file? }.
pages è il numero delle pagine; id c'è quando il file resta in memoria (per $orch.pdf.send, per 5 minuti); path, record e field, oppure file, quando è stato salvato.
routerAdd("GET", "/api/orders/{id}/pdf", (e) => {
const order = e.app.findRecordById("orders", e.request.pathValue("id"));
const r = $orch.pdf.render({ template: "order", data: { number: order.getString("number"), supplier: order.getString("supplier") } },
{ name: "order-" + order.getString("number") + ".pdf", lang: "it" });
return $orch.pdf.send(e, r);
}, $apis.requireAuth("users"));
Dalle pagine dell'app: POST /api/tools/pdf/render (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.pdf.html(template, data?, options?)
Esegue un modello HTML dell'app (html/template di Go: i dati passano dall'escaping) e fa un file HTML unico con dentro caratteri e immagini; script e risorse esterne si tolgono. Funzioni nel modello: image, recordImage, qr, barcode, money, number, percent, date, datetime, pyriteLogo. Opzioni: name, lang, path, record, field, owner, app.
Restituisce: { format: "html", type, name, size, id? | path? | record?, field? | file? }.
const r = $orch.pdf.html("label.html", { code: "F-0001", price: 12.5 }, { path: "state/labels/F-0001.html" });
// release/templates/label.html: <p>{{.code}} · {{money .price}}</p><img src="{{qr .code}}">
Dalle pagine dell'app: POST /api/tools/pdf/html (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.pdf.send(e, result, options?)
Manda un file fatto dalle altre funzioni come risposta della rotta, da scaricare (inline: true apre PDF e immagini PNG nel browser). Opzioni: inline, name.
Restituisce: null (the response is written).
return $orch.pdf.send(e, $orch.pdf.render(doc), { inline: true });
$orch.pdf.info(source, options?)
Pagine, metadati e se il PDF è cifrato. La sorgente è un percorso nella cartella dell'app o il file di un record: { record, field }. Opzioni: password, app.
Restituisce: { pages, encrypted, passwordRequired, version, width, height, title, author, subject, keywords, creator, producer, created, modified, size, hasForm }.
const i = $orch.pdf.info("release/seed/manual.pdf"); // or { record: e.record, field: "attachment" }
Dalle pagine dell'app: POST /api/tools/pdf/info (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.pdf.text(source, options?)
Il testo di ogni pagina con la posizione di ogni pezzo, in punti dall'angolo in alto a sinistra. Solo per i PDF che hanno il testo: niente OCR. Opzioni: pages, positions, password, maxPages, app.
Restituisce: { pageCount, chars, pages: [{ page, width, height, text, pieces: [{ text, x, y, width, height, size }] }] }.
const t = $orch.pdf.text({ record: e.record, field: "invoice" }, { pages: [1] });
const total = t.pages[0].pieces.find((p) => p.text.startsWith("Total"));
Dalle pagine dell'app: POST /api/tools/pdf/text (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.pdf.fields(source, options?)
I campi dei moduli del PDF, con i loro valori. Opzioni: pages, password, maxPages, app.
Restituisce: { pageCount, fields: [{ page, name, type, value, values, checked, options, readOnly, required }] }.
const f = $orch.pdf.fields("state/uploads/form.pdf");
Dalle pagine dell'app: POST /api/tools/pdf/fields (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.pdf.pageImage(source, page, options?)
Una pagina come immagine PNG, al massimo 300 dpi e 20,000,000 pixel (meno per le app con poca memoria). Opzioni: dpi, password, name, path, record, field, owner, app.
Restituisce: { format: "png", type, name, size, width, height, dpi, page, id? | path? | record?, field? | file? }.
const img = $orch.pdf.pageImage("release/seed/manual.pdf", 1, { dpi: 100, path: "state/previews/manual-1.png" });
Dalle pagine dell'app: POST /api/tools/pdf/page-image (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
$orch.pdf.merge(sources, options?)
Unisce da 2 a 20 PDF in uno, nell'ordine dato (le pagine, senza segnalibri né campi dei moduli). I PDF cifrati si rifiutano. Opzioni: name, path, record, field, owner, maxPages, app.
Restituisce: { format: "pdf", type, name, size, pages, id? | path? | record?, field? | file? }.
const all = $orch.pdf.merge(["state/out/cover.pdf", { record: e.record, field: "attachment" }], { name: "dossier.pdf" });
Dalle pagine dell'app: POST /api/tools/pdf/merge (effetto: solo lettura), come lavoro in sottofondo (202 con il lavoro), con i permessi di toolAccess in app.json.
Come si usa
Quando usarlo. Ogni volta che un'app consegna un documento da stampare, firmare o mandare: un buono d'ordine, un verbale, un riepilogo, un'etichetta con il codice a barre. Non generare PDF a mano negli script e non installare librerie: descrivi il documento a blocchi (o scrivi un modello in release/templates/) e il motore fa il PDF, con i caratteri IBM Plex dentro il file, nello stile di stampa di Pyrite (righe nere, etichette in maiuscoletto mono, titoli forti). Dallo stesso documento esce anche un file HTML unico (caratteri, stile e immagini dentro), per chi lo vuole aprire nel browser o archiviarlo. Lo strumento legge anche i PDF che hanno il testo (non le scansioni: niente OCR in questa versione) e unisce più PDF.
Il documento a blocchi (misure in millimetri, caratteri in punti):
{
"lang": "it", "title": "Buono d'ordine 2026/42", "currency": "EUR",
"page": { "size": "A4", "orientation": "portrait", "margins": 18 },
"header": { "logo": "pyrite", "right": "Officina Rossi" },
"footer": { "left": "Documento interno", "right": "Pagina {page} di {pages}" },
"blocks": [
{ "type": "title", "text": "Buono d'ordine", "subtitle": "n. 2026/42 del 3 novembre 2026" },
{ "type": "fields", "columns": 3, "items": [ { "label": "Fornitore", "value": "Ricambi Bianchi" }, { "label": "Totale", "value": 1234.5, "format": "money" } ] },
{ "type": "table", "columns": [ { "key": "code", "title": "Codice", "width": 30 }, { "key": "qty", "title": "Q.tà", "format": "integer" }, { "key": "price", "title": "Prezzo", "format": "money" } ],
"rows": [ { "code": "F-0001", "qty": 2, "price": 10.5 } ], "totals": { "price": 21 } },
{ "type": "text", "text": "Consegna entro **venerdì**, *salvo diverso accordo*." },
{ "type": "qr", "text": "https://example.com/ordini/42", "size": 28, "align": "right" }
]
}
| blocco | voci |
|---|---|
title | text, subtitle: titolo forte con una riga nera sotto |
heading | text, level (1 con la riga sotto, 2 senza): non resta mai da solo in fondo alla pagina |
text | text con **grassetto** e *corsivo* (\* per un asterisco; valgono in coppia e attaccati alle parole: 5 * 3 e A*B restano asterischi), paragrafi separati da una riga vuota; align (left, center, right, justify), size |
fields | items: [{ label, value, format, decimals }] su una griglia di columns colonne (1–4, predefinito 2); un valore più lungo di una pagina continua nella pagina dopo |
table | columns: [{ key, title, width, align, format, decimals, currency }] (senza width: lo spazio che resta in parti uguali), rows (oggetti con le key, oppure elenchi nell'ordine delle colonne), totals (come una riga, in fondo, in semigrassetto), totalsLabel, size. L'intestazione si ripete a ogni pagina e una riga non si spezza mai a metà |
image | path (un file PNG, JPEG o GIF dell'app) oppure file: { collection, record, field, name } (il file di un record), width, height (nel riquadro, senza deformare), align, caption |
qr | text, size (lato in mm, predefinito 30), align, caption |
barcode | format (code128 predefinito, ean13: 12 cifre, la tredicesima la calcola il motore), text, width, height, humanReadable (il testo sotto le barre, predefinito sì) |
spacer | height (se non ci sta, la pagina finisce lì) |
pageBreak | — (all'inizio, in fondo o due di fila non lascia pagine vuote) |
Formati di format (colonne e campi): text, number (2 decimali), integer, money (la valuta del documento o currency della colonna: 1.234,50 € in italiano, €1,234.50 in inglese), percent (0,25 → 25 %, come Excel), date, datetime (dal formato di PocketBase o ISO: 03/11/2026 in italiano, 2026-11-03 in inglese). I numeri si arrotondano a metà in su come sono scritti (10,125 → 10,13; 2,5 → 3). PocketBase scrive le date in UTC: una data con il suo fuso si scrive nel fuso timeZone del documento (un nome IANA come "Europe/Rome"; senza: quello del server), una data senza ora o senza fuso resta com'è. I caratteri che i caratteri IBM Plex non hanno oltre il piano di base (le emoji) nel PDF diventano �; l'HTML li tiene. Nella testata e nel piede {page} e {pages} sono il numero della pagina e quante sono; logo è "pyrite" (il segno del motore) o un'immagine dell'app. Stampando l'HTML (browser Chromium) i testi della testata e del piede vanno nei margini di ogni pagina e il logo esce in cima alla prima pagina; nel PDF il logo è in ogni pagina. Una voce scritta male è un errore che dice quale blocco e quale voce.
Modelli dell'app. Un file release/templates/<nome>.json con lo stesso documento e i segnaposto {{nome}} (anche {{cliente.nome}}, {{righe.0.codice}}), riempiti dai dati: $orch.pdf.render({ template: "order", data }). Un testo che è solo un segnaposto prende il valore così com'è ("rows": "{{righe}}" riceve l'elenco), dentro un testo più lungo diventa testo. "if": "note" su un blocco lo tiene solo se il dato c'è (non vuoto, non zero, non false). I valori dei dati non si rileggono: un {{…}} scritto in un dato resta com'è, e gli asterischi di un dato nel testo di un blocco text restano asterischi (grassetto e corsivo sono del modello). Il modello del motore "@pyrite" è il punto di partenza (logo in testata, numeri di pagina nel piede, titolo, campi, tabella con i totali, note, QR): dati title, subtitle, company, reference, fields, intro, columns, rows, totals, totalsLabel, notesTitle, notes, qr, qrCaption, footer, lang, currency. Per farne uno tuo, copia il suo JSON (qui sotto) in release/templates/ e cambialo.
{
"lang": "{{lang}}",
"title": "{{title}}",
"author": "{{company}}",
"currency": "{{currency}}",
"page": { "size": "A4" },
"header": { "logo": "pyrite", "center": "{{reference}}", "right": "{{company}}" },
"footer": { "left": "{{footer}}", "right": "{page} / {pages}" },
"blocks": [
{ "type": "title", "text": "{{title}}", "subtitle": "{{subtitle}}" },
{ "if": "fields", "type": "fields", "columns": 3, "items": "{{fields}}" },
{ "if": "intro", "type": "text", "text": "{{intro}}" },
{ "if": "rows", "type": "table", "columns": "{{columns}}", "rows": "{{rows}}", "totals": "{{totals}}", "totalsLabel": "{{totalsLabel}}" },
{ "if": "notes", "type": "heading", "text": "{{notesTitle}}", "level": 2 },
{ "if": "notes", "type": "text", "text": "{{notes}}" },
{ "if": "qr", "type": "qr", "text": "{{qr}}", "size": 28, "align": "right", "caption": "{{qrCaption}}" }
]
}
HTML libero. $orch.pdf.html(modello, dati, opzioni): il modello è il testo HTML (html/template di Go: {{.campo}}, {{range .righe}}…{{end}}, {{if}}) oppure il nome di un file release/templates/<nome>.html. I dati passano dall'escaping automatico nel contesto giusto: un dato con <script> o virgolette resta testo. Funzioni in più: {{image "release/templates/logo.png"}} (per src), {{recordImage "vehicles" .id "photo"}}, {{qr .link}}, {{barcode "ean13" .ean}}, {{pyriteLogo}}, {{money .totale}}, {{number .qta 0}}, {{percent .sconto}}, {{date .data}}, {{datetime .quando}} (con il fuso come secondo argomento: {{datetime .quando "Europe/Rome"}}). Il file prodotto è unico: il motore mette in testa la codifica, una Content-Security-Policy (niente script, niente richieste fuori dal file) e i caratteri IBM Plex (font-family: "IBM Plex Sans" e "IBM Plex Mono"), e toglie script, iframe, oggetti, animazioni SVG, attributi on…, indirizzi javascript: e le risorse esterne (immagini e CSS presi da fuori, anche scritti con le sequenze \ del CSS). La difesa vera è la Content-Security-Policy: la pulizia è un secondo recinto per i programmi che non la applicano, e il modello è codice dell'app (i dati non possono aggiungere marcatori). Niente PDF dall'HTML libero in questa versione: il PDF si fa dal modello a blocchi. Il modello si ferma al tempo massimo (1 minuto) anche con cicli che non scrivono niente: il lavoro finisce con «tempo scaduto», ma il ciclo va avanti nel processo dell'app finché non finisce da sé (anche per ore), e intanto l'app rifiuta gli altri modelli HTML (pdf.err.html_busy). Nel registro della console c'è l'avviso con il nome del modello; il ciclo si ferma subito riavviando l'app dalla console. Al più 2 modelli HTML girano insieme in un'app: gli altri aspettano il loro turno, entro il tempo massimo. Scrivi cicli che finiscono (niente {{range 1000000000}}, niente cicli annidati su elenchi lunghi che non scrivono niente).
Dove va il file (opzioni di render, html, pageImage, merge): path (un file dentro state/), record con field (un campo file del record, salvato con l'app dello script: rispetta maxSize del campo), owner (un utente dell'app, per esempio e.auth: il file va nel deposito degli strumenti e la pagina lo scarica con PyriteTools.download(r.file.id), se toolAccess.pdf.pages è vero). Senza nessuna delle tre il file resta in memoria per 5 minuti e $orch.pdf.send(e, r) lo manda come risposta della rotta, da scaricare ({ inline: true } apre nel browser PDF e PNG; l'HTML si scarica sempre). Il risultato ha format, type, name, size, pages e, secondo la destinazione, id, path, record e field, o file.
Leggere i PDF. info, text, fields, pageImage ricevono un percorso nella cartella dell'app ("state/…", "release/…") oppure il file di un record: { record: e.record, field: "allegato" } o { collection, id, field, name }. text dà il testo di ogni pagina e i pezzi con la posizione (x, y, width, height in punti dall'angolo in alto a sinistra, size del carattere); { pages: [1, 2] } sceglie le pagine, { positions: false } toglie le posizioni. info di un PDF cifrato con la password di apertura dice passwordRequired: true; le altre letture vogliono { password }. Lo fa PDFium (il lettore di PDF di Chromium) compilato in WebAssembly, chiuso in un recinto: non vede file, rete né altro, ha un tetto di memoria e di tempo. La prima lettura dopo l'installazione del motore compila PDFium (circa 2 secondi), poi lo prende dalla cache. merge unisce da 2 a 20 PDF nell'ordine dato, con PDFium nello stesso recinto: copia le pagine (con le loro annotazioni), non i segnalibri né i campi dei moduli; i PDF cifrati si rifiutano.
Limiti. PDF da leggere al più 100 MB e 500 pagine per lettura (maxPages fino a 2000); PDF da unire al più 200 MB in tutto; immagine di una pagina al più 300 dpi e 20.000.000 di pixel (meno nelle app con un memoryLimitMB piccolo: a PDFium ne va un quarto, tra 64 e 256 MB); testo letto al più 10 milioni di caratteri (dalle pagine, il risultato al più 4 MB); documento prodotto al più 2000 pagine, 5000 blocchi, 50.000 righe di tabella, 50 immagini (ognuna al più 10 MB e 16.000.000 di pixel); tempo al più 1 minuto per fare un documento e 2 minuti per leggerne uno. Un PDF rotto, cifrato, troppo grande o costruito per fare danni dà un errore che lo dice e l'app resta in piedi (anche nell'unione: tutto succede nel recinto, con il suo tetto di memoria).
Dalle pagine dell'app (regola 8 delle regole comuni), senza scrivere rotte: lo strumento in "tools" e in app.json un permesso come questo.
"toolAccess": {
"pdf": { "pages": true, "minRole": "editor", "templates": ["order", "label.html", "@pyrite"], "collections": ["vehicles"], "maxPages": 200 }
}
POST /api/tools/pdf/render{ template, data, format, name }→202 { job }: il modello deve essere intemplates(senza: nessuno; un nome da solo, come"order", è il modello a blocchiorder.json); i dati sono quelli che manda la pagina, che la persona ha già. Il file prodotto va nel deposito:job.result.file.id→PyriteTools.download(id).POST /api/tools/pdf/html{ template, data, name }: un modellorelease/templates/<nome>.htmlelencato intemplatescon l'estensione ("label.html"):"label"da solo non lo apre, così un modello HTML scritto per gli script non si apre alle pagine per sbaglio.POST /api/tools/filescontool=pdfe il PDF (al più 100 MB) →{ id }; poiPOST /api/tools/pdf/info,textofields{ file, pages, positions, password },POST /api/tools/pdf/page-image{ file, page, dpi, name },POST /api/tools/pdf/merge{ files: [id, id, …], name }(al più 200 MB in tutto, controllati prima di leggere). Si leggono solo i file caricati dalla stessa persona (per gli altri: 404). Il testo letto per una pagina è al più 4 MB (in JSON): per i PDF lunghi scegli lepages, o togli le posizioni.- Dalle pagine le immagini dei modelli vengono solo da
release/pb_public/(già pubbliche) e darelease/templates/; i file dei record solo dalle raccolte incollections, con la regola di elenco dell'app per chi chiede (si legge solo quello che la persona vede già), e mai i file protetti. Pagine al piùmaxPages(predefinito 500). - Ogni operazione dalle pagine che produce un file (documento, immagine di una pagina, unione) lascia un evento nel registro del motore, e così lo scaricamento.
Regole per questo strumento:
- I documenti da stampare si descrivono a blocchi, o con un modello in
release/templates/: niente librerie PDF nell'app, niente HTML convertito a mano. - I dati entrano nei modelli solo come dati: non costruire testo di modello concatenando valori scritti dalle persone.
- Per un documento da scaricare da una rotta:
rendersenza destinazione e poireturn $orch.pdf.send(e, r); per tenerlo:record+field. - Da leggere sono solo i PDF con il testo: una scansione dà pagine senza testo (niente OCR).
- Le letture e le unioni sono lente rispetto a una normale rotta: dagli script falle in una rotta chiamata dalla pagina o in un lavoro pianificato, non negli hook di salvataggio dei record.
Esempio: il PDF di un ordine da una rotta dell'app, con il modello release/templates/order.json.
routerAdd("GET", "/api/orders/{id}/pdf", (e) => {
const order = e.app.findRecordById("orders", e.request.pathValue("id"));
const rows = e.app.findRecordsByFilter("order_rows", "order = {:id}", "code", 0, 0, { id: order.id }).map((x) => ({
code: x.getString("code"), description: x.getString("description"), qty: x.getInt("qty"), price: x.getFloat("price"),
amount: x.getInt("qty") * x.getFloat("price") }));
const r = $orch.pdf.render({ template: "order", data: { number: order.getString("number"), supplier: order.getString("supplier"), rows: rows,
total: { amount: rows.reduce((s, x) => s + x.amount, 0) } } }, { name: "order-" + order.getString("number") + ".pdf", lang: "it" });
return $orch.pdf.send(e, r);
}, $apis.requireAuth("users"));
Esempio: il modello standard del motore, con il PDF salvato nel record.
onRecordAfterCreateSuccess((e) => {
$orch.pdf.render({ template: "@pyrite", data: {
title: "Delivery report", company: "Rossi Garage", reference: e.record.getString("number"),
fields: [{ label: "Vehicle", value: e.record.getString("plate") }, { label: "Date", value: e.record.getString("created"), format: "date" }],
notes: e.record.getString("notes"),
} }, { record: e.record, field: "report", lang: "it", app: e.app });
e.next();
}, "deliveries");
Esempio: il testo di un PDF caricato in un record e la pagina 1 come anteprima.
routerAdd("POST", "/api/invoices/{id}/read", (e) => {
const rec = e.app.findRecordById("invoices", e.request.pathValue("id"));
const t = $orch.pdf.text({ record: rec, field: "pdf" }, { pages: [1] });
const total = t.pages[0].pieces.find((p) => p.text.startsWith("Total"));
$orch.pdf.pageImage({ record: rec, field: "pdf" }, 1, { dpi: 72, record: rec, field: "preview", app: e.app });
return e.json(200, { text: t.pages[0].text, total: total ? total.text : null });
}, $apis.requireAuth("users"));
Esempio: da una pagina dell'app, con /__platform/tools.js.
const job = await PyriteTools.run("pdf", "render", { template: "order", data: { number: "2026/42", rows: rows }, format: "pdf" });
if (job.status === "finished") {
await PyriteTools.download(job.result.file.id);
}
Statistiche e stime (stats)
I conti «oltre Excel» sulle raccolte dell'app, in una sola query SQL: gruppi per campi o per periodi di una data (giorno, settimana ISO, mese, trimestre, anno, anche in un fuso orario), somme, medie, minimi, massimi, conteggi dei valori diversi, mediane e percentili, con il filtro di PocketBase. Istantanee salvate che i lavori pianificati rifanno, e funzioni per le serie piccole: mediana, percentili, media mobile, tendenza lineare, variazione rispetto al periodo prima.
Si attiva con "tools": ["stats"] in app.json. Categoria: Analisi.
Funzioni
$orch.stats.aggregate(query)
Calcola le statistiche di una raccolta in una sola query SQL. Operazioni: count, countDistinct, sum, avg, min, max, median, percentile; periodi di una data (by): day, week, month, quarter, year. Campi e operazioni si controllano contro lo schema della raccolta; il filtro è quello di PocketBase, con i segnaposto {:nome} riempiti da params. Le raccolte di sistema non si contano.
Restituisce: { collection, groupBy: [{ name, field, by }], measures: [{ name, op, field, p }], rows: [{ <group>: value, <measure>: value }], truncated, timezone, duration }.
Un oggetto per gruppo in rows, con i nomi dei gruppi e dei conti (nomi predefiniti: count, sum_total, p90_total…; con as li scegli tu). truncated = c'erano più righe di limit (predefinito 1000, al più 10000).
const r = $orch.stats.aggregate({
collection: "orders",
filter: "status = {:status} && created >= {:from}",
params: { status: "paid", from: "2026-01-01" },
groupBy: ["customer", { field: "created", by: "month" }],
measures: [{ op: "count" }, { op: "sum", field: "total" }, { op: "median", field: "total" }, { op: "percentile", field: "total", p: 90 }],
sort: "-sum_total",
timezone: "Europe/Rome",
});
// r.rows[0] → { customer: "…", created_month: "2026-03", count: 12, sum_total: 3400.5, median_total: 250, p90_total: 610 }
Dalle pagine dell'app: POST /api/tools/stats/aggregate (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.stats.snapshot(name, query?, options?)
Calcola le statistiche e le salva con un nome, nella raccolta di sistema nascosta orch_stats_snapshots. Senza query rifà quella salvata: chiamala da un lavoro cronAdd per tenerla aggiornata.
Restituisce: { name, query, result, takenAt, duration }.
cronAdd("stats-sales-by-month", "15 2 * * *", () => {
$orch.stats.snapshot("sales-by-month", {
collection: "orders",
groupBy: [{ field: "created", by: "month" }],
measures: [{ op: "sum", field: "total" }],
});
});
$orch.stats.read(name, options?)
L'istantanea salvata con questo nome, oppure null.
Restituisce: { name, query, result, takenAt, duration } | null.
routerAdd("GET", "/api/myapp/sales", (e) => {
const s = $orch.stats.read("sales-by-month");
return e.json(200, s ? s.result.rows : []);
}, $apis.requireAuth());
Dalle pagine dell'app: POST /api/tools/stats/read (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.stats.median(values)
La mediana di una lista di numeri (null se la lista è vuota).
Restituisce: number | null.
const m = $orch.stats.median([3, 1, 4, 1, 5]); // 3
$orch.stats.percentile(values, p)
Il percentile p (0–100) di una lista di numeri, con l'interpolazione lineare come PERCENTILE.INC di Excel.
Restituisce: number | null.
const p90 = $orch.stats.percentile(prices, 90);
$orch.stats.rolling(values, window)
La media mobile: la media degli ultimi window valori, per ogni posizione (null finché la finestra non è piena).
Restituisce: [number | null].
const avg3 = $orch.stats.rolling([10, 20, 30, 40], 3); // [null, null, 20, 30]
$orch.stats.trend(values)
La tendenza lineare di una serie (minimi quadrati, x = posizione): slope, intercept, r2 e next, il valore atteso per la posizione dopo.
Restituisce: { slope, intercept, r2, next, n } | null.
const t = $orch.stats.trend(r.rows.map((x) => x.sum_total)); // t.next = the next month, if the trend holds
$orch.stats.change(values)
Ogni valore confrontato con quello prima: differenza e percentuale (null dove non si può dire).
Restituisce: [{ value, previous, delta, percent }].
const c = $orch.stats.change([100, 120, 90]); // c[1].percent = 20, c[2].percent = -25
Come si usa
Quando usarlo. Ogni volta che una pagina mostra numeri calcolati su tanti record: totali per mese, prezzo medio per modello, tempo di consegna mediano, i primi 10 clienti, quanti clienti diversi hanno comprato quest'anno. Non caricare i record nello script per sommarli in JS e non scrivere SQL a mano: aggregate lo fa in una sola query SQL sul database dell'app, con campi e operazioni controllati contro lo schema della raccolta e il filtro di PocketBase. Su un PC normale 100.000 record si contano in qualche decimo di secondo.
Raccolte «vista» di PocketBase o stats? Una raccolta vista è una SELECT … GROUP BY fissa scritta in una migrazione: il frontend la legge con le API normali di PocketBase, con le sue regole, e filtra e ordina le righe del risultato. Basta, ed è la scelta più semplice, quando:
- il raggruppamento è sempre lo stesso e si sa prima (totali per cliente, conteggi per stato);
- bastano
COUNT,SUM,AVG,MIN,MAX; - i filtri che servono alla pagina sono sulle colonne del risultato (il filtro delle API su una vista lavora dopo il raggruppamento: «totali per mese del cliente X» vuole il cliente tra i gruppi della vista).
Usa stats quando:
- è la persona a scegliere cosa vedere: il periodo (giorno, settimana, mese…), il raggruppamento, i conti;
- il filtro deve scegliere i record prima dei conti («ordini pagati tra due date, per mese»), con i parametri;
- servono mediane, percentili, conteggi dei valori diversi, periodi in un fuso orario (
timezone: "Europe/Rome": un ordine delle 00:30 in Italia è di quel giorno, non del giorno prima come in UTC) o settimane ISO; - il conto è pesante e lo guardano in tanti: salva un'istantanea da un lavoro
cronAdde lascia che le pagine la leggano (read), senza rifare il conto; - serve l'andamento di una serie: media mobile, tendenza lineare, variazione rispetto al periodo prima.
La query (argomento di aggregate, secondo argomento di snapshot):
| voce | valori | significato |
|---|---|---|
collection | nome della raccolta | obbligatoria; le raccolte di sistema (_superusers, orch_…) non si contano |
filter | filtro di PocketBase | per esempio "status = {:status} && created >= {:from}"; le relazioni funzionano come nelle API (customer.city = "Bologna") |
params | oggetto | i valori dei segnaposto {:nome} del filtro, come in $app.findRecordsByFilter |
groupBy | lista | nomi di campi ("customer") oppure { field, by, as }; by è il periodo di un campo data: day (2026-03-15), week (2026-W11, ISO), month (2026-03), quarter (2026-Q1), year (2026). Un campo data si raggruppa sempre con by. Al più 5 voci |
measures | lista | { op, field, p, as } (vedi sotto); vuota = [{ op: "count" }]. Al più 20 |
sort | "-sum_total,customer" o una lista | nomi delle colonne del risultato; - = decrescente. Vuoto = per i gruppi |
limit | da 1 a 10000 | predefinito 1000; truncated: true nel risultato quando c'erano più righe |
timezone | per esempio "Europe/Rome" | fuso orario dei periodi; predefinito "UTC" |
Operazioni (op): count (i record; con field: i record in cui il campo è compilato), countDistinct (i valori diversi di field), sum, avg (campi numero), min, max (campi numero o data), median, percentile (campi numero; p da 0 a 100, con l'interpolazione lineare come PERCENTILE.INC di Excel). I testi vuoti e le date vuote non contano; i campi numero contano il loro 0 (PocketBase scrive 0 quando un numero manca).
Campi che si possono usare: numero, sì/no, testo, email, url, scelta singola, relazione singola (il gruppo è l'id del record collegato: i nomi si leggono con $app.findRecordsByIds), data e data automatica (nei gruppi con by, e in min/max). No: JSON, file, editor, password, scelte e relazioni con più valori. Un campo che non c'è, o di un tipo che non va per l'operazione, dà un errore che lo nomina.
Il risultato. Un oggetto per gruppo in rows, con i nomi dei gruppi e dei conti: di base il gruppo si chiama come il campo (customer, oppure created_month per un periodo) e il conto count, sum_total, avg_total, countDistinct_customer, median_total, p90_total; con as il nome lo scegli tu (lettere, cifre e _, non una cifra all'inizio, al più 50 caratteri). I numeri sono numeri, i sì/no sono true/false, i periodi sono testi che si ordinano bene. groupBy e measures nel risultato descrivono le colonne, duration è in millisecondi.
Istantanee. $orch.stats.snapshot(name, query) fa il conto e salva il risultato con il nome name (lettere, cifre, _ . -); $orch.stats.snapshot(name) lo rifà con la query salvata; $orch.stats.read(name) dà { name, query, result, takenAt, duration } oppure null. Stanno nella raccolta di sistema nascosta orch_stats_snapshots: l'app non deve scriverci. Rifalle da un lavoro cronAdd (vedi «Lavori pianificati»).
cronAdd("stats-sales-by-month", "15 2 * * *", () => {
$orch.stats.snapshot("sales-by-month", {
collection: "orders",
filter: "status = 'paid'",
groupBy: [{ field: "created", by: "month" }],
measures: [{ op: "sum", field: "total" }, { op: "count" }],
timezone: "Europe/Rome",
});
});
routerAdd("GET", "/api/myapp/sales", (e) => {
const s = $orch.stats.read("sales-by-month");
if (!s) return e.json(200, { months: [] });
const totals = s.result.rows.map((r) => r.sum_total);
return e.json(200, {
months: s.result.rows,
trend: $orch.stats.trend(totals),
change: $orch.stats.change(totals),
average3: $orch.stats.rolling(totals, 3),
takenAt: s.takenAt,
});
}, $apis.requireAuth());
Chi vede i numeri. Come ogni strumento, stats conta come il server: le regole delle API della raccolta non valgono (regola 6). Una rotta che mostra statistiche alle persone controlla prima chi sono, e conta solo quello che possono vedere (per esempio con un filtro sull'utente). Dalle pagine dell'app invece il motore conta come la persona (sotto).
Serie piccole (median, percentile, rolling, trend, change): liste di numeri già in memoria, al più 100.000 valori, per esempio le righe di un aggregate. Per i dati grandi usa aggregate, che conta in SQL.
Dalle pagine dell'app (regola 8 delle regole comuni), senza scrivere rotte: lo strumento in "tools" e un permesso in app.json.
"toolAccess": {
"stats": { "pages": true, "minRole": "editor", "collections": ["orders"], "maxRows": 500, "snapshots": ["sales-by-month"] }
}
POST /api/tools/stats/aggregatecon la query nel corpo (la stessa diaggregate): il conto si fa come la persona, sui record che vedrebbe conGET /api/collections/{c}/records. Vale la regola di elenco della raccolta come nell'elenco dei record di PocketBase (una raccolta senza regola di elenco è solo dei superuser: 403); i campi nascosti e l'email delle raccolte auth non si usano nei gruppi, nei conti e nel filtro (un campo che non c'è o che la persona non può usare dà lo stesso messaggio);@request.e@collection.nel filtro si rifiutano (403). Solo le raccolte dicollections; al piùmaxRowsrighe (predefinito 1000, mai oltre 10.000: unlimitpiù alto si abbassa etruncatedlo dice); il filtro al più 3500 caratteri, con i valori diparamsal loro posto (come nelle API dei record); al più 15 secondi per conto, due conti delle pagine insieme per app e uno alla volta per persona (per gli ospiti: per indirizzo IP): chi trova i posti occupati aspetta al più 5 secondi, poi riceve 503 («riprova tra un momento»). Il filtro confronta le date in UTC, come le API di PocketBase:timezonevale solo per i periodi dei gruppi (nell'esempio sotto i limiti dell'anno sono quelli di Roma). Vale il limite di richieste della raccolta (<raccolta>:list, se il limite di richieste dell'app è acceso: di serie è spento), e gli hookonRecordsListRequestdell'app si chiamano con un elenco vuoto: un hook che respinge la persona respinge anche le statistiche. Un hook che toglie record dall'elenco invece non vale per i conti (si fanno in SQL, non sui record): se l'app nasconde record con un hook, metti la condizione nella regola di elenco, oppure non aprirestatssu quella raccolta.POST /api/tools/stats/read{ name }: un'istantanea salvata dagli script. Un'istantanea è calcolata come il server, senza le regole della persona, quindi le pagine leggono solo quelle elencate insnapshots, e senza la query salvata:{ name, result, takenAt, duration }(404 se non è ancora stata calcolata).snapshot(che conta come il server) e le funzioni sulle serie piccole non si offrono alle pagine: le mediane e gli andamenti delle sue righe una pagina li calcola da sé.
Esempio: una pagina dell'app con i totali per mese, e l'istantanea salvata ogni notte.
<script src="/__platform/tools.js"></script>
<script>
async function salesByMonth(year) {
const r = await PyriteTools.call("stats", "aggregate", {
collection: "orders",
// the filter compares UTC dates: the year in Rome starts at 23:00 UTC of 31 December (winter time, UTC+1)
filter: "status = 'paid' && created >= {:from} && created < {:to}",
params: { from: (year - 1) + "-12-31 23:00:00.000Z", to: year + "-12-31 23:00:00.000Z" },
groupBy: [{ field: "created", by: "month" }],
measures: [{ op: "count" }, { op: "sum", field: "total" }],
timezone: "Europe/Rome",
});
return r.rows; // [{ created_month: "2026-03", count: 12, sum_total: 3400.5 }, ...]: only the orders this person sees
}
async function savedSales() {
const s = await PyriteTools.call("stats", "read", { name: "sales-by-month" });
return s.result.rows;
}
</script>
Storico delle modifiche (history)
Tiene traccia di chi ha cambiato cosa e quando nei record dell'app, con il valore di prima e quello di dopo, e riporta una versione di prima (anche di un record eliminato).
Si attiva con "tools": ["history"] in app.json. Categoria: Dati.
Funzioni
$orch.history.list(collection, id?, options?)
Le voci dello storico di un record (id), o di tutta una raccolta con id vuoto, dalla più recente. Opzioni: limit (da 1 a 500, 50 se manca), offset (voci da saltare).
Restituisce: { collection, record, total, items: [{ id, collection, record, action, changes: { field: { before, after } }, by, byName, person, source, restoredFrom, created }] }.
changes ha solo i campi cambiati: before e after per una modifica, solo after per una creazione, solo before per un'eliminazione. source è api, script, import o console; person è la persona della rubrica del motore, quando si sa.
routerAdd("GET", "/api/requests/{id}/history", (e) => {
const rec = e.app.findRecordById("requests", e.request.pathValue("id"));
if (!e.auth || rec.getString("owner") !== e.auth.id) throw new ForbiddenError("Not yours");
return e.json(200, $orch.history.list("requests", rec.id, { limit: 20 }));
}, $apis.requireAuth());
Dalle pagine dell'app: POST /api/tools/history/list (effetto: solo lettura), con i permessi di toolAccess in app.json.
$orch.history.restore(entryId, options?)
Riporta il record ai valori di quella versione (subito dopo quella modifica; per un'eliminazione: il record com'era, con lo stesso id). Lascia una voce nuova «restore» e un evento. I campi file non si ripristinano (i file vecchi non ci sono più). Opzioni: by (chi ripristina, per lo storico), app.
Restituisce: { id, entry, collection, record, action: updated | recreated | unchanged, restored, skipped }.
id è la voce nuova dello storico (vuoto se non è cambiato niente); skipped elenca i campi rimasti come sono.
const r = $orch.history.restore(entryId, { by: e.auth.getString("name"), app: e.app });
Dalle pagine dell'app: POST /api/tools/history/restore (effetto: scrive dati), con i permessi di toolAccess in app.json.
API HTTP del motore
Non le chiama l'app: servono alla console e al portale. Sono elencate perché l'app non le rifaccia.
| metodo | percorso | chi | stato | cosa fa |
|---|---|---|---|---|
| POST | /api/platform/apps/{slug}/history/search | Proprietari e Admin della console (gli Admin: solo per le app che gestiscono) | pronta | Scheda «Storico» di un'app nella console: lo storico di un record o di una raccolta, come pezzo di pagina. |
Come si usa
Quando usarlo. Quando l'app deve poter dire chi ha cambiato una scheda, quando e cosa c'era prima: richieste con un iter di approvazione, anagrafiche condivise, prezzi, scadenze. Non scrivere uno storico a mano negli hook: il motore registra ogni creazione, modifica ed eliminazione nella stessa transazione del salvataggio, da qualunque strada arrivi (pagine dell'app, script, import, console).
Configurazione in app.json, con lo strumento attivo:
"tools": ["history"],
"history": { "collections": ["requests", "suppliers"], "retentionDays": 365 }
| voce | significato |
|---|---|
collections | le raccolte da registrare; ["*"] = tutte le raccolte dell'app (non quelle di sistema) |
retentionDays | dopo quanti giorni le voci si cancellano (predefinito 365; 0 = mai). La pulizia gira ogni notte e all'avvio dell'app |
exclude | campi da non registrare mai, in più di quelli esclusi sempre |
includeHidden | campi nascosti alle API (Hidden) da registrare lo stesso; senza, i campi nascosti non si registrano |
Cosa si registra. Per ogni salvataggio una voce con: l'azione (create, update, delete, restore), chi (by: l'id dell'utente dell'app; byName: il suo nome o la sua email; person: l'id della persona della rubrica del motore, quando si sa, vedi «La persona» sotto), quando (created), da dove (source: api per le pagine dell'app, script, import, console) e i campi cambiati in changes, con il valore di prima (before) e di dopo (after). Per una modifica fatta dalla scheda Dati della console source è console, byName il nome (o l'email) della persona della console che l'ha fatta e person il suo id nella rubrica del motore: il motore lo dice all'app, firmato, e una pagina non lo può inventare. Una modifica che non cambia niente di registrato non lascia voci. Non si registrano mai: password, tokenKey, i campi con il nome di un segreto (con «password», «secret», «token», «credential» nel nome, oppure con la parola «key», «pin», «otp», «hash», «salt»: per esempio api_key, apiKey, pin_code), quelli di exclude e le date automatiche (created, updated: il quando è quello della voce); dei campi file solo i nomi dei file. I campi nascosti alle API (Hidden) solo se elencati in includeHidden. La voce si scrive nella transazione della sola scrittura, dopo gli hook dell'app e dopo il caricamento dei file.
La persona (person). È la persona della rubrica del motore dietro l'utente dell'app. C'è per le modifiche fatte dalle pagine (e dagli strumenti delle pagine: l'import di Excel e CSV e quello di foto, con i loro annullamenti, dove source è import e by è chi li ha fatti partire) da un utente che il motore ha legato a una persona: con l'accesso unico o con la scheda «Accessi e ruoli». Il motore dice all'app quale persona c'è dietro ogni utente: quando la persona entra, quando riceve un ruolo e a ogni accensione dell'app. L'app lo ricorda anche dopo un riavvio, in una tabella interna che le API e le pagine non vedono. Una pagina non lo può inventare né cambiare: non viene da un'intestazione né da un campo della richiesta, ma dall'utente che il token dice. È vuoto per un utente creato dall'app stessa, per gli ospiti e per i salvataggi degli script e degli import lanciati dalla console o da uno script, che non hanno un utente (by è vuoto anche lui). Una persona eliminata dalla rubrica non compare più nelle voci nuove (le app accese lo sanno subito, quelle spente alla prossima accensione); le voci già scritte restano con il suo id, perché lo storico non si riscrive. Una persona disattivata resta legata: non può entrare, e se torna attiva è ancora lei.
Dove sta lo storico. Nella raccolta di sistema orch_history dell'app, creata dal motore: viaggia nei backup, non si vede dalle API (nemmeno dai superuser) né nella scheda Dati. Non toccarla e non crearla nelle migrazioni. Nella console del motore la pagina dell'app ha la scheda «Storico»: le ultime modifiche e lo storico di una scheda.
Ripristino. $orch.history.restore(entryId) riporta il record ai valori di quella versione: quelli subito dopo quella modifica (per una voce delete: il record com'era prima di essere eliminato, ricreato con lo stesso id). Il salvataggio passa dalle validazioni e dagli hook dei record e lascia a sua volta una voce restore (con restoredFrom), più un evento del motore. I campi file non si ripristinano, perché i file di prima non ci sono più: restano come sono e si elencano in skipped, come i campi che la raccolta non ha più.
Regole per questo strumento:
- Gli script leggono lo storico come il server: la rotta che lo mostra a una persona controlla da sé che possa vedere quel record (le regole API delle raccolte non valgono qui) e non le mostra i campi che non vede. Dalle pagine dell'app il motore lo fa da sé (sotto, «Dalle pagine dell'app»).
- Il ripristino di uno script è un salvataggio vero, fatto come il server (senza le regole API né gli hook delle richieste): fallo solo da una rotta che controlla chi lo chiede e che cosa può cambiare, e passa
bycon il nome della persona, che resta nella voce nuova.
Esempio: lo storico di una richiesta, solo per chi l'ha creata.
routerAdd("GET", "/api/requests/{id}/history", (e) => {
const rec = e.app.findRecordById("requests", e.request.pathValue("id"));
if (!e.auth || rec.getString("owner") !== e.auth.id) throw new ForbiddenError("Not yours");
return e.json(200, $orch.history.list("requests", rec.id, { limit: 20 }));
}, $apis.requireAuth());
Esempio: riportare una richiesta a una versione di prima, solo per il responsabile (ruolo manager).
routerAdd("POST", "/api/history/{entry}/restore", (e) => {
if (e.auth.getString("role") !== "manager") {
throw new ForbiddenError("Only the manager can restore a version.");
}
const result = $orch.history.restore(e.request.pathValue("entry"), { by: e.auth.getString("name"), app: e.app });
return e.json(200, result);
}, $apis.requireAuth("users"));
Dalle pagine dell'app (regola 8 delle regole comuni), senza scrivere rotte: lo strumento in "tools" e un permesso in app.json.
"toolAccess": {
"history": { "pages": true, "minRole": "editor", "collections": ["requests"], "maxRows": 100 }
}
POST /api/tools/history/list{ collection, record, limit, offset }→{ collection, record, total, items, $version }: lo storico di un record come lo vede la persona. Vale laviewRuledel record come per lei (404 se non lo vede, e per un record eliminato); delle voci restano solo i campi che vedrebbe leggendo il record dalle API adesso (niente campi nascosti, niente campi che l'app nasconde conhide()inonRecordEnrich, niente email che non può vedere), ebyNamenon mostra mai l'email di altre persone (e agli ospiti, conguests, nemmenoperson, l'id della persona nella rubrica del motore).$versionè la versione del record adesso, da rimandare conrestore. Al piùmaxRowsvoci per chiamata (predefinito 50, mai oltre 500); solo le raccolte dicollections.POST /api/tools/history/restore{ entry, $version }(oppure l'intestazioneIf-Match) → il risultato direstore, con il$versiondel record dopo il ripristino. È una modifica vera della persona: laupdateRulecon i valori ripristinati in@request.body(un valore che la regola non le permette: 404, come le API), la manageRule delle raccolte auth, gli hook delle richieste dell'app, il numero che le pagine non cambiano (resta, inskipped) e la protezione dalle modifiche concorrenti: nel modostrict(il predefinito) senza versione 428, con una versione vecchia 409, gli stessi errori di unaPATCH; inwarnun ripristino senza versione passa e si conta tra le modifiche senza versione, come unaPATCH. Si riportano solo i campi che la persona vede (come inlist): quelli che non vede restano come sono e non compaiono inrestoredné inskipped. Effettowrite: vuole un utente dell'app e, se c'è,minRole(mai gli ospiti). Un record eliminato dalle pagine non si ricrea (404: lo fanno la console e gli script). L'evento nel registro del motore è quello dello storico (con chi ha ripristinato).
Esempio: lo storico di una richiesta nella sua pagina, e una versione riportata.
<script src="/__platform/tools.js"></script>
<script>
async function showHistory(id) {
const h = await PyriteTools.call("history", "list", { collection: "requests", record: id, limit: 20 });
return h; // h.items: who changed what and when; h["$version"]: the version to send back with restore
}
async function restore(entry, version) {
try {
return await PyriteTools.call("history", "restore", { entry: entry, $version: version });
} catch (err) {
if (err.status === 409) {
alert(err.message); // someone changed the request in the meantime: show the history again
}
throw err;
}
}
</script>
In arrivo
Decisi per le prossime versioni del motore, non ancora disponibili: non usarli e non costruire niente che ne dipenda. Se l'app ne ha bisogno, scrivilo nelle note di consegna.
- Email e notifiche (
notifications): invio di email da modelli e avvisi dentro le app, con il server di posta configurato dall'IT. - Prenotazioni (
bookings): calendario di risorse condivise (sale, auto di cortesia, postazioni) con il controllo delle sovrapposizioni.
Rete in uscita: dichiarare gli indirizzi ("network")
Gli script di un'app (pb_hooks e pb_migrations) raggiungono altri sistemi solo passando dal motore. Ogni chiamata di $http.send, $filesystem.fileFromURL e $filesystem.s3, e quelle che PocketBase fa per l'app (archivio S3 e backup, OAuth2), passano da un controllo del motore:
- Solo indirizzi dichiarati. L'app li elenca in
app.json:
json "network": { "allow": ["api.openai.com", "*.example.com", "erp.example.local:8443"], "allowHttp": ["old-erp.example.local"] }
Una voce è un nome o un indirizzo IP, con la porta se serve; senza porta vale la porta normale del protocollo (443 per https, 80 per http). *.example.com vale per i nomi sotto example.com, non per example.com stesso. Niente * da solo, niente https:// davanti, niente percorsi. Un'app senza "network" non può chiamare nessun indirizzo. Dichiara solo gli indirizzi che l'app chiama davvero, e scrivi in DELIVERY.md a cosa serve ognuno: l'IT li approva quando installa l'app («Carica app» li elenca), e da lì li può cambiare nella scheda «Rete in uscita» dell'app.
- Solo https. L'http semplice solo per le voci di
allowHttp, per sistemi interni che non hanno https: i dati viaggiano in chiaro. - Rete interna. Il motore controlla l'indirizzo IP vero dopo la risoluzione DNS e si collega a quell'indirizzo. Un server della rete dell'azienda (10.x, 172.16-31.x, 192.168.x) si raggiunge solo con una voce esatta, senza
*; la macchina stessa (127.0.0.1,localhost) e gli indirizzi link-local (169.254.x, i metadati del cloud) solo con una voce scritta come indirizzo IP, ma il motore stesso e le altre app di questo server non si raggiungono mai da uno script, nemmeno con una voce (403, codiceapp_origin);0.0.0.0e gli indirizzi multicast mai. Con*si raggiungono solo indirizzi pubblici: i server dell'azienda si scrivono per intero. - Posta. Anche il server di posta dell'app (SMTP, usato da
$app.newMailClient(),$mails.*e dai codici monouso) è un indirizzo: dichiaralo con la porta, per esempio"smtp.example.com:587". Valgono le stesse regole sull'IP, e ogni invio si registra (mai destinatari, oggetto o testo). - I redirect si seguono (al massimo 9) solo verso indirizzi dichiarati.
- Limiti per gli script: una risposta di al massimo 32 MB, e al massimo 300 secondi per chiamata (
timeoutin secondi; senza, 120). - Errori. Una chiamata non permessa dà un'eccezione in inglese che dice cosa dichiarare: gestiscila con
try { … } catch (err) { … }. Il motore registra ogni chiamata (metodo, host, stato, dimensione, tempo: mai il contenuto) e l'IT vede il registro nella scheda «Rete in uscita» dell'app. L'IT decide anche la modalità: enforce blocca ciò che non è dichiarato, warn lo registra soltanto.
Modelli AI
- Le app chiamano i modelli AI (OpenAI, Anthropic, Google Gemini, Mistral, Azure OpenAI, OpenRouter, Groq, DeepSeek…) solo dagli script, con
$http.send, mai dal browser: in una pagina la chiave del fornitore sarebbe visibile a chiunque la apra, e la chiamata non passerebbe dal motore. La pagina chiama una rotta dell'app (routerAdd) che controlla chi è l'utente e poi chiama il modello. - L'indirizzo del fornitore va in
"network"come ogni altro, per esempio"api.openai.com". - La chiave non si scrive mai nel codice, in
app.jsono nelle pagine: chiedi all'IT dove la tiene (per ora una variabile d'ambiente del server; nel motore arriverà una cassaforte per i segreti). - Il motore legge dalle risposte il modello e i token, anche in streaming quando il fornitore manda i consumi nell'ultimo evento (con OpenAI Chat Completions si chiedono con
stream_options: { include_usage: true }), e mostra consumi e costi stimati nella pagina «AI» della console. Non registra mai il contenuto dei messaggi.
routerAdd("POST", "/api/summary", (e) => {
const text = String(e.requestInfo().body.text || "").slice(0, 20000);
try {
const res = $http.send({
url: "https://api.openai.com/v1/chat/completions",
method: "POST",
headers: { "Authorization": "Bearer " + $os.getenv("OPENAI_API_KEY"), "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-5-mini", messages: [{ role: "user", content: "Summarize: " + text }] }),
timeout: 90,
});
if (res.statusCode !== 200) {
return e.json(502, { message: "The AI service did not answer (" + res.statusCode + ")." });
}
return e.json(200, { summary: res.json.choices[0].message.content });
} catch (err) {
return e.json(502, { message: String(err.message || err) });
}
}, $apis.requireAuth());
12 /Provare l'app
12.1 Prova locale, con l'eseguibile del motore
L'eseguibile del motore (pyrite.exe, te lo dà l'IT) fa girare una sola app da sola, con gli stessi limiti e gli stessi strumenti $orch del motore, ma senza porta d'ingresso, senza accesso unico e senza il blocco dell'identità: puoi creare utenti dal pannello (da superuser; un'iscrizione dalle API qui è rifiutata con 403 come nel motore) e cambiare nome, email e password, e chi può modificare gli utenti (nell'esempio il responsabile) può cambiare anche il proprio ruolo. Il divieto di cambiarsi il ruolo lo mette il motore e si prova nel motore di prova (12.2). Su una copia della cartella dell'app:
pyrite.exe worker --app-dir C:\tests\parts-pricelist superuser upsert <email> <password>
pyrite.exe worker --app parts-pricelist --app-dir C:\tests\parts-pricelist --http 127.0.0.1:8190
Il primo comando crea il database (in C:\tests\parts-pricelist\state\) e un amministratore di prova, con un'email e una password scelte da te e usate solo su questa copia; il secondo avvia l'app, che applica le migrazioni. Poi:
http://127.0.0.1:8190/_/è il pannello di PocketBase dell'app: controlli raccolte, regole e dati, e crei gli utenti di prova con email, password e ruolo;http://127.0.0.1:8190/sono le pagine. Senza il motore nessuno ti mette l'accesso nel browser: per provare da utente, apri la console del browser ed entra a mano conawait pb.collection("users").authWithPassword("email", "password"), poi ricarica;- le API si provano direttamente, per esempio con uno script, con ogni ruolo e senza login.
Gli utenti di prova e la cartella state\ restano sulla tua copia: non vanno nel pacchetto. Per ricominciare da zero cancella state\ della copia.
Anche la prova locale ha gli strumenti dalle pagine (/api/tools/ con toolAccess), la protezione dalle modifiche concorrenti e la porta d'uscita della rete in enforce (solo gli indirizzi in network), come nel motore.
Un'app statica si prova aprendo release\public\index.html con un piccolo server locale (per esempio python -m http.server dalla cartella).
12.2 Prova nel motore di prova
Se l'IT ha un motore di prova: l'IT carica lo zip con «Carica app» (se lo carica un Admin, prima lo approva un Proprietario; un Proprietario può anche copiare la cartella in apps\ del motore e premere «Rileggi cartelle»), preme «Avvia» nella pagina dell'app, dà l'accesso alle persone di prova (Persone o Gruppi) e si entra dal portale come una persona vera. È il solo modo di provare l'accesso unico, il blocco dell'identità (nessuno cambia il proprio ruolo, il nome, l'email o la password), i ruoli dati dalla console e la lingua scelta nel portale.
12.3 Cosa provare, sempre
- Ogni regola di ogni raccolta: senza login, con ogni ruolo, chiamando le API direttamente (non solo dalle pagine). Un ruolo basso non deve leggere né scrivere quello che non gli spetta. Nel motore di prova (12.2), inoltre, nessuno deve poter cambiare il proprio ruolo.
- Le rotte
routerAdd: senza login (deve rispondere 401) e con un ruolo non ammesso (403). - Le migrazioni da un database vuoto (cancella
state\della copia e riavvia). - Gli errori: file sbagliati, campi mancanti, valori fuori misura. I messaggi devono essere nella lingua della pagina e comprensibili.
- Le pagine su schermo piccolo (a 390 px di larghezza la pagina non scorre di lato), in tema scuro e in ogni lingua dell'app.
- L'output del processo (finestra del comando o scheda Stato della console) senza errori all'avvio.
- Le modifiche concorrenti: lo stesso record aperto in due finestre e salvato in tutte e due; il secondo salvataggio riceve l'avviso (
409), non sovrascrive in silenzio, e nessuna modifica delle pagine risponde428. - Gli strumenti dalle pagine con ogni ruolo e senza login: chi
toolAccessnon ammette riceve403(e non vede il pulsante). - Le chiamate di rete degli script: funzionano con gli indirizzi in
network; le altre si rifiutano con un errore chiaro.
13 /Dopo l'installazione: la scheda Dati e le modifiche fatte a mano
Nella console del motore la scheda «Dati» dell'app (per i Proprietari e per gli Admin che gestiscono l'app) apre il pannello PocketBase dell'app, ridotto a raccolte e record (struttura compresa). Impostazioni, log e backup interni non ci sono, e nelle app con login nemmeno gli utenti dell'app (sono la copia della rubrica del motore: scheda Accessi e ruoli); si gestiscono nelle schede del motore (Accessi e ruoli, Log, Backup, Configurazione, Importa), e il motore li blocca anche lato server. In un'app pubblica la raccolta degli utenti c'è: l'IT ci crea, cambia e toglie gli utenti uno alla volta, e ognuna di queste modifiche lascia un evento con chi l'ha fatta. La sua struttura resta bloccata, e dalla scheda Dati nessuno può entrare come un utente dell'app (impersonate), svuotare la raccolta o mandare le email di accesso.
- L'IT può cambiare a mano la struttura di una raccolta dalla scheda Dati (un campo, un indice, una raccolta nuova). Il motore lo permette ma scrive un avviso negli eventi: «Struttura della raccolta … cambiata a mano dal pannello: va riportata nel pacchetto dell'app».
- Per te vuol dire: la verità è il pacchetto. Se l'IT ti segnala una modifica del genere (o la trovi negli eventi), riportala nel pacchetto come nuova migrazione; non contare mai su una modifica fatta a mano, che una reinstallazione dal pacchetto perderebbe.
- La struttura della raccolta utenti dell'app e le raccolte di sistema non si cambiano dalla scheda Dati.
14 /Checklist prima della consegna
- La cartella si chiama come
ide contiene soloapp.json,DELIVERY.mderelease\. -
app.jsonè JSON valido, conname,kind,port(quella data dall'IT o8199),versionaumentata,accessvoluto, etools. - La porta è una richiesta: né il codice né i documenti del pacchetto contano su un indirizzo con la porta fissa (per esempio
http://server:8199/…); la porta può cambiare quando l'IT carica l'app. - Con login e ruoli:
authconroleField: "role",rolesdal più al meno potente edefaultRole; il camporoleesiste in una migrazione con gli stessi valori. -
toolselenca tutti e soli gli strumenti usati negli script e intoolAccess, con i nomi inglesi ($orch.import.load,{ dryRun: true }…). -
toolAccessapre alle pagine solo gli strumenti che usano davvero, conminRoledove non tutti gli utenti li possono usare,collectionsridotte a quelle che servono,destructiveRolesolo se le pagine devono togliere dati,keyFieldsper l'import delle foto dalle pagine. - Ogni raccolta ha le cinque regole scritte; nessuna regola di scrittura
""; ogni regola di un'app con login inizia con@request.auth.id != ''. -
users:createRuleedeleteRuleanull, anche in un'app pubblica (nessuna iscrizione); nel motore di prova nessuno cambia il proprio ruolo. - Di chi è un record si scrive con l'
iddell'utente (relazione), mai con l'email. - Le rotte
routerAddhanno$apis.requireAuth(...)e controllano il ruolo; nessuna sta sotto/api/tools/. - I campi decisi dal server sono impostati negli hook o vietati nelle regole.
- Gli handler non usano variabili scritte fuori: il codice comune sta in un modulo caricato con
requiredentro l'handler. - SQL solo con parametri legati (
{:nome}e.bind). - Nessuna pagina di login, registrazione, password o «Esci»; c'è il collegamento al portale
/__platform/portal. - Pagine: ogni campo ha la sua etichetta, le tabelle stanno in un contenitore che scorre, a 390 px la pagina non scorre di lato; ogni chiamata che risponde
401ricarica la pagina. - Nessuna risorsa da Internet (CDN, caratteri, librerie); l'SDK è in
pb_public\lib\; nessuna libreria per Excel, PDF o immagini nelle pagine: lo fanno gli strumenti del motore. - Le modifiche dalle pagine portano il
$version(confields,$versionè nell'elenco); il409si mostra in parole semplici; nessun428nelle prove. - Lavori pianificati con un nome stabile, errori non ingoiati, lavori lunghi che si possono rifare dopo un riavvio.
- Nessun indirizzo, porta o percorso assoluto nel codice.
- Nessun programma esterno, nessun file fuori dalla cartella, nessun collegamento simbolico o giunzione nella cartella, nessun servizio esterno che l'IT non abbia chiesto.
- Ogni indirizzo chiamato dagli script è in
networkdiapp.json(e inDELIVERY.mdcon a cosa serve); modelli AI solo dagli script, mai dalle pagine; nessuna chiave nel codice. - Le migrazioni partono da un database vuoto e hanno la funzione per tornare indietro; quelle già consegnate non sono state modificate.
- Niente
state\,pb_data,logs\,node_modules,.git, file temporanei, utenti o password di prova, dati personali reali. - Nomi tecnici e commenti del codice in inglese; testi per le persone nella lingua (o nelle lingue) dell'app, semplici; numeri e date nel formato della lingua.
- Le prove della sezione 12.3 sono state fatte e descritte in
DELIVERY.md. - Nessun archivio di dati di persone nello zip (sezione 7.4); i dati iniziali sono importati con lo strumento del motore;
DELIVERY.mdha la riga «Dati personali». - L'app è stata provata su un Pyrite proprio (sezione 15, punto 10) e
DELIVERY.mdcomincia con la versione del motore per cui è scritta (punto 11). - Lo zip ha la struttura della sezione 15 e sta nei suoi limiti.
15 /La consegna: il pacchetto zip e «Carica app»
L'IT installa l'app dalla console del motore, pagina «Carica app» (Upload app): sceglie (o trascina) lo zip, il motore lo controlla a passi e, se i controlli veri sono superati, lo installa. Un Proprietario la installa direttamente; un'app caricata da un Admin si installa in attesa di approvazione (punto 6).
- Il pacchetto è un archivio ZIP (per esempio
parts-pricelist-0.1.0.zip) con dentro la cartella dell'app:parts-pricelist/app.json,parts-pricelist/DELIVERY.md,parts-pricelist/release/….app.jsonpuò stare anche nella radice dello zip, ma una cartella con il nome dell'idè la scelta più chiara. Una sola app per zip. - Cosa non ci va:
state/elogs/(se ci sono non si installano: le crea il motore; in un aggiornamento una cartellastate/fa rifiutare il pacchetto: punto 8),pyrite-archivio.jsonepyrite-ripristino.jsonaccanto adapp.json(quei nomi il motore li tiene per il suo Archivio: non si installano, e il riepilogo lo dice),node_modules,.git, file di programmi (.exe,.bat,.ps1…: si copiano ma il motore non li esegue mai, e il controllo li segnala). - Limiti (oltre, rifiutato): zip fino a 200 MB, al più 10.000 voci, al più 1 GB una volta estratto,
app.jsonfino a 256 KB e in UTF-8 (il segno BOM che scrive il Blocco note si toglie con un avviso). Percorsi rifiutati: con.., assoluti o con un'unità (C:), barre rovesciate che risalgono, nomi riservati di Windows (CON,NUL,COM1…), caratteri vietati (: * ? " < > |), nomi che finiscono con uno spazio o un punto, due nomi che differiscono solo per maiuscole, collegamenti simbolici, voci cifrate, nomi non UTF-8, caratteri di controllo, nomi oltre 255 caratteri (400 per tutto il percorso), compressione diversa da store/deflate, un file e una cartella con lo stesso nome.__MACOSX,.DS_Store,Thumbs.dbedesktop.inisi ignorano. - Cosa controlla davvero «Carica app» (tre passi, ognuno con un punteggio):
- Lettura del pacchetto: i limiti e i percorsi qui sopra, e dove sta la cartella dell'app;
- Scheda dell'app (app.json): le stesse regole di «Rileggi cartelle» e di «Nuova app» (un
app.jsoncon due chiavi uguali a meno delle maiuscole è rifiutato):idvalido, non riservato (anche i nomi dei dispositivi di Windows, sezione 3) e non registrato (l'iddi un'app installata che la persona gestisce ne fa un aggiornamento: punto 8), cartella libera,name,kind,access,portnell'intervallo, non usata dal motore né da un'altra app registrata (anche ferma) e libera davvero, insieme alla porta + 1000; nomi dei ruoli validi,defaultRoletra i ruoli; per informazione, gli strumenti aperti alle pagine e chi li può usare (toolAccess) e gli indirizzi di rete che l'app dichiara (network), con un avviso per ogni voce scritta male; - Struttura delle cartelle:
release/;release/publicper un'app statica,release/pb_publicper un'app PocketBase (conindex.html),release/pb_hooksobbligatoria perpocketbase-cron, migrazioni, note di consegna, le ricette di import inrelease/import/*.json(ognuna si legge: una voce sconosciuta rende la ricetta non valida e la scheda «Importa» la mostra con il motivo); un pacchetto che registra rotte scritte sotto/api/tools/inrelease/pb_hookssi rifiuta (con un segnaposto che potrebbe coincidere: solo un avviso) (il riepilogo dice file, riga e il percorso da usare,/api/<id>/…).
- Cosa è solo dimostrativo in questa alpha: i passi Controllo di sicurezza e Compatibilità con lo standard mostrano un punteggio simulato, segnato «solo indicativo», con una nota che dice che non è un controllo di sicurezza. Le righe che mostrano sono vere (file di programmi trovati, copia locale dell'SDK, note presenti), ma non esaminano il tuo codice: l'app la controlla l'IT prima di avviarla (e un Proprietario prima di approvarla). Non contarci, e segui questo standard.
- L'app installata resta spenta: si copia in
apps\del motore e si registra, ma non compare nel portale finché l'IT non preme «Avvia» nella sua pagina. Se l'ha caricata un Admin è in attesa di approvazione: niente la può avviare finché un Proprietario non preme «Approva»; dopo la può avviare l'Admin, che la gestisce. Chi l'ha caricata non resta al buio: la pagina dell'app dice quando i Proprietari sono stati avvisati (ricevono un'email, se il motore ha la posta impostata), chi ha caricato può premere «Ricorda ai Proprietari» una volta al giorno, e se un Proprietario preme «Rifiuta» l'app viene archiviata e chi l'ha caricata legge il motivo in Panoramica (e in un'email). Poi l'IT dà l'accesso a persone o gruppi, un Proprietario controlla gli strumenti nella scheda Configurazione, i permessi delle pagine nella scheda «Strumenti per le pagine» e gli indirizzi nella scheda «Rete in uscita». DELIVERY.md, in parole semplici, per l'IT (nella lingua che l'IT legge; di norma l'inglese). «Carica app» ne mostra l'inizio nel riepilogo, come testo semplice. Valgono ancheREADME.md,NOTES.mdeCONSEGNA.md; mettine uno solo. Dice:- a cosa serve l'app e chi la usa;
- tipo, accesso, porta proposta;
- ruoli: cosa può fare ciascuno e qual è il livello base;
- strumenti del motore da attivare (gli stessi di
app.json) e gli strumenti aperti alle pagine (toolAccess): quali, per chi e perché; - gli indirizzi di rete (
network) e a cosa serve ciascuno; - i lavori pianificati: nome, quando girano (in UTC) e cosa fanno;
- dati iniziali compresi (cosa sono, da dove vengono);
- Dati personali (riga obbligatoria, sezione 7.4): quali dati di persone ci sono, da dove vengono, quali ruoli li vedono; «nessuno» se non ce ne sono;
- le prove fatte e come rifarle;
- quello che non c'è o che servirebbe (strumenti mancanti, richieste in contrasto con lo standard).
- Aggiornamenti. Un aggiornamento è un nuovo zip con lo stesso
ideversionpiù alta, caricato da «Carica app» oppure dal pulsante Aggiorna da zip dell'app (da lì un altroidsi rifiuta). Il pacchetto passa gli stessi controlli di uno nuovo (la porta resta quella dell'app, e il riepilogo lo dice); poi il motore lo confronta con la versione installata e mostra all'IT che cosa cambia (versione, campi diapp.json, migrazioni nuove, tolte e rinominate), prima di toccare niente. Lo fa partire un Proprietario; se lo carica un Admin che gestisce l'app, aspetta l'approvazione di un Proprietario e intanto la versione in uso continua a girare. Un aggiornamento aspetta finché ci sono lavori avviati dalle pagine in corso. Lo scambio è tutto o niente: l'app si ferma (i lavori pianificati in corso si interrompono, come con un riavvio), si fa una copia di sicurezza (release\,app.jsone tuttastate\senza le cartelle temporanee del motoretmp,import,importa,tools\files: tornano anche i file che gli script scrivono instate\), si sostituisconorelease\,app.jsone le note di consegna in radice (DELIVERY.md,README.md,NOTES.md,CONSEGNA.md; quelle che la versione nuova non ha si tolgono), la versione nuova parte, applica le migrazioni nuove e deve rispondere. Se non succede (una migrazione si ferma, uno script non si legge, un errore di configurazione, nessuna risposta) tutto torna da solo com'era, dati compresi, e l'IT legge il perché. Un'app che era ferma parte solo per questo e torna ferma. Accessi, ruoli e legami delle persone e il record dell'app nel motore non cambiano. Dopo, un Proprietario può premere Torna alla versione di prima: anche le note di consegna tornano com'erano, e i dati scritti dopo l'aggiornamento per l'app si perdono (restano in una copia messa da parte): la numerazione torna indietro e i numeri possono ripetersi (anche su un PDF già inviato), quindi va usato subito dopo un aggiornamento. Anche la porta torna quella che aveva prima dell'aggiornamento (se nel frattempo l'IT l'ha cambiata, la cambia di nuovo dalla scheda Configurazione). Regole per chi prepara un aggiornamento:- Il pacchetto ha
release\intera (un file che nel pacchetto non c'è sparisce) eapp.jsoncompleto. - Mai una cartella
state\: il pacchetto si rifiuta, un aggiornamento non sostituisce mai i dati. Si sostituiscono solorelease\,app.jsone le note di consegna in radice: altri file in radice non si copiano (DELIVERY.mdcompare nel riepilogo: scrivi lì che cosa cambia in questa versione). - Le migrazioni sono i file
.jse.tsdipb_migrations\, come li legge il worker (anche un.tsgira). Le modifiche allo schema arrivano solo come migrazioni nuove, con un numero nuovo e più alto. Una migrazione già consegnata non si cambia, non si rinomina e non si toglie: il motore ricorda le migrazioni per nome del file, quindi un file rinominato ripartirebbe sui dati veri. Per una rinominata o tolta l'IT riceve un avviso forte, da accettare apposta; una migrazione consegnata con il contenuto cambiato tiene il nome e non riparte, quindi la modifica non ha effetto sui dati (l'IT riceve un avviso). Per cambiare lo schema aggiungi una migrazione nuova; se poi quella migrazione si ferma con un errore, la versione di prima torna da sola. - Tutte le migrazioni nuove di un aggiornamento girano in un'unica transazione, su un'app con dati veri: non devono supporre raccolte vuote, non devono aggiungere dati di esempio e devono essere veloci (c'è un tempo massimo, pochi minuti, prima che l'aggiornamento venga annullato).
- L'
idnon può cambiare, e nemmeno il tipo tra statica e PocketBase (pocketbaseepocketbase-cronsì).versiondeve essere più alta, confrontata come semver (1.0.0-beta.10viene dopo1.0.0-beta.9; quello che segue+si ignora): altrimenti l'IT riceve un avviso. - I cambi di
access,port,auth,network,toolAccess,concurrencyetoolssi mostrano all'IT uno per uno: spiega il perché inDELIVERY.md. Quello che allarga i permessi (accessche diventapublic, voci nuove innetwork,toolAccessotools, ruoli nuovi) è un avviso forte che l'IT deve confermare apposta: il nuovoapp.jsonriscrive anche quello che l'IT ha cambiato dalla console (un accesso ristretto, gli indirizzi). Una porta nuova si controlla come per un'installazione. Se togli un ruolo, di' che cosa succede alle persone che ce l'hanno: il loro ruolo nei dati dell'app non cambia da solo. - Nessun file con il nome che finisce in
.agg-tmp(i file temporanei del motore: il pacchetto si rifiuta).
- Il pacchetto ha
- Senza «Carica app» un Proprietario può ancora installare l'app copiando la sua cartella in
apps\del motore e premendo «Rileggi cartelle» nella console (l'app viene registrata e parte). - Prova prima sul tuo Pyrite. Chi scrive un'app la installa e la prova su un Pyrite suo, prima di caricarla sul motore dell'azienda: basta l'eseguibile, con il doppio clic. Lì ci sono la numerazione, lo storico e i ruoli veri, che la prova locale della sezione 12.1 non ha. Sul motore dell'azienda un'app caricata da un Admin aspetta l'approvazione di un Proprietario (punto 6): è voluto, non va aggirato.
- Per quale versione. Prima di consegnare guarda la versione del motore dove andrà l'app (è scritta in console) e scrivila nella prima riga di
DELIVERY.md, per esempio «Per Pyrite 0.7.7». Non scrivere la versione dello standard o quella che ricordi: conta quella del motore dove l'app sarà installata.