npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@msbci/form-server

v1.17.0

Published

Form server — REST API with Prisma, multi-tenant, multi-database

Readme

@msbci/form-server

npm version license

REST API server for form management — Prisma ORM, multi-tenant, PostgreSQL + SQLite. Compatible with Express, Fastify, and Next.js API Routes.

Installation

npm install @msbci/form-server @msbci/form-core @prisma/client
npm install -D prisma

Usage — Next.js API Routes

// app/api/forms/[...path]/route.ts
import { createFormRouter } from '@msbci/form-server'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()
const router = createFormRouter({
  prisma,
  auth: async (req) => {
    const token = req.headers.authorization?.replace('Bearer ', '')
    const user = await verifyToken(token)
    return user ? { userId: user.id, tenantId: user.tenantId } : null
  },
})

export async function GET(req: Request) {
  const url = new URL(req.url)
  const path = url.pathname.replace('/api/forms', '')
  const result = await router.handle({
    method: 'GET',
    path,
    params: {},
    query: Object.fromEntries(url.searchParams),
    body: null,
    headers: Object.fromEntries(req.headers),
  })
  return Response.json(result.body, { status: result.status })
}

export async function POST(req: Request) {
  const url = new URL(req.url)
  const path = url.pathname.replace('/api/forms', '')
  const body = await req.json()
  const result = await router.handle({
    method: 'POST',
    path,
    params: {},
    query: Object.fromEntries(url.searchParams),
    body,
    headers: Object.fromEntries(req.headers),
  })
  return Response.json(result.body, { status: result.status })
}

Usage — Express

import express from 'express'
import { createFormRouter } from '@msbci/form-server'
import { PrismaClient } from '@prisma/client'

const app = express()
const router = createFormRouter({ prisma: new PrismaClient() })

app.use('/api/forms', express.json(), async (req, res) => {
  const result = await router.handle({
    method: req.method,
    path: req.path,
    params: {},
    query: req.query as Record<string, string>,
    body: req.body,
    headers: req.headers as Record<string, string>,
  })
  res.status(result.status).json(result.body)
})

Auth Injectable

The package includes no auth logic. Inject your own via the auth option:

import type { AuthMiddleware } from '@msbci/form-server'

const myAuth: AuthMiddleware = async (req) => {
  const token = req.headers.authorization?.replace('Bearer ', '')
  if (!token) return null // → 401 Unauthorized
  const user = await verifyJWT(token)
  return { userId: user.id, tenantId: user.orgId }
}

createFormRouter({ prisma, auth: myAuth })

Default: noAuth — allows all requests (for development).

Multi-Tenant

tenantId is an optional field on FormDefinition and FormSubmission:

  • Pass tenantId when creating forms and submissions
  • Filter by tenantId in list queries
  • Auth middleware can set tenantId from the authenticated user

Scoping submissions (v1.14.0+)

By default the submission routes perform no tenant check: any caller the auth middleware lets through can read and update any submission. Opt in to scoping:

createFormRouter({ prisma, auth: myAuth, tenancy: { scopeSubmissions: true } })

With the option on:

  • a submission belongs to the tenant of the form it answers, never to the tenantId sent in the request body — that one comes from the caller;
  • GET /submissions/:id, PUT /submissions/:id and GET /forms/:id/submissions answer 404 to anyone else, so the refusal does not disclose that the submission exists;
  • the author of a submission (IRequestContext.userId matching createdBy) keeps access to it even without a tenant — an external respondent belongs to none;
  • POST /submissions stays open to any caller, and stamps the created row with the form's tenant.

Database

| Provider | Use case | URL format | |----------|----------|------------| | PostgreSQL | Production | postgresql://user:pass@host:5432/db | | SQLite | Development / Testing | file:./dev.db |

Copy the Prisma schema from the package and adjust the provider:

datasource db {
  provider = "postgresql"  // or "sqlite"
  url      = env("DATABASE_URL")
}

Routes (27)

| Method | Path | Description | |--------|------|-------------| | GET | /form-types | List all form types | | GET | /form-types/:id | Get form type | | POST | /form-types | Create form type | | PUT | /form-types/:id | Update form type | | DELETE | /form-types/:id | Delete form type | | GET | /forms | List forms (paginated, searchable) | | GET | /forms/:id | Get form with full structure | | POST | /forms | Create form | | PUT | /forms/:id | Update form | | DELETE | /forms/:id | Delete form (cascade) | | GET | /forms/:id/export | Export form as JSON | | POST | /forms/import | Import form from JSON | | POST | /forms/:id/duplicate | Copy the form into the next version (unpublished, no submissions) | | POST | /forms/:formId/pages | Create page | | PUT | /pages/:id | Update page | | DELETE | /pages/:id | Delete page | | POST | /pages/:pageId/rosters | Create roster | | PUT | /rosters/:id | Update roster | | DELETE | /rosters/:id | Delete roster | | POST | /pages/:pageId/variables | Create variable on page | | POST | /rosters/:rosterId/variables | Create variable on roster | | PUT | /variables/:id | Update variable | | DELETE | /variables/:id | Delete variable | | GET | /forms/:formId/submissions | List submissions (paginated) | | GET | /submissions/:id | Get submission | | POST | /submissions | Create submission | | PUT | /submissions/:id | Update submission status | | GET | /integrity/variable-codes | List duplicate variable codes (optional ?formId=) | | GET | /submissions/:id/attachment?ref= | Stream one attachment of that submission (only when storage is configured) |

All inputs validated with Zod. Errors returned as { status, message, data, errors }.

Attachment storage (v1.15.0+)

By default an attachment travels inside the answer, base64-encoded, and the server never talks to a store. Pass storage and two things change, both of them opt-in:

const router = createFormRouter({
  prisma,
  storage: {
    // Any @msbci/form-storage adapter satisfies this shape, and so does a
    // hand-written reader: only head() and getStream() are required.
    store: adapter,
    // Mandatory, with no permissive default: a reference is not a right.
    authorizeRef: async ({ stored, formId, submissionId, userId }) =>
      myDomain.userMayRead(userId, stored.ref),
  },
})

1. References carried by a submission are checked before it is stored. For every value of a file field that is an IStoredFile, the server asks the store whether the reference exists, then asks the host — through authorizeRef — whether it belongs to this submission's scope. Either answer being negative yields the same 400 message, so the caller cannot tell an invented reference from someone else's. The metadata the browser announced (fileName, contentType, size, checksum, adapter) is then replaced by what the store returns: a declared size can no longer walk past maxBytes. A reference found outside a file field is refused outright.

Two things the package cannot check, and does not pretend to: whether the depositor was entitled to write that file in the first place — that is the upload route's job, not the submission's — and what a "scope" means, which is exactly what authorizeRef is for. A store outage is not turned into a rejection: the error propagates, because a refusal is final where an outage is temporary.

2. One read route appears, GET /submissions/:id/attachment?ref=…. It identifies the file by the submission that carries it, never by the reference alone, and it reuses the submission's own access rule — including tenancy.scopeSubmissions and its silent 404. A reference the stored submission does not carry is a 404 too, with nothing in the body confirming it exists elsewhere. The bytes are then streamed by the server, under the original file name in Content-Disposition (?download=1 for an attachment disposition). A content policy restricted to same-origin lets a signed link work for a download — that is a navigation — but blocks the preview of an image served from another host; streaming is therefore the default. Set signedUrlRedirect: true to let a caller ask for a 302 towards a signed link with ?redirect=1.

That route is the only one that returns stream and headers on RouteResponse. The host must relay them instead of serialising body:

const result = await router.handle(request)
if (result.stream) {
  res.status(result.status).set(result.headers)
  Readable.fromWeb(result.stream).pipe(res)
} else {
  res.status(result.status).json(result.body)
}

Without the storage option none of this exists: no reference is looked at, no value is rewritten, the route is not registered, and data URLs pass exactly as before.

Reporting unexpected failures (v1.12.0+)

An error that is neither an ApiError nor a validation rejection becomes a bare 500 — no cause, no route, nothing logged. Pass onError to see it:

const router = createFormRouter({
  prisma,
  onError: (error, request) => {
    logger.error({ err: error, path: request.path, method: request.method })
  },
})

The callback never changes the response: the client still gets the same 500 Internal server error body, with nothing of the cause in it.

Server-side data integrity (v1.5.0+)

Five guards protect stored data. They are on by default.

const router = createFormRouter({
  prisma,
  integrity: {
    // false restores the pre-1.5.0 behaviour (no server-side check at all)
    validateSubmissions: true,
    // language used to resolve localized rejection messages
    lang: 'fr',
  },
})

| Guard | Behaviour | |-------|-----------| | Submission validation | A submission whose status is not draft is checked against the form definition (ValidationEngine from @msbci/form-core). A rejection is a 400 whose errors array names each offending field. Drafts are never blocked. | | Form deletion | DELETE /forms/:id returns 409 when the form carries submissions. Use PUT /forms/:id { "isPublished": false } to take a form out of circulation without losing the data. | | Answered field removal (v1.9.0+) | A field that carries at least one answer can no longer disappear — neither through PUT /forms/:id (whole-tree save, which replaces every page) nor through DELETE /variables/:id, DELETE /pages/:id or DELETE /rosters/:id. A rejection is a 409 naming each lost code. Fields that were never filled in stay freely removable. | | Variable code uniqueness | Two variables of one form can no longer share a code (409). Enforced at write time and by two unique indexes on form_variables. | | Variable code rename | PUT /variables/:id { "code": … } is accepted while the form has no submission — every reference (conditions, expression, pilot variable, instance control, data-source dependencies, label templates) is rewritten — and refused (409) once a submission exists. | | All-or-nothing tree write (v1.13.0+) | Creating, updating, importing or duplicating a form writes its whole page tree inside one Prisma transaction. A failure halfway through leaves the stored form untouched instead of amputated. Timeouts: integrity.transactionTimeoutMs / integrity.transactionMaxWaitMs. | | Concurrent save (v1.13.0+) | PUT /forms/:id accepts the optional updatedAt returned by GET /forms/:id. When it no longer matches the stored row, the write is refused with a 409 carrying code: "FORM_VERSION_CONFLICT" and naming the concurrent version's timestamp. Omit the field to keep the previous, unchecked behaviour. | | Reshaping a form that carries submissions (v1.13.0+) | POST /forms/:id/duplicate copies the form into the next version — same code, no submissions, unpublished — so it can be reshaped freely while the original stays frozen with its data. Override code, version, name or tenantId in the payload; a version already taken is a 409. |

Run GET /integrity/variable-codes before applying the schema: the unique indexes cannot be created on a database that already holds duplicates. The route returns one entry per collision (formId, formCode, code, variableIds), which is what a rename has to resolve. The same information is available in SQL:

SELECT p."formId", v.code, count(*)
FROM form_variables v
LEFT JOIN form_pages p ON p.id = v."pageId"
LEFT JOIN form_rosters r ON r.id = v."rosterId"
LEFT JOIN form_pages rp ON rp.id = r."pageId"
GROUP BY coalesce(p."formId", rp."formId"), v.code
HAVING count(*) > 1;

v1.17.0

  • Les identifiants d'un formulaire ne changent plus a chaque enregistrement. PUT /forms/:id remplacait l'arbre en bloc : suppression des pages, puis recreation complete. Pages, tableaux et variables recevaient donc un identifiant neuf a chaque sauvegarde, meme quand rien n'avait bouge. Un hote qui conserve l'identifiant d'un element — correspondance, cache, rapport, piece jointe, journal d'audit — voyait cette reference se rompre en silence, sans qu'aucun contrat ne l'annonce. L'arbre entrant est desormais reconcilie avec celui de la base : un element deja present garde son identifiant, un element neuf en recoit un, et seul ce qui a reellement disparu est supprime.
  • L'appariement va a l'identifiant d'abord, au code ensuite, a la creation a defaut. Le code seul ne suffisait pas : il est renommable, et l'appariement par code aurait fait changer l'identifiant a chaque renommage — ce qui vidait la correction de son sens. L'identifiant transporte par l'arbre entrant est celui de la base pour un element deja enregistre, et une valeur fabriquee cote client pour un element neuf ; cette derniere ne figure dans aucun index et retombe naturellement sur le code, puis sur la creation. Les deux passes sont menees l'une apres l'autre sur tout un conteneur, et non element par element : un element qui reprend le code libere par un renommage ne peut donc pas voler l'identifiant de celui qui a ete renomme.
  • La reconciliation vaut a tous les niveaux — pages, tableaux, colonnes de tableau, enfants de panneau. La reprise par identifiant porte sur le formulaire entier et non sur le seul conteneur : un element deplace d'une page a l'autre, sorti d'un panneau ou tire d'un tableau vers sa page garde son identifiant, et son rattachement suit.
  • Les rangs sont renumerotes a l'ecriture. Variables de premier niveau et tableaux d'une meme page partagent une seule suite de rangs — c'est elle qui porte leur entrelacement a la relecture. Le rang recu sert desormais de critere de tri, non de valeur a recopier : la suite est reecrite a partir de zero, sans trou ni doublon. Un deplacement laissait jusqu'ici des rangs en double, dont l'ordre de relecture dependait du hasard.
  • Les codes revendiques sont liberes avant la reecriture. Deux elements qui echangent leur code, ou un element qui reprend celui d'un element supprime, heurtaient la contrainte d'unicite au milieu de l'ecriture. Les seules lignes concernees recoivent d'abord un code de remplacement derive de leur identifiant, puis leur code definitif ; un enregistrement sans renommage n'ecrit rien de plus qu'avant.
  • Rien d'autre ne change. Aucune signature, aucune route, aucun corps de reponse, aucune colonne. La transaction en tout ou rien, les trois gardes evaluees avant toute ecriture (assertTreeKeepsAnsweredCodes, assertFlatPanels, assertUniqueTreeCodes), la detection de conflit entre deux onglets par horodatage et le renvoi d'updatedAt apres enregistrement sont intacts et couverts. Un hote pouvait jusqu'ici ne pas compter sur la stabilite de ces identifiants : il le peut desormais.
  • Verifie sur une base reelle : un enregistrement inchange d'un formulaire de 102 elements renouvelait 102 identifiants sur 102 en v1.16.1, et n'en change plus aucun. 13 tests ajoutes, 177 passants. Le test d'atomicite injectait jusqu'ici un rang invalide pour interrompre l'ecriture ; le rang etant maintenant reecrit par le serveur, l'injection porte sur un code invalide, et la garantie verifiee est la meme.

v1.16.1

  • Republication d'alignement. @msbci/form-core passe en 1.16.0 (un conteneur ne peut plus etre rendu obligatoire, un panneau masque emporte ses enfants) ; la dependance etant epinglee a l'exacte version, ce paquet est republie pour rester installable avec elle. Aucun changement du service ni du routeur : la validation de valeur relevee par ce lot est celle du coeur, partagee par les deux cotes. 164 tests passants.

v1.16.0

  • Deux reglages d'affichage de tableau, persistes. hideInstanceTitle et showRowNumber (form-core 1.15.0) sont acceptes a l'ecriture, stockes et relus. Sans cela, le concepteur les aurait poses dans l'editeur pour les voir disparaitre au rechargement : le modele stocke un tableau colonne par colonne, et une propriete qu'aucune colonne ne porte n'est pas ecrite.
  • Deux colonnes nullables sur form_rosters : hideInstanceTitle et showRowNumber, toutes deux Boolean?. Une ligne ecrite par une version anterieure les a a NULL et est relue sans ces proprietes, c'est-a-dire avec le comportement d'avant. A appliquer par prisma db push (ou une migration equivalente) au deploiement.
  • Le schema de validation les accepte sur la creation comme sur la modification d'un tableau. Un corps qui ne les porte pas est traite exactement comme avant.
  • L'aller-retour est verifie par la comparaison d'arbre existante, propriete par propriete : les deux reglages font desormais partie du formulaire de reference, et un oubli de mappage ferait echouer le test au lieu de se decouvrir a la relecture.
  • Republication d'alignement. @msbci/form-core passe en 1.15.0 (deplacement d'un element de page, reglages d'affichage de tableau) ; la dependance etant epinglee a l'exacte version, ce paquet est republie pour rester installable avec elle. Le deplacement entre pages se joue entierement cote client : le serveur recoit un arbre de pages comme un autre. 164 tests passants.

v1.15.1

  • Republication d'alignement. @msbci/form-core passe en 1.14.0 (duplication d'un sous-arbre de formulaire) ; la dependance etant epinglee a l'exacte version, ce paquet est republie pour rester installable avec elle. Aucun changement du service ni du routeur.
  • Un aller-retour de plus sous surveillance. La duplication se joue entierement cote client : le serveur recoit un arbre de pages comme un autre. Quatre tests verifient qu'un sous-arbre duplique — codes generes, references reecrites, ordres renumerotes — est relu a l'identique apres enregistrement, et que deux duplications successives ne heurtent pas les contraintes d'unicite du modele. 4 tests ajoutes, 164 passants.

v1.15.0

Le serveur savait qu'une reponse pouvait porter une reference de fichier, mais il n'en verifiait aucune et ne savait pas la relire.

  • Une reference arrivant du navigateur etait crue sur parole. Depuis v1.14.1, une valeur de piece jointe peut etre une reference IStoredFile au lieu d'une data URL. Rien ne la confrontait a quoi que ce soit : un appelant pouvait placer dans sa soumission la reference d'un fichier depose par quelqu'un d'autre, ou une reference inventee, et le serveur l'enregistrait. Nouvelle option storage de createFormRouter : chaque reference d'un champ fichier est confrontee au stockage (head), puis soumise au rappel authorizeRef de l'hote. Ce rappel est obligatoire et sans defaut permissif — le paquet sait dire qu'une reference existe, il ignore a quel objet metier elle se rattache et qui a le droit de la consulter.
  • Le refus est le meme dans les deux cas. Une reference inconnue et une reference relevant d'une autre portee produisent le meme message : distinguer les deux revelerait l'existence de la seconde.
  • Le poids annonce ne fait plus autorite. Les metadonnees de la reference — nom, type, taille, empreinte, code du stockage — sont remplacees par celles que le stockage rend, avant que le plafond de taille ne soit mesure. Sans cela, un size declare a 1 laissait passer n'importe quel fichier a travers maxBytes. Une reference glissee dans un champ qui n'attend pas de fichier est refusee : elle ne serait ni verifiee ni lisible.
  • Une panne du stockage n'est pas convertie en refus. L'erreur remonte telle quelle. Une soumission legitime ne doit pas etre rejetee definitivement parce que le stockage n'a pas repondu.
  • Aucun moyen de relire une piece. Nouvelle route GET /submissions/:id/attachment?ref=…, montee uniquement lorsque storage est configure. Elle designe la piece par la soumission qui la porte, jamais par la seule reference, et reprend le cloisonnement existant des soumissionstenancy.scopeSubmissions et son 404 muet — sans creer un second mecanisme. Une reference que la soumission enregistree ne porte pas vaut 404 elle aussi, sans que le corps confirme qu'elle existe ailleurs.
  • La lecture est servie en flux par le serveur, pas par un renvoi vers un lien signe. Une politique de contenu restreinte a la meme origine laisse passer un telechargement par navigation mais bloque l'apercu d'une image servie par un autre hote : la panne serait partielle, donc invisible aux sondes. Le nom d'origine est restitue en Content-Disposition, en ASCII et en filename* UTF-8 ; ?download=1 bascule en piece jointe. Un renvoi 302 vers un lien signe reste possible sur demande explicite de l'hote (signedUrlRedirect: true) et de l'appelant (?redirect=1).
  • RouteResponse porte deux champs facultatifs de plus, headers et stream. Seule la nouvelle route les renseigne ; un hote qui monte l'option storage doit relayer le flux au lieu de serialiser body, faute de quoi la piece arrive vide.
  • Le contrat de lecture attendu du stockage est declare ici, IAttachmentStore, et reduit a head et getStream (getSignedUrl facultatif). Le serveur ne depend d'aucun paquet de connecteurs : un IStorageAdapter de @msbci/form-storage le satisfait sans adaptation, et un hote qui possede deja son stockage n'a que deux methodes a ecrire.
  • Impact : strictement additif, l'option etant absente par defaut. Sans elle, aucune reference n'est verifiee, aucune valeur n'est reecrite, la route n'est pas enregistree et une data URL passe exactement comme avant. Aucune signature modifiee, aucune colonne ajoutee, aucune migration necessaire. 15 tests ajoutes, 160 passants.

v1.14.1

  • Republication de suivi. Aucun changement de code ni de comportement : le paquet est republie pour dependre de @msbci/form-core v1.13.0. La validation des soumissions y gagne mecaniquement la mesure bivalente des pieces jointes — une reponse portant une reference IStoredFile est desormais pesee au lieu d'etre ignoree — sans qu'une ligne du serveur ait change. L'option storage et la route de lecture restent a venir.
  • Le paquet publie empechait le processus Node qui l'importait de se terminer. L'obfuscation posait une protection anti-debogage a intervalle, c'est-a-dire un setInterval jamais arrete : la boucle d'evenements restait occupee, et tout script, outil en ligne de commande ou tache d'integration continue qui importait le paquet restait suspendu jusqu'a son delai d'expiration, sans message et sans cause visible. Le defaut touchait ce paquet de plein fouet, puisqu'il est fait pour etre monte dans un processus serveur. La protection est retiree — elle vise la console d'un navigateur et ne protegeait rien sur une cible Node, tandis que les instructions debugger qu'elle injectait interrompaient le debogage legitime d'un consommateur. Ce qui rend le code couteux a relire est conserve : tableau de chaines encode, aplatissement du flot de controle, code mort, renommage des identifiants. 145 tests passants, inchanges.

v1.14.0

Les soumissions n'etaient cloisonnees par aucun tenant.

  • Une soumission etait lisible et modifiable par n'importe quel appelant que l'authentification laissait passer. GET /submissions/:id et PUT /submissions/:id ne consultaient ni le tenant de l'appelant, ni celui du formulaire renseigne : un hote multi-tenant qui montait ces routes exposait a chacun de ses tenants les reponses deposees chez les autres, et leur permettait d'en changer le statut. Les soumissions portant les donnees des declarants, la portee du defaut est celle des donnees elles-memes. Nouvelle option tenancy.scopeSubmissions de createFormRouter : une soumission n'est desormais lisible et modifiable que par le tenant du formulaire qu'elle renseigne, et par l'auteur qui l'a deposee.
  • Le tenant retenu est celui du formulaire, pas celui de la soumission. La colonne tenantId d'une soumission vient du corps de la requete, donc de l'appelant : elle ne prouve rien. Celle du formulaire est posee par l'hote a la creation. Le cloisonnement s'appuie sur elle, et POST /submissions rattache desormais la soumission creee au tenant du formulaire, en ignorant ce que l'appelant annonce.
  • Le depot reste ouvert, et c'est voulu. Exiger un tenant a la creation fermerait le formulaire a tout declarant externe, qui par definition n'appartient a aucune organisation de l'hote. POST /submissions ne demande donc aucun tenant. L'auteur est reconnu par IRequestContext.userId, confronte a la colonne createdBy : sans cela, un declarant sans tenant ne pourrait ni relire ni completer son propre brouillon.
  • Le refus est un 404, pas un 403. Un appelant d'un autre tenant ne doit pas apprendre qu'une soumission existe. GET /forms/:id/submissions repond de meme lorsque le formulaire appartient a un autre tenant.
  • Impact : strictement additif, l'option etant inactive par defaut. Un hote qui ne la declare pas garde exactement le comportement des versions anterieures, y compris le rattachement de la soumission au tenantId envoye par le client. Aucune signature modifiee, aucune colonne ajoutee, aucune migration necessaire. 9 tests ajoutes, 145 passants.

v1.13.0

Trois defauts lies, tous sur l'integrite de l'enregistrement d'un formulaire.

  • L'enregistrement n'etait pas transactionnel — perte de donnees silencieuse. updateFormDefinition supprimait toutes les pages du formulaire (formPage.deleteMany) puis reecrivait l'arbre recu, sans transaction. Une coupure entre les deux — reseau, redemarrage du conteneur, contrainte violee sur une page — laissait le formulaire ampute en base, definitivement : les pages supprimees, les nouvelles jamais ecrites, aucun retour arriere. Le travail de conception disparaissait sans le moindre message. Toute ecriture d'arbre — creation, mise a jour, import, duplication — se deroule desormais dans une transaction Prisma : elle aboutit entierement ou ne laisse aucune trace. Deux options facultatives, integrity.transactionTimeoutMs (defaut 30 s) et integrity.transactionMaxWaitMs (defaut 10 s), parce qu'un formulaire de plusieurs centaines de champs s'ecrit ligne par ligne et depasse le plafond de 5 s de Prisma.
  • Les gardes restent evaluees avant la moindre ecriture. Le controle des panneaux plats, l'unicite des codes et le refus de faire disparaitre un code deja repondu sont des lectures : ils precedent l'ouverture de la transaction, de sorte qu'un refus n'a jamais a etre annule.
  • Deux enregistrements concurrents : le dernier ecrasait l'autre sans un mot. Meme cause — l'arbre est remplace en bloc — et aucun moyen de s'en apercevoir. La reponse de GET /forms/:id porte desormais updatedAt, date de la derniere ecriture ; PUT /forms/:id accepte un champ facultatif updatedAt et le confronte a l'etat en base dans la meme operation SQL que l'ecriture, ce qu'un SELECT suivi d'un UPDATE ne saurait garantir. En cas d'ecart, refus 409 portant le code FORM_VERSION_CONFLICT et nommant l'heure de la version concurrente ; rien n'est ecrit. Un hote qui n'envoie pas updatedAt garde exactement le comportement anterieur.
  • Un formulaire porteur de dossiers etait immobilise. La garde des codes deja repondus protege les reponses deposees, mais elle interdit tout remaniement des la premiere soumission : la seule issue etait de vider les soumissions, inacceptable en production. Nouvelle route POST /forms/:id/duplicate, qui recopie le formulaire — pages, tableaux, panneaux, champs, liens de gabarit, perimetres, configuration linguistique — dans la version suivante. Les arbitrages : le code est conserve (une version n'est pas un autre formulaire, la clef unique (code, version, tenantId) est faite pour cela), les soumissions ne sont pas copiees (elles ont ete deposees sur la structure de l'original), et la copie n'est pas publiee, quel que soit l'etat de l'original. La version est calculee en incrementant le premier nombre du numero et en remettant les suivants a zero (1.0.0 devient 2.0.0), la premiere libre etant retenue ; code, version, name et tenantId peuvent tous etre imposes dans la charge, et une version deja prise vaut refus 409.
  • Deux versions publiees simultanement restent possibles, et le modele ne tranche pas. Rien n'y designe la version courante d'un code : si l'hote publie la copie sans retirer l'original, deux versions du meme formulaire sont en circulation et l'API ne dit pas laquelle un declarant doit voir. La copie naissant non publiee, ce cas demande un geste explicite — il n'arrive pas par accident — mais il appartient a l'hote de l'eviter, ou d'attendre une gestion de version complete (brouillon, promotion, retrait, reference de version portee par chaque soumission).
  • ApiError porte un code facultatif, repercute dans le corps de la reponse. Un client doit pouvoir distinguer deux refus de meme statut : un 409 de concurrence n'appelle pas la meme reaction qu'un 409 de code deja repondu. Les refus qui n'en portent pas repondent exactement comme avant.
  • Impact : additif sur l'API (un champ de plus dans la reponse de lecture, un champ facultatif de plus accepte a l'ecriture, une route de plus, deux options de plus, un code de plus dans les refus qui en declarent un). Aucune signature modifiee, aucune colonne ajoutee, aucune migration necessaire. Un seul changement de comportement, assume et voulu : une ecriture d'arbre qui echouait a mi-parcours laissait un formulaire ampute, elle ne laisse desormais rien. 13 tests ajoutes, 136 passants.

v1.12.0

  • Une panne du serveur ne laissait aucune trace. Toute erreur qui n'est ni une ApiError ni un refus de validation devenait une reponse 500 Internal server error vide : ni la cause, ni la route, ni la moindre ligne de journal. Une colonne absente de la base — un schema Prisma en retard sur celui du paquet — se presentait ainsi comme une panne muette, que seule une reproduction hors du serveur permettait d'identifier. Nouvelle option onError(error, request) de createFormRouter : le rappel recoit l'erreur d'origine et la requete fautive, et l'hote la journalise comme il l'entend.
  • La reponse ne change pas. Ni le code, ni le corps : rien de la cause n'est renvoye au client. Sans rappel, le comportement est celui d'avant.
  • Impact : strictement additif (une option facultative de plus). 2 tests ajoutes, 123 passants.

v1.11.0

  • L'API acceptait une piece jointe de n'importe quelle taille. Le navigateur ne prouve rien : une soumission peut arriver par appel direct, et une piece de quarante mega-octets entrait telle quelle dans la ligne enregistree. Nouvelle garde validateAttachmentSizes(form, data, options), exportee, appliquee a la creation comme a la mise a jour d'une soumission.
  • La garde vaut quel que soit le statut, et meme validation desactivee. Le plafond ne protege pas une regle metier mais la ligne stockee : une piece pese le meme poids dans un brouillon que dans une soumission definitive. Le controle est donc separe du reste de la validation, qui elle ne s'applique qu'au definitif et peut etre coupee par integrity.validateSubmissions: false. La reponse est un 400 nommant la variable, le poids recu et la limite.
  • fileConfig.maxBytes et imageConfig.maxBytes transitent par les routes granulaires de creation et de mise a jour d'une variable, et sont relus a l'identique.
  • checkSubmissionData rapporte desormais les pieces hors limite y compris sur un brouillon, sans doublon avec le signalement du moteur.
  • Impact : additif sur l'API (une fonction exportee de plus, deux proprietes acceptees de plus). Un seul changement de comportement, assume et voulu : une soumission portant une piece au-dela du plafond par defaut de 5 Mio est refusee, meme sur un formulaire qui ne declarait aucun plafond, et meme en brouillon. Une soumission dont les pieces restent sous cette limite est traitee exactement comme avant. 6 tests ajoutes, 121 passants.

v1.10.0

  • La valeur par defaut d'une variable etait denaturee a l'ecriture. Elle etait stockee par String(value) dans une colonne texte : une case a cocher revenait 'true', un nombre '42', une liste de valeurs 'a,b'. Le rendu recevait donc une chaine la ou le champ attend un booleen, un nombre ou un tableau. Nouvelle colonne nullable defaultValueJson, qui porte la valeur encodee en JSON et fait foi a la relecture ; la colonne defaultValue continue d'etre alimentee telle quelle, de sorte qu'une version anterieure du serveur lit la meme base sans rien perdre, et qu'une variable ecrite avant ce lot est relue exactement comme avant.
  • Le champ signature est persiste. Nouvelle colonne nullable signatureConfig (JSON ISignatureConfig), portee par la creation, la mise a jour et l'enregistrement de l'arbre complet, et validee par un schema Zod dedie. 'signature' rejoint la liste des types de variable acceptes.
  • Une soumission portant une signature obligatoire est verifiee cote serveur. La validation reutilisant ValidationEngine, un objet quelconque envoye a la place d'une signature est desormais refuse (400) la ou il etait accepte : isEmpty rendant false sur tout objet, un dossier non signe passait.
  • Migration : les deux colonnes sont nullables et sans valeur par defaut. prisma db push (ou une migration) suffit ; aucune reprise de donnees n'est necessaire.
  • Impact : strictement additif — deux colonnes nullables, un champ optionnel de plus dans les charges utiles. Aucune route, aucune reponse et aucune signature de service modifiee. 11 tests ajoutes, 115 passants.

v1.9.0

  • La garde de suppression d'un formulaire etait contournable. DELETE /forms/:id refuse bien un formulaire portant des soumissions depuis la v1.5.0, et PUT /variables/:id refuse d'en renommer un code une fois un dossier depose. Mais PUT /forms/:id remplace l'arbre des pages en bloc — il commence par un DELETE de toutes les pages, puis reecrit celles qu'on lui envoie. Il suffisait donc d'y renommer une variable, ou de ne pas la renvoyer du tout, pour obtenir en un appel ce que la route granulaire refusait : des reponses deposees orphelines de la variable qui les nomme. Une garde contournable ne protege rien.
  • Le controle porte desormais sur le chemin de l'arbre complet, avant la moindre ecriture, comme celui d'unicite des codes. Un enregistrement qui ferait disparaitre un code auquel une soumission a repondu est refuse par un 409 qui nomme chaque code perdu. Le critere est la reponse effective, pas la simple existence de la variable : un champ jamais renseigne reste librement supprimable, y compris sur un formulaire portant des dossiers. Une valeur vide — chaine blanche, liste vide, absence — ne fait pas obstacle.
  • Les suppressions granulaires suivent la meme regle. DELETE /variables/:id, DELETE /pages/:id et DELETE /rosters/:id n'avaient aucune garde : elles auraient rouvert exactement la meme breche par une autre porte. Elles refusent maintenant (409) la disparition d'un champ renseigne, en propre comme par cascade — les enfants d'un panneau, les colonnes d'un tableau, les champs d'une page.
  • Aucune option de forcage. Une soumission est une donnee deposee par un tiers ; retirer un formulaire de la circulation se fait par PUT /forms/:id { "isPublished": false }, qui n'a jamais perdu de donnee.
  • Impact : aucune route, aucun contrat de reponse et aucune option modifies. Un formulaire sans soumission se remanie exactement comme avant, et un formulaire avec soumissions accepte toujours l'ajout d'une page, d'un tableau ou d'un champ. 7 tests ajoutes, 104 passants.

v1.8.0

  • Republication alignee sur @msbci/form-core 1.9.0, dont le message de validation integre etait illisible lorsque le nom de la variable etait multilingue ([object Object] is required) et dont les messages par defaut sont desormais localises. La validation du serveur passant par ce moteur, un dossier refuse renvoie maintenant un message lisible, en francais par defaut, la ou il renvoyait un texte anglais parfois degenere.
  • Aucun changement d'API : routes, formes de reponse et schema de base identiques. La version est portee au niveau mineur parce que le texte des messages de validation renvoyes par l'API evolue.
  • Rappel de publication : la dependance interne etant epinglee a la version exacte au moment de la publication, ce paquet doit etre republie des que le coeur l'est, faute de quoi une installation autonome se retrouve avec deux copies du coeur.

v1.7.0

  • La validation du serveur avait son propre parcours des pages. Il couvrait bien toutes les pages visibles depuis la v1.5.0, mais il etait ecrit ici, en double du rendu — et les deux divergeaient : un tableau masque par une condition y exigeait quand meme ses lignes obligatoires, alors que le rendu n'en exige aucune. Un dossier valide a l'ecran devenait donc indeposable. Le parcours est desormais celui du coeur (ValidationEngine.validateForm, @msbci/form-core 1.8.0+), partage avec le navigateur : les deux cotes acceptent et refusent exactement les memes dossiers.
  • Les champs calcules sont recalcules par le serveur. La valeur d'un champ calculated recue dans une soumission n'engage que le navigateur qui l'a produite, et une soumission peut arriver par appel direct a l'API. Le serveur resout les expressions avec le meme moteur que le rendu et valide sa propre valeur : un total minore avant l'envoi ne passe plus une regle de plafond. La validation seule est concernee — les valeurs enregistrees restent celles recues.
  • Impact : aucune route, aucun contrat de reponse et aucune option modifies. Un formulaire sans champ calcule et sans tableau conditionne est valide exactement comme avant. 4 tests ajoutes, 97 passants.

v1.6.0

  • La persistance perdait les champs regroupes dans un panneau. persistPages n'ecrivait que les variables de premier niveau : un panneau porteur de champs (IFormVariable.children, @msbci/form-core 1.7.0+) etait enregistre sans eux, et la rubrique revenait vide au rechargement. Nouvelle colonne nullable form_variables.parentVariableId et relation reflexive PanelChildren : un enfant conserve son pageId — c'est lui qui porte l'unicite du code a l'echelle de la page et la suppression en cascade — et designe en plus le panneau qui le contient. La lecture ne remonte que les variables sans parent et joint leurs enfants, de sorte qu'un champ regroupe ne reapparaisse jamais au niveau de la page.
  • Le refus est porte par le serveur, pas seulement par l'editeur. Nouvelle garde assertFlatPanels(pages), appliquee a la creation, a la mise a jour et a l'import : un panneau contenant un tableau, un panneau imbrique ou un enfant porteur d'enfants est refuse par un 400 qui nomme chaque anomalie. Ces formes n'ont aucune representation dans le schema — les accepter reviendrait a les perdre en silence a la relecture. Le controle intervient avant la moindre ecriture, comme celui d'unicite des codes : la persistance commence par un DELETE des pages, echouer en cours de route laisserait le formulaire ampute. assertUniqueTreeCodes et la validation des soumissions comptent desormais les enfants d'un panneau parmi les champs du formulaire, de sorte qu'un code d'enfant en collision est refuse (409) comme n'importe quel autre.
  • Test d'aller-retour etendu. Le formulaire de reference porte maintenant un panneau a deux champs — avec colSpan, startWithNewLine, condition et regles de validation — et un panneau sans enfants. Il est enregistre, relu et compare propriete par propriete a l'original, y compris apres avoir deplace un champ dans le panneau puis l'en avoir ressorti. Un panneau sans enfants ne porte pas la propriete a la relecture : sa forme reste celle d'avant l'imbrication.
  • Impact : additif et retrocompatible — une colonne nullable, aucune colonne supprimee, aucune signature ni contrat existant modifie. Un formulaire enregistre avant ce lot reste lisible, la colonne nouvelle a NULL, et ses variables restent des variables de premier niveau. Appliquer prisma db push (ou une migration) pour creer la colonne form_variables.parentVariableId et son index. 14 tests ajoutes, 93 passants.

v1.5.1

  • Persistance des proprietes ignorees a l'enregistrement d'un formulaire. persistPages — le chemin qu'emprunte le PUT de l'editeur visuel, l'import et la creation d'un formulaire non vide — ecrivait le code, le nom, l'ordre et la configuration de repetition d'une page, et pour un tableau son type, son pilote et ses options. Il n'ecrivait ni les conditions de page, ni les conditions de tableau, ni les metadonnees de l'un ou de l'autre, alors que les colonnes existaient depuis l'origine : un concepteur configurait une condition d'affichage, enregistrait, et elle disparaissait au rechargement. Les conditions, regles de validation et options d'une variable etaient deja persistees et ne sont pas concernees. Cinq reports ajoutes a l'ecriture, deux a la lecture (metadata de page et de tableau n'etaient pas relues non plus).
  • Bornes de lignes d'un tableau (collectionConfig). IFormRoster.collectionConfig (min / max de lignes des tableaux collection et collection_extend) n'avait aucune colonne : la propriete etait perdue avant meme d'atteindre la base. Colonne nullable collectionConfig ajoutee sur form_rosters (JSON serialise, meme pattern que options), schema Zod correspondant, et report aller-retour.
  • Configuration linguistique d'un formulaire (langConfig). Meme cas : IFormDefinition.langConfig (langue par defaut, langues disponibles, strategie de detection, position du selecteur) est declare par le coeur et lu par la validation des soumissions, mais aucune colonne ne le portait — les reglages de langue etaient absents a la reouverture. Colonne nullable langConfig ajoutee sur form_definitions, schema Zod correspondant, report aller-retour. Un formulaire enregistre avant ce lot a la colonne a NULL et retombe sur DEFAULT_LANG_CONFIG du coeur, qui remplace desormais la valeur 'fr' ecrite en dur dans la validation des soumissions.
  • Libelles multilingues sur la route du formulaire lui-meme. La v1.5.0 avait ouvert LocalizedString sur les routes de pages, de tableaux et de variables, mais pas sur POST /forms et PUT /forms/:id : name et description y etaient declares z.string(), donc un nom { fr, en } etait rejete alors que la lecture savait deja le restituer. Les deux champs acceptent desormais les deux formes et passent par le meme encodage que les autres colonnes localisees.
  • Test d'aller-retour. Un formulaire portant chaque propriete configurable du modele — conditions de page, de tableau et de variable, regles de validation, options, bornes de lignes, metadonnees a tous les niveaux, configuration linguistique, libelles multilingues — est enregistre puis relu et compare propriete par propriete a l'original. Seules deux tolerances : les identifiants sont reattribues par la base a l'ecriture, et une liste vide vaut une absence de liste.
  • Additif et retrocompatible : deux colonnes nullables, aucune colonne supprimee, aucune signature ni contrat existant modifie. Un formulaire enregistre avant ce lot reste lisible, ses colonnes nouvelles a NULL. Le controle d'unicite des codes intervient toujours avant la moindre ecriture — la persistance commence par un DELETE des pages. Appliquer prisma db push (ou une migration) pour creer les deux colonnes (form_rosters.collectionConfig et form_definitions.langConfig).

v1.5.0

  • Changement de comportement — les soumissions sont validees cote serveur. POST /submissions et PUT /submissions/:id serialisaient les donnees en JSON sans jamais les confronter a la definition du formulaire : l'API acceptait un dossier incomplet, un champ inconnu, une valeur hors bornes. Toute la validation reposait sur le navigateur, c'est-a-dire sur le client, qui ne prouve rien. Le controle s'appuie sur ValidationEngine et FormTree de @msbci/form-core — le meme moteur que le rendu, pour qu'aucune regle ne diverge entre l'ecran et le serveur — et parcourt toutes les pages visibles, pas seulement la derniere consultee. Les champs masques par une condition sont ecartes du resultat : le serveur n'est jamais plus severe que le navigateur. Le refus est un 400 dont le tableau errors nomme chaque champ en cause (CODE (ligne N) : message). Un brouillon (status: 'draft') reste accepte tel quel : il est normal qu'il soit incomplet, et les brouillons deja enregistres ne deviennent pas illisibles ; c'est la soumission definitive (submitted, approved, rejected, completed) qui doit etre complete. Un hote tiers qui dependrait de l'ancien comportement peut le retablir avec createFormRouter({ prisma, integrity: { validateSubmissions: false } }) — l'interrupteur est actif par defaut.
  • Suppression d'un formulaire portant des soumissions empechee. deleteFormDefinition etait une suppression sans garde, et la relation FormSubmission -> FormDefinition est en cascade : un appel effacait tous les dossiers deposes, valides compris, sans retour possible. La route renvoie desormais 409 avec le decompte par statut. Le refus est inconditionnel et sans option de forcage : une soumission est une donnee deposee par un tiers, qu'aucune API de conception de formulaire n'a vocation a detruire. Pour retirer un formulaire de la circulation, PUT /forms/:id avec isPublished: false suffit et preserve les dossiers.
  • Unicite du code de variable. Le schema posait une unicite sur le code d'une page et sur celui d'un tableau, mais pas sur celui d'une variable : deux champs pouvaient porter le meme code alors que le moteur indexe reponses et etats par code seul, faisant diverger conditions et visibilite en silence. Deux contraintes additives (@@unique([pageId, code]) et @@unique([rosterId, code])) et un controle a l'ecriture a l'echelle du formulaire (les NULL etant distincts en SQL, la base seule ne couvre pas les collisions entre deux pages). L'arbre de pages envoye par l'editeur est verifie avant la moindre ecriture : la persistance commence par un DELETE, un refus en cours de route laisserait le formulaire ampute.
  • Detection prealable des collisions existantes. Les index d'unicite ne peuvent pas etre crees sur une base qui porte deja des doublons — la creation echoue, sans perte de donnees. GET /integrity/variable-codes (et FormService.findDuplicateVariableCodes()) liste les collisions par formulaire avant migration ; la requete SQL equivalente est documentee plus haut. Aucun renommage automatique n'est applique : renommer un code sans intention orphelinerait les reponses.
  • Renommage sur du code de variable, borne et propage. Le code etait pleinement mutable par l'API, sans controle d'unicite, sans reecriture des expressions qui le referencent, sans migration des reponses saisies. Il est desormais modifiable tant qu'aucune soumission n'existe — c'est la seule fenetre ou la reaffectation des reponses ne peut rien perdre — et refuse ensuite (409). Tant qu'il est permis, les sept emplacements qui referencent un code sont reecrits dans tout le formulaire : conditions de page, de tableau et de variable, expression de champ calcule, variable pilote d'un tableau, comptage d'instances d'une page repetable, dependances de source de donnees, et gabarits de libelle (${CODE} dans un nom ou un libelle d'instance).
  • Libelles multilingues sur les routes granulaires. Les schemas Zod des routes de pages, de tableaux et de variables declaraient des chaines simples la ou le coeur attend un LocalizedString : un libelle { fr, en } etait rejete, alors que les routes de perimetre et de gabarit l'acceptaient deja. name, description, placeholder, repeatInstanceLabel, les libelles d'option et les messages de regle acceptent desormais les deux formes, sont stockes comme les autres colonnes localisees et relus tels quels.
  • Migration : prisma db push (ou une migration) pour creer les deux index d'unicite sur form_variables. Aucune colonne ajoutee, aucune colonne supprimee, aucun contrat existant modifie. Verifier GET /integrity/variable-codes au prealable.

v1.4.3

  • Persistance de fileConfig / imageConfig — l'editeur permet de configurer les contraintes d'upload d'une variable file (maxFiles, minFiles, accept) ou image (maxImages, minImages, allowCamera), mais le serveur ne les stockait pas : la table form_variables n'avait pas les colonnes correspondantes et les schemas Zod (mode strip) supprimaient silencieusement les deux cles. Consequence : apres enregistrement puis rechargement, la configuration disparaissait et le champ retombait en upload simple sans filtre. Ajout des colonnes nullables fileConfig et imageConfig (JSON serialise, meme pattern que options/validationRules/style), des schemas Zod correspondants et du mapping aller-retour (createFormVariable, updateFormVariable, extractVariableData, dbToVariable). Deux tests d'integration ajoutes (CRUD variable et sauvegarde du formulaire complet).
  • Persistance de fileConfig / imageConfig sur les modeles de variables — meme trou sur les templates reutilisables d'un scope : IVariableTemplate porte les deux objets, mais la table variable_templates n'avait pas les colonnes et les schemas Zod de creation/mise a jour ne les declaraient pas (la mise a jour, en mode strict, rejetait meme la cle). Un modele de variable file ou image perdait donc ses contraintes d'upload des sa creation dans la bibliotheque partagee. Ajout des colonnes nullables fileConfig et imageConfig, des schemas Zod correspondants et du mapping aller-retour (createTemplate, updateTemplate, dbToTemplate). Deux tests d'integration ajoutes (aller-retour creation/lecture/liste, et non-regression d'une mise a jour partielle qui omet la cle).
  • Additif et retrocompatible : colonnes nullables, aucune signature modifiee. Un hote qui n'utilise pas de champ fichier ne voit aucune difference. Appliquer prisma db push (ou une migration) pour creer les quatre colonnes (form_variables et variable_templates).
  • Resolution des modeles : le chemin resolu (GET /forms/:id/resolved) est assure par resolveFormTemplates dans @msbci/form-core, qui ne reportait pas fileConfig / imageConfig sur la variable resolue. C'est corrige a partir de @msbci/form-core v1.3.5 : les deux objets sont persistes et renvoyes par les endpoints de formulaires et de modeles, et desormais reportes sur une variable liee a un modele (surcharge locale prioritaire sur la valeur du modele).

v1.4.2

  • Import sans collision de codePOST /forms/import derivait un code identique a la source ; dans un tenant donne, la contrainte @@unique([code, version, tenantId]) etait violee (erreur 500 a la duplication d'un formulaire). L'import suffixe desormais le code (-COPY, -COPY-2, ...) lorsqu'il existe deja pour ce tenant. Sans tenant (NULL), le code est preserve (les NULL ne collisionnent pas en SQL) : comportement inchange. Test d'integration ajoute.

v1.4.1

  • Documentation : retrait d'une référence à un hôte spécifique dans le changelog au profit d'une formulation générique (l'application hôte). Aucun changement de code ni d'API.

v1.4.0

  • GET /forms/:id renvoie la forme éditable — la route retournait l'objet Prisma brut (pivot scopes, JSON sérialisé) au lieu du type domaine IFormDefinition. Conséquence : form.scopeIds était absent et les scopes liés disparaissaient au rechargement dans l'éditeur. La route renvoie désormais getEditableFormDefinition (= dbToFormDefinition sans résolution de templates) : scopeIds présents, JSON parsé, et templateId/templateOverrides conservés pour permettre l'édition (contrairement à /forms/:id/resolved qui fusionne les templates). Test d'intégration ajouté.
  • Aucun changement de schéma. Bump mineur car la forme de réponse de GET /forms/:id change (alignée sur le type documenté).

v1.3.3 – v1.3.5

  • Persistance profonde des pagesupdateFormDefinition (PUT du formulaire complet par l'éditeur visuel) fait un deep-upsert des pages, variables et rosters via persistPages, partagé avec importForm/createFormDefinition. Les pages ne se perdaient plus entre deux enregistrements.
  • Champs de mise en pageFormVariable.startWithNewLine Boolean? et colSpan Int? (colonnes nullables, additives) persistés et relus (extractVariableData/dbToVariable).
  • Écritures multi-tenant sûres — le tenantId des formulaires (et, côté application hôte, des scopes/templates) est dérivé du contexte de la requête plutôt que de la confiance au client.
  • Tolérance aux champs optionnels à null côté hôte et republication corrigeant le protocole workspace:* dans le tarball publié (installation autonome via pnpm publish).

v1.3.2

  • DataSourceConfig CRUD — nouvelle table data_source_configs (@@unique([code, scopeId]), onDelete: Cascade depuis Scope). 6 nouveaux endpoints REST :
    • GET / POST /scopes/:scopeId/datasources
    • GET / PUT / DELETE /datasources/:id
    • GET /forms/:formId/datasourcesendpoint agrégé : renvoie { items: IDataSourceConfig[], scopeHeaders: IScopeHeaders[] } (configs + headers décryptés des scopes du form, tous en un seul appel).
  • Chiffrement AES-256-GCM des headers admin via src/utils/encryption.ts. Format base64(iv):base64(authTag):base64(ciphertext), IV 12 bytes (recommandé GCM), auth tag 16 bytes.
    • MOSOBI_ENCRYPTION_KEY (32 bytes en base64 ou 64 chars hex) est requis pour écrire des headers : requireEncryption() lève 503 Service Unavailable sinon.
    • Lecture tolérante : si la clé manque, decrypt() renvoie {} avec un warning console — la lecture n'est jamais bloquée (la form reste rendable).
  • Headers de Scope : Scope.headers String? (chiffré JSON). Surfacé décrypté dans IScope.headers sur GET. Mergés avec IDataSourceConfig.headersOverride côté renderer (config gagne).
  • Suppression d'un scope refusée en 409 si au moins une DataSourceConfig le référence (en plus des forms et templates existants).
  • Nouveau helper ApiError.serviceUnavailable(message) pour les 503.
  • Migration Postgres 20260524000000_datasource_config fournie pour apps/demo.
  • 9 nouveaux tests d'intégration (54 total) : CRUD complet, dédup code, agrégation par formId, refus 503 sans clé, chiffrement effectif en base (le bearer n'apparaît jamais en clair), suppression scope bloquée par data source.
  • Tests serveur passent en fileParallelism: false pour éviter les courses sur la même test.db.

v1.3.1

  • Multi-scopes par formulaire — la colonne FormDefinition.scopeId est remplacée par une table pivot FormDefinitionScope (formId, scopeId, order) avec onDelete: Cascade de chaque côté. L'ordre dans le pivot porte la priorité des scopes.
  • POST /forms et PUT /forms/:id acceptent désormais scopeIds: string[] (validé par Zod). Le payload scopeId n'est plus reconnu.
  • GET /forms?scopeId=X filtre via scopes: { some: { scopeId } } (transparent pour le client).
  • GET /forms/:id et GET /forms/:id/resolved retournent scopeIds triés par order asc.
  • dbToFormDefinition extrait scopeIds depuis la relation scopes ordonnée.
  • Migration Postgres 20260523000000_scope_pivot fournie pour apps/demo (drop column → create pivot + indexes + FKs). Côté serveur (SQLite test) : prisma db push synchronise le schéma au démarrage des tests.
  • Suppression d'un scope refusée en 409 si au moins une ligne pivot le référence (utilise formDefinitionScope.count).
  • 2 nouveaux tests d'intégration (45 total) : préservation de l'ordre des scopes sur GET /forms/:id/resolved, remplacement complet de la liste sur PUT /forms/:id.

v1.3.0

  • Scope : CRUD complet (/scopes) + multi-tenant via tenantId @default("default")
  • VariableTemplate : CRUD complet (/scopes/:scopeId/templates, /templates/:id) — code et name immutables (enforced Zod .strict() + service)
  • GET /forms/:id/resolved — formulaire résolu via Option B (templates appliqués in-memory)
  • 10 nouveaux endpoints REST (/scopes, /scopes/:id, /scopes/:scopeId/templates, /templates/:id, /forms/:id/resolved)
  • Suppression scope refusée en 409 si forms OU templates rattachés
  • Tenant default créé automatiquement au boot via initializeDefaults (idempotent, WeakSet<PrismaClient>)
  • FormDefinition.scopeId + FormVariable.templateId / templateOverrides (champs Prisma)
  • 10 nouveaux tests d'intégration (43 total)

v1.2.0

  • LocalizedString sérialisé en JSON côté persistence
  • Compatible avec les schémas multilingues @msbci/form-core v1.2.0
  • No breaking changes

What's new in v1.1.0

  • Version aligned with @msbci/form-core v1.1.0. The server transparently persists the extended IFieldResponseMetadata (8 new optional fields including displayValue, variableLabel, page / roster context) when host applications forward responses from FormRenderer — no API or schema change required.
  • No breaking change. Drop-in upgrade from v1.0.x.

License

Copyright (c) 2026 MOSOBI — All rights reserved. Commercial license required. Contact: [email protected]