🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

system-definition

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

system-definition - npm Package Compare versions

Comparing version
0.0.1
to
0.0.2
+6
.c8rc.json
{
"all": true,
"src": ["dist/src", "dist/examples"],
"exclude": ["dist/test/**", "**/*.d.ts"],
"reporter": ["text", "html"]
}
root = true
# Defaults for every file
[*]
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
# Normalize to LF in the repo on commit; check out with the OS-native EOL.
* text=auto
{
"window.title": "🍃 ${dirty}${rootName}${separator}${activeEditorMedium}",
"editor.renderWhitespace": "selection",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"files.encoding": "utf8"
}
# system-design
Parte descriptiva del framework SSOTIGAD (Single Source Of Truth Implies Good Application Design).
El documento de diseño conceptual está en [SSOTIGAD.md](https://github.com/codenautas/ideas/blob/SSOTIGAD/src/SSOTIGAD.md).
Este módulo provee el vocabulario para describir sistemas (tipos de dominio, entidades, campos,
procedimientos, etc.) de modo que generadores automáticos o implementaciones on-the-fly puedan
derivar los scripts de creación de tablas, los endpoints CRUD con su capa de base de datos,
las pantallas del frontend, los serializadores en ambos sentidos y los validadores de tipo.
Este módulo cubre **solo la parte descriptiva**: no genera nada.
## Forma de trabajo
* Avanzamos de a pasos chicos, guiados por el programador. Acordar antes de programar.
* Enfoque TDD: primero el test que muestra el problema. Mostrar los rojos (errores de
compilación o tests fallando) y **esperar la revisión del programador antes de corregir**.
* Los tests de tipos no deben ser flojos: probar asignabilidad **en ambos sentidos**, y
también los rechazos con `// @ts-expect-error` (que no se pueda asignar un valor de un
tipo que no corresponde, ni acceder a campos que no existen en la definición).
* Código e identificadores dentro de `src` y `examples` en inglés.
* Los planes y este archivo, en castellano.
* Documentación multilingüe con la herramienta `multilang` (disponible en el PATH):
la fuente es `LEEME.md` en castellano; `README.md` en inglés se genera con `multilang LEEME.md`.
No editar `README.md` a mano.
## Estructura
* `src/common`: el framework descriptor. No conoce ningún sistema concreto.
(Más adelante podrían aparecer `src/backend` y `src/frontend`, o aplanarse todo a `src` si no hacen falta.)
* `examples/common`: un sistema de ejemplo (sistema de alumnos) descripto con el framework.
* `test/`: tests con mocha que importan las definiciones de los ejemplos (los ejemplos implican tests).
`npm test` compila con tsc y corre mocha sobre `dist/test/`; no se usa ts-node ni loaders.
## Herramientas
* TypeScript 7 (el compilador nativo). Que quitó
`baseUrl` y `moduleResolution: node`, y ya no incluye los `@types` automáticamente
(van listados en `types` del tsconfig).
* Cobertura: c8 (`npm run test-cov`, configuración en `.c8rc.json`). Ojo: los tests que solo
verifican tipos no cargan nada en runtime (tsc elide los imports usados solo en posiciones
de tipo), así que la cobertura es 0% hasta que haya comportamiento runtime que ejercitar;
por eso `all: true`, para que los archivos aparezcan igual en el reporte.
(Esa elisión fue también la causa de que nyc reportara vacío: no era un bug de nyc.)
## Decisiones de diseño acordadas
* TypeScript estricto, sin `any`.
* Las descripciones son valores TypeScript fuertemente tipados y **serializables** (representables
como JSON plano, sin funciones embebidas). Los comportamientos especiales se referencian por
nombre y se resuelven contra implementaciones registradas aparte.
* Los tipos de dominio (por ejemplo "Edad", "Legajo") los define cada sistema (en `examples`),
no el framework. El framework provee el mecanismo para definirlos.
* Del valor de una definición se deriva el tipo estático correspondiente (por ejemplo, el tipo
de una fila de la entidad), sin escribir los campos dos veces: preservación del tipado
de compile-time a runtime.
* La validación estructural de las descripciones (FK que apuntan a entidades existentes,
PK sobre campos declarados, etc.) se expresa preferentemente en el sistema de tipos.
## Convención de nombres: Def e Info
Para cada concepto descriptivo hay al menos dos versiones, distinguidas por sufijo:
* `XxxDef` (definition): lo que escribe el humano. Contiene solo lo mínimo necesario para
tener sentido semántico; todo lo que tiene un default razonable se puede omitir
(por ejemplo, si un campo es nulleable o no).
* `XxxInfo`: lo que produce el framework completando la Def con los defaults. Ahí está todo
explícito; es lo que consumen los generadores.
Ambas versiones son serializables. La Info se deriva determinísticamente de la Def.
Nombres ya elegidos:
* La descripción del registro de una entidad (el elemento fundamental) es `RecordDef` / `RecordInfo`.
El identificador pelado `Record` no se usa nunca, para no competir con el tipo utilitario
`Record<K, V>` de TypeScript.
* La descripción de un campo es `FieldDef` / `FieldInfo`. `RecordDef` es el mapa de campos:
`Record<string, FieldDef>`.
* `EntityDef` es el nivel contenedor (la unidad representable como grilla, como la llama el
documento SSOTIGAD): `{pk, fields}` donde `fields` es un `RecordDef`; ahí se irán agregando
foreign keys, subgrillas, título, etc. Se construye con `defineEntity`, que chequea en
compilación que los elementos de `pk` sean keys de `fields` (funciona con PK compuesta)
y preserva los literales (parámetros de tipo `const`).
* Convención de nombres en los sistemas de ejemplo: el record en singular, la entidad en
plural (`docente` es el `RecordDef`, `docentes` es la entity que lo envuelve).
* `PkFieldsOf<TEntityDef>` / `extractPk(entityDef)`: los campos de la pk como `RecordDef`
tipado exacto, para heredarlos con spread en otra entidad
(`fields: {...extractPk(cursos), orden: ...}` — la repetición semántica buena del documento).
* `MergedPk<TPks>` / `mergePk(...pks)`: une pks que se superponen sin repetir elementos,
deduplicando también a nivel de tipos (tupla recursiva), preservando el orden de primera
aparición. Es para pks combinadas (`presencias.pk = mergePk(inscripciones.pk, clases.pk)`);
para los `fields` no hace falta: el spread ya deduplica keys solo.
* `isName?: true` en `FieldDef` (solo `true`, así el literal sobrevive al `satisfies`);
`completeRecord` lo completa a `false` en la Info.
* `EntityDef` tiene además `uks` (uniques con nombre: `{denominacion: ['denominacion']}`) y
`fks`. Una `FkDef` es `{entity, fields}` donde `entity` es el **nombre** de la entidad
destino (string, no el objeto: mantiene la serializabilidad y permite fks circulares y
reflexivas), y `fields` tiene dos formas: array de nombres cuando origen y destino se
llaman igual (`fields: cursos.pk`), o mapa `{origen: 'destino'}` cuando no
(`{jefe: 'docente'}`). La key del mapa de `fks` es el nombre de la fk (permite dos fks a
la misma entidad: `presidente` y `vocal` → docentes).
* Los chequeos de fks tienen dos niveles: `defineEntity` chequea lo local (campos origen y
de uks existen en `fields`); `defineEntities(entityDefs)` chequea lo global del sistema
(la entidad destino existe, y los campos destino son su pk completa o una de sus uks).
El error de `defineEntities` es críptico (mapped type a `never`), pero señala la fk mala.
* `RecordInfoOf<TRecordDef>`: la Info precisa que corresponde a una Def concreta (conserva
las claves y los literales de `type`); es lo que devuelve `completeRecord`. El sufijo `Of`
marca "tipo derivado de una definición concreta".
/* EJEMPLO del sistema de alumnos */
import {
boxType, commonTypeDefs,
RecordDef, defineEntity, defineEntities, extractPk, mergePk
} from "../../src/common/system-design";
type Fecha = {año: number, mes: number, día:number}
export var typeDefs = {
...commonTypeDefs,
fecha: {tsType: boxType<Fecha>()},
email: commonTypeDefs.text
}
type RecordsDef = RecordDef<typeof typeDefs>
export const cargo = {
cargo : {type: 'text' },
denominacion : {type: 'text' , label:'denominación'},
orden : {type: 'integer'},
puede_dirigir : {type: 'boolean'},
} satisfies RecordsDef
export const materia = {
materia : {type: 'text' },
denominacion : {type: 'text' , label:'denominación', nullable: false, isName: true, description: 'si corresponde a más de una carrera, aclarar en el nombre'},
} satisfies RecordsDef
export const docente = {
docente : {type: 'text' },
apellido : {type: 'text' , nullable:false},
nombres : {type: 'text' , nullable:false},
cargo : {type: 'text' },
email : {type: 'email'},
email_alternativo: {type: 'email'},
jefe : {type: 'text' , description: 'jefe de cátedra (otro docente)'},
} satisfies RecordsDef
export const asignacion = {
docente: docente.docente,
materia: materia.materia,
cargo : cargo.cargo,
} satisfies RecordsDef
export const periodo = {
periodo : {type: 'text' , description: 'bimestre, cuatrimestre, etc...'},
} satisfies RecordsDef
/* entities: plural names wrap the singular record defs */
export const docentes = defineEntity({
pk: ['docente'],
// reflexive fk: inside its own definition the entity is referenced by name,
// and the source field (jefe) is mapped to the target field (docente)
fks: {jefe: {entity: 'docentes', fields: {jefe: 'docente'}}},
fields: docente,
})
export const materias = defineEntity({
pk: ['materia'],
uks: {denominacion: ['denominacion']},
fields: materia,
})
export const periodos = defineEntity({pk: ['periodo'], fields: periodo})
export const curso = {
...extractPk(periodos),
...extractPk(materias),
...extractPk(docentes), // docente responsable del curso
} satisfies RecordsDef
export const cursos = defineEntity({
pk: ['periodo', 'materia'],
fks: {
periodos : {entity: 'periodos', fields: periodos.pk},
materias : {entity: 'materias', fields: materias.pk},
responsable: {entity: 'docentes', fields: docentes.pk},
},
fields: curso,
})
export const clase = {
...extractPk(cursos),
orden : {type: 'integer'},
fecha : {type: 'fecha' },
tema : {type: 'text' },
} satisfies RecordsDef
export const clases = defineEntity({
pk: [...cursos.pk, 'orden'],
fks: {cursos: {entity: 'cursos', fields: cursos.pk}},
fields: clase,
})
export const alumno = {
alumno : {type: 'text' },
apellido : {type: 'text' , nullable:false},
nombres : {type: 'text' , nullable:false},
email : {type: 'email'},
} satisfies RecordsDef
export const alumnos = defineEntity({pk: ['alumno'], fields: alumno})
export const pregunta = {
...extractPk(clases),
pregunta : {type: 'integer'},
formulacion : {type: 'text' , nullable:false, label: 'formulación', description: 'texto principal de la pregunta'},
aclaraciones : {type: 'text' , description: 'texto que no necesita repetirse cuando se quiera referir a una pregunta por su formulación, pero que es necesario para aclarar el contexto o posibles ambigüedades de la pregunta'},
tipo_respuesta : {type: 'text' , nullable:false, label: 'tipo'}
} satisfies RecordsDef
export const preguntas = defineEntity({
pk: [...clases.pk, 'pregunta'],
fks: {clases: {entity: 'clases', fields: clases.pk}},
fields: pregunta,
})
export const opcion = {
...extractPk(preguntas),
opcion : {type: 'text' },
detalle : {type: 'text' },
} satisfies RecordsDef
export const opciones = defineEntity({
pk: [...preguntas.pk, 'opcion'],
fks: {preguntas: {entity: 'preguntas', fields: preguntas.pk}},
fields: opcion,
})
export const inscripcion = {
...extractPk(cursos),
...extractPk(alumnos),
} satisfies RecordsDef
export const inscripciones = defineEntity({
pk: [...cursos.pk, 'alumno'],
fks: {
cursos : {entity: 'cursos' , fields: cursos.pk},
alumnos: {entity: 'alumnos', fields: alumnos.pk},
},
fields: inscripcion,
})
/* combined pk: inscripciones and clases share periodo and materia, no repetition;
periodo and materia belong to both fks */
export const presencia = {
...extractPk(inscripciones),
...extractPk(clases),
} satisfies RecordsDef
export const presencias = defineEntity({
pk: mergePk(inscripciones.pk, clases.pk),
fks: {
inscripciones: {entity: 'inscripciones', fields: inscripciones.pk},
clases : {entity: 'clases' , fields: clases.pk},
},
fields: presencia,
})
/* two fks to the same entity, renaming the fields */
export const mesa = {
...extractPk(cursos),
fecha : {type: 'fecha'},
presidente : {type: 'text' },
vocal : {type: 'text' },
} satisfies RecordsDef
export const mesas = defineEntity({
pk: [...cursos.pk, 'fecha'],
fks: {
cursos : {entity: 'cursos' , fields: cursos.pk},
presidente: {entity: 'docentes', fields: {presidente: 'docente'}},
vocal : {entity: 'docentes', fields: {vocal: 'docente'}},
},
fields: mesa,
})
export const recordDefs = {
cargo,
docente,
materia,
asignacion,
periodo,
curso,
clase,
alumno,
pregunta,
opcion,
inscripcion,
presencia,
mesa,
}
export const entityDefs = defineEntities({
docentes,
materias,
periodos,
cursos,
clases,
alumnos,
preguntas,
opciones,
inscripciones,
presencias,
mesas,
})
// Placeholder: the example system (students) described with the framework will grow here step by step.
export {};
# system-design
<!--lang:es-->
Capa descriptiva para sistemas diseñados alrededor de una única fuente de verdad (SSOT).
<!--lang:en--]
Descriptive layer for systems designed around a Single Source of Truth (SSOT).
[!--lang:*-->
<!--multilang v0 es:LEEME.md en:README.md -->
<!--multilang buttons-->
idioma: ![castellano](https://raw.githubusercontent.com/codenautas/multilang/master/img/lang-es.png)
también disponible en:
[![inglés](https://raw.githubusercontent.com/codenautas/multilang/master/img/lang-en.png)](README.md)
<!--lang:es-->
## Objetivo
Este módulo provee el vocabulario para describir un sistema — tipos de dominio, entidades,
campos, procedimientos — como valores fuertemente tipados y serializables. A partir de esas
descripciones, generadores de código o implementaciones on-the-fly pueden derivar los scripts
de creación de tablas, los endpoints CRUD con su capa de base de datos, las pantallas del
frontend, los serializadores en ambos sentidos, los validadores de tipo, etc.
Este módulo cubre solo la parte descriptiva de los sistemas: no genera nada por sí mismo.
<!--lang:en--]
## Goal
This module provides the vocabulary to describe a system — domain types, entities, fields,
procedures — as strongly typed, serializable values. From those descriptions, code generators
or on-the-fly implementations can derive the table creation scripts, the CRUD endpoints with
their database layer, the frontend screens, the serializers in both directions, the type
validators, and so on.
This module covers only the descriptive part of systems: it does not generate anything itself.
[!--lang:es-->
## Convención de nombres: Def e Info
Cada concepto descriptivo tiene (al menos) dos versiones, distinguidas por sufijo:
* `XxxDef` (definition): lo que escribe el humano. Contiene solo lo mínimo con sentido
semántico; lo que tiene un default razonable se puede omitir.
* `XxxInfo`: lo que produce el framework al completar la `Def` con los defaults. Ahí está
todo explícito; es lo que consumen los generadores.
La `Info` se deriva determinísticamente de la `Def`, y ambas son serializables (representables
como JSON plano, sin funciones embebidas).
<!--lang:en--]
## Naming convention: Def and Info
Every descriptive concept has (at least) two versions, distinguished by a suffix:
* `XxxDef` (definition): what the human writes. It contains only the minimum with semantic
meaning; anything with a sensible default can be omitted.
* `XxxInfo`: what the framework produces by completing the `Def` with the defaults. Everything
is explicit there; it's what the generators consume.
The `Info` is derived deterministically from the `Def`, and both are serializable (representable
as plain JSON, with no embedded functions).
[!--lang:es-->
## Vocabulario
### Tipos de dominio
Cada sistema define su propia colección de tipos (`TypeCollection`), asociando un nombre de
tipo (por ejemplo `"texto"`, `"legajo"`) con el tipo TypeScript que le corresponde en
tiempo de ejecución. El framework aporta unos pocos tipos comunes (`text`, `integer`,
`boolean`) como punto de partida; cada sistema puede agregar los suyos (en el ejemplo,
`fecha`, `email`).
### Campos: `FieldDef` / `FieldInfo`
Un campo se describe con `FieldDef`: el `type` (una key de la `TypeCollection`) y,
opcionalmente, `label`, `nullable` y `description`. `completeRecord` produce el `FieldInfo`
correspondiente, con esos tres campos siempre presentes (defaults: `label` derivado del
nombre, `nullable: true`, `description: ''`), preservando el literal de `type`.
### Records: `RecordDef` / `RecordInfo`
Un `RecordDef` es simplemente un mapa de campos (`Record<string, FieldDef>`): la descripción
de una fila. `RecordInfoOf<TRecordDef>` es el tipo exacto de `Info` que corresponde a un
`RecordDef` concreto — conserva las claves y los literales de `type` de cada campo — y es lo
que devuelve `completeRecord`.
`RecordInstanceType<TTypeCollection, TRecordDef>` deduce, a partir de un `RecordDef` y la
`TypeCollection` del sistema, el tipo TypeScript de una instancia real de ese record (los
valores que tomaría cada campo en tiempo de ejecución).
### Entidades: `EntityDef`
`EntityDef` es el nivel contenedor — la unidad representable como grilla — con la forma
`{pk, fields}`, donde `fields` es un `RecordDef` y `pk` es la tupla de nombres de campo que
forman la clave primaria (admite pk compuesta). Se construye con `defineEntity`, que
chequea en tiempo de compilación que cada elemento de `pk` sea una key de `fields`, y
preserva los literales (`pk` queda tipado como tupla exacta, no como `string[]`).
### Reutilización de claves: `extractPk` / `mergePk`
* `extractPk(entityDef)` devuelve los campos de la pk de una entidad como un `RecordDef` con
el tipo exacto (`PkFieldsOf<TEntityDef>`), para heredarlos por spread en otra entidad
(por ejemplo, `curso` hereda las pk de `periodos`, `materias` y `docentes`). Para el resto
de los campos no hace falta una función especial: el spread de objetos ya deduplica keys.
* `mergePk(...pks)` une varias pk que pueden superponerse, sin repetir elementos y
deduplicando también a nivel de tipos (preserva el orden de primera aparición). Se usa
para pks combinadas, como la de `presencias`, que junta las de `inscripciones` y `clases`.
<!--lang:en--]
## Vocabulary
### Domain types
Each system defines its own type collection (`TypeCollection`), associating a type name
(e.g. `"text"`, `"student id"`) with the TypeScript type it maps to at runtime. The
framework provides a few common types (`text`, `integer`, `boolean`) as a starting point;
each system can add its own (in the example, `fecha` — date — and `email`).
### Fields: `FieldDef` / `FieldInfo`
A field is described with `FieldDef`: its `type` (a key of the `TypeCollection`) and,
optionally, `label`, `nullable` and `description`. `completeRecord` produces the
corresponding `FieldInfo`, with those three fields always present (defaults: `label`
derived from the field name, `nullable: true`, `description: ''`), preserving the `type`
literal.
### Records: `RecordDef` / `RecordInfo`
A `RecordDef` is simply a map of fields (`Record<string, FieldDef>`): the description of a
row. `RecordInfoOf<TRecordDef>` is the exact `Info` type that corresponds to a concrete
`RecordDef` — it keeps the keys and the `type` literal of each field — and it's what
`completeRecord` returns.
`RecordInstanceType<TTypeCollection, TRecordDef>` deduces, from a `RecordDef` and the
system's `TypeCollection`, the TypeScript type of an actual instance of that record (the
values each field would hold at runtime).
### Entities: `EntityDef`
`EntityDef` is the container level — the grid-representable unit — shaped as
`{pk, fields}`, where `fields` is a `RecordDef` and `pk` is the tuple of field names that
make up the primary key (composite keys are supported). It's built with `defineEntity`,
which checks at compile time that every element of `pk` is a key of `fields`, and preserves
the literals (`pk` ends up typed as an exact tuple, not as `string[]`).
### Reusing keys: `extractPk` / `mergePk`
* `extractPk(entityDef)` returns an entity's pk fields as a `RecordDef` with the exact type
(`PkFieldsOf<TEntityDef>`), so they can be inherited by spreading them into another entity
(for example, `curso` — course — inherits the pks of `periodos`, `materias` and
`docentes`). The rest of the fields need no special function: spreading objects already
dedups keys.
* `mergePk(...pks)` merges several pks that may overlap, without repeating elements and
deduplicating at the type level too (preserving the order of first appearance). It's used
for combined pks, like the one for `presencias` (attendance), which joins the pks of
`inscripciones` (enrollments) and `clases` (classes).
[!--lang:es-->
## Ejemplo: sistema de alumnos
`examples/common/aida.ts` describe un sistema de alumnos con este vocabulario. Incluye
entidades independientes (`docentes`, `materias`, `periodos`, `alumnos`) y entidades que
heredan claves de otras:
* `cursos` hereda las pk de `periodos`, `materias` y `docentes` (el docente responsable).
* `clases` extiende la pk de `cursos` agregando `orden`.
* `preguntas` extiende la pk de `clases` agregando `pregunta`, y `opciones` extiende la de
`preguntas` agregando `opcion` (encadenamiento de herencia de pk en varios niveles).
* `inscripciones` hereda las pk de `cursos` y `alumnos`.
* `presencias` combina, con `mergePk`, las pk de `inscripciones` y `clases`, que comparten
`periodo` y `materia`: esos campos no se repiten.
Los tests en `test/aida-test.ts` importan estas definiciones y verifican, para cada tramo
del vocabulario, tanto la asignabilidad en ambos sentidos (una `Info` esperada escrita a
mano y la deducida deben poder asignarse mutuamente) como los rechazos esperados en
compilación (con `// @ts-expect-error`): pk con campos inexistentes, acceso a campos que
la entidad no tiene, reasignación de un `type` literal, etc.
<!--lang:en--]
## Example: student system
`examples/common/aida.ts` describes a student system using this vocabulary. It includes
independent entities (`docentes` — instructors, `materias` — subjects, `periodos` — terms,
`alumnos` — students) and entities that inherit keys from others:
* `cursos` (courses) inherits the pks of `periodos`, `materias` and `docentes` (the
instructor in charge of the course).
* `clases` (classes) extends the `cursos` pk by adding `orden` (sequence number).
* `preguntas` (questions) extends the `clases` pk by adding `pregunta`, and `opciones`
(options) extends the `preguntas` pk by adding `opcion` (pk inheritance chained across
several levels).
* `inscripciones` (enrollments) inherits the pks of `cursos` and `alumnos`.
* `presencias` (attendance) combines, with `mergePk`, the pks of `inscripciones` and
`clases`, which share `periodo` and `materia`: those fields aren't repeated.
The tests in `test/aida-test.ts` import these definitions and check, for each part of the
vocabulary, both two-way assignability (a hand-written expected `Info` and the deduced one
must be mutually assignable) and the expected compile-time rejections (with
`// @ts-expect-error`): a pk with nonexistent fields, accessing a field the entity doesn't
have, reassigning a literal `type`, and so on.
[!--lang:es-->
## Estructura
* `src/common`: el framework descriptor; no conoce ningún sistema concreto.
* `examples/common`: un sistema de ejemplo (sistema de alumnos) descripto con el framework.
* `test/`: tests con mocha que importan las definiciones de los ejemplos (los ejemplos
implican tests).
<!--lang:en--]
## Structure
* `src/common`: the descriptive framework; it knows nothing about any concrete system.
* `examples/common`: an example system (a students system) described with the framework.
* `test/`: mocha tests that import the example definitions (the examples double as tests).
[!--lang:es-->
## Forma de trabajo
Enfoque TDD, avanzando de a pasos chicos: primero el test que muestra el problema, después
la implementación mínima que lo hace pasar. Los tests de tipos no son flojos: prueban
asignabilidad en ambos sentidos y también los rechazos esperados con `// @ts-expect-error`.
`npm test` compila con TypeScript y corre mocha sobre el resultado compilado (no se usa
ts-node ni loaders).
<!--lang:en--]
## Way of working
TDD approach, moving forward in small steps: first the test that shows the problem, then
the minimal implementation that makes it pass. Type tests aren't loose: they check
assignability in both directions and also the expected rejections with
`// @ts-expect-error`.
`npm test` compiles with TypeScript and runs mocha over the compiled output (no ts-node,
no loaders).
[!--lang:es-->
## Estado
En etapa de diseño.
<!--lang:en--]
## Status
Design stage.
[!--lang:*-->
<!--lang:es-->
## Licencia
<!--lang:en--]
## License
[!--lang:*-->
[MIT](LICENSE)
MIT License
Copyright (c) 2026 ari.dc.uba.ar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# system-design
Descriptive layer for systems designed around a Single Source of Truth (SSOT).
language: ![English](https://raw.githubusercontent.com/codenautas/multilang/master/img/lang-en.png)
also available in:
[![Spanish](https://raw.githubusercontent.com/codenautas/multilang/master/img/lang-es.png)](LEEME.md)
## Goal
This module provides the vocabulary to describe a system — domain types, entities, fields,
procedures — as strongly typed, serializable values. From those descriptions, code generators
or on-the-fly implementations can derive the table creation scripts, the CRUD endpoints with
their database layer, the frontend screens, the serializers in both directions, the type
validators, and so on.
This module covers only the descriptive part of systems: it does not generate anything itself.
## Naming convention: Def and Info
Every descriptive concept has (at least) two versions, distinguished by a suffix:
* `XxxDef` (definition): what the human writes. It contains only the minimum with semantic
meaning; anything with a sensible default can be omitted.
* `XxxInfo`: what the framework produces by completing the `Def` with the defaults. Everything
is explicit there; it's what the generators consume.
The `Info` is derived deterministically from the `Def`, and both are serializable (representable
as plain JSON, with no embedded functions).
## Vocabulary
### Domain types
Each system defines its own type collection (`TypeCollection`), associating a type name
(e.g. `"text"`, `"student id"`) with the TypeScript type it maps to at runtime. The
framework provides a few common types (`text`, `integer`, `boolean`) as a starting point;
each system can add its own (in the example, `fecha` — date — and `email`).
### Fields: `FieldDef` / `FieldInfo`
A field is described with `FieldDef`: its `type` (a key of the `TypeCollection`) and,
optionally, `label`, `nullable` and `description`. `completeRecord` produces the
corresponding `FieldInfo`, with those three fields always present (defaults: `label`
derived from the field name, `nullable: true`, `description: ''`), preserving the `type`
literal.
### Records: `RecordDef` / `RecordInfo`
A `RecordDef` is simply a map of fields (`Record<string, FieldDef>`): the description of a
row. `RecordInfoOf<TRecordDef>` is the exact `Info` type that corresponds to a concrete
`RecordDef` — it keeps the keys and the `type` literal of each field — and it's what
`completeRecord` returns.
`RecordInstanceType<TTypeCollection, TRecordDef>` deduces, from a `RecordDef` and the
system's `TypeCollection`, the TypeScript type of an actual instance of that record (the
values each field would hold at runtime).
### Entities: `EntityDef`
`EntityDef` is the container level — the grid-representable unit — shaped as
`{pk, fields}`, where `fields` is a `RecordDef` and `pk` is the tuple of field names that
make up the primary key (composite keys are supported). It's built with `defineEntity`,
which checks at compile time that every element of `pk` is a key of `fields`, and preserves
the literals (`pk` ends up typed as an exact tuple, not as `string[]`).
### Reusing keys: `extractPk` / `mergePk`
* `extractPk(entityDef)` returns an entity's pk fields as a `RecordDef` with the exact type
(`PkFieldsOf<TEntityDef>`), so they can be inherited by spreading them into another entity
(for example, `curso` — course — inherits the pks of `periodos`, `materias` and
`docentes`). The rest of the fields need no special function: spreading objects already
dedups keys.
* `mergePk(...pks)` merges several pks that may overlap, without repeating elements and
deduplicating at the type level too (preserving the order of first appearance). It's used
for combined pks, like the one for `presencias` (attendance), which joins the pks of
`inscripciones` (enrollments) and `clases` (classes).
## Example: student system
`examples/common/aida.ts` describes a student system using this vocabulary. It includes
independent entities (`docentes` — instructors, `materias` — subjects, `periodos` — terms,
`alumnos` — students) and entities that inherit keys from others:
* `cursos` (courses) inherits the pks of `periodos`, `materias` and `docentes` (the
instructor in charge of the course).
* `clases` (classes) extends the `cursos` pk by adding `orden` (sequence number).
* `preguntas` (questions) extends the `clases` pk by adding `pregunta`, and `opciones`
(options) extends the `preguntas` pk by adding `opcion` (pk inheritance chained across
several levels).
* `inscripciones` (enrollments) inherits the pks of `cursos` and `alumnos`.
* `presencias` (attendance) combines, with `mergePk`, the pks of `inscripciones` and
`clases`, which share `periodo` and `materia`: those fields aren't repeated.
The tests in `test/aida-test.ts` import these definitions and check, for each part of the
vocabulary, both two-way assignability (a hand-written expected `Info` and the deduced one
must be mutually assignable) and the expected compile-time rejections (with
`// @ts-expect-error`): a pk with nonexistent fields, accessing a field the entity doesn't
have, reassigning a literal `type`, and so on.
## Structure
* `src/common`: the descriptive framework; it knows nothing about any concrete system.
* `examples/common`: an example system (a students system) described with the framework.
* `test/`: mocha tests that import the example definitions (the examples double as tests).
## Way of working
TDD approach, moving forward in small steps: first the test that shows the problem, then
the minimal implementation that makes it pass. Type tests aren't loose: they check
assignability in both directions and also the expected rejections with
`// @ts-expect-error`.
`npm test` compiles with TypeScript and runs mocha over the compiled output (no ts-node,
no loaders).
## Status
Design stage.
## License
[MIT](LICENSE)
// Placeholder: the descriptive framework (system-agnostic) will grow here step by step.
export {};
export const boxType = <T>() => null as T
export interface TypeDef<TsType> {
tsType: TsType
}
export type TypeCollection = Record<string , TypeDef<any>>
export const commonTypeDefs = {
text : {tsType: boxType<string>()},
integer : {tsType: boxType<number>()},
boolean : {tsType: boxType<boolean>()},
} satisfies TypeCollection;
export type FieldDef<TypeDefs extends TypeCollection = typeof commonTypeDefs> = {
type: keyof TypeDefs
isName?: true
nullable?: boolean
label?: string
description?: string
}
export type FieldInfo<TypeDefs extends TypeCollection = typeof commonTypeDefs> = Required<Omit<FieldDef<TypeDefs>, 'isName'>> & {isName: boolean}
export type RecordDef<TypeDefs extends TypeCollection = typeof commonTypeDefs> = Record<string, FieldDef<TypeDefs>>
// export type RecordInfo<TypeDefs extends TypeCollection = typeof commonTypeDefs> = Required<RecordDef<TypeDefs>>
export type RecordInfo<TypeDefs extends TypeCollection = typeof commonTypeDefs> = Record<string, FieldInfo<TypeDefs>>
export type RecordInfoOf<TRecordDef extends RecordDef<TypeCollection>> = {
[K in keyof TRecordDef]: FieldInfo<TypeCollection> & {type: TRecordDef[K]['type']}
}
export function completeRecord<TRecordDef extends RecordDef<TypeCollection>>(recordDef: TRecordDef): RecordInfoOf<TRecordDef>{
return Object.fromEntries(Object.entries(recordDef).map(([name, fieldDef]) => ([name, {
// @ts-expect-error type is specified because we need to guaranty the order in the completed type
type: null,
isName: false,
nullable: true,
label: name.replace(/_/g,' '),
description: '',
...fieldDef,
}]))) as RecordInfoOf<TRecordDef>;
}
export type RecordInstanceType<TTypeCollection extends TypeCollection, TRecordDef extends RecordDef<TTypeCollection>> = {
[K in keyof TRecordDef]: TTypeCollection[TRecordDef[K]['type']]['tsType']
}
/* fks reference the target entity BY NAME (a string, not the object): that keeps the defs
serializable and makes circular and reflexive fks representable. The counterpart is that
the target side can only be checked at the system level: see defineEntities. */
export type FkDef = {
entity: string
fields: readonly string[] | Readonly<Record<string, string>>
}
export type EntityDef<TypeDefs extends TypeCollection = typeof commonTypeDefs> = {
fields: RecordDef<TypeDefs>
pk: readonly string[]
fks?: Readonly<Record<string, FkDef>>
uks?: Readonly<Record<string, readonly string[]>>
}
export function defineEntity<
const TPk extends readonly (keyof TFields & string)[],
const TFields extends RecordDef<TypeCollection>,
const TUks extends Readonly<Record<string, readonly (keyof TFields & string)[]>> = {},
const TFks extends Readonly<Record<string, {entity: string, fields: readonly (keyof TFields & string)[] | {readonly [K in keyof TFields]?: string}}>> = {},
>(
entityDef: {fields: TFields, pk: TPk, fks?: TFks, uks?: TUks}
): {fields: TFields, pk: TPk, fks: TFks, uks: TUks} {
return {
fields: entityDef.fields,
pk: entityDef.pk,
fks: entityDef.fks ?? {} as TFks,
uks: entityDef.uks ?? {} as TUks,
};
}
export type PkFieldsOf<TEntityDef extends EntityDef<TypeCollection>> =
Pick<TEntityDef['fields'], TEntityDef['pk'][number] & keyof TEntityDef['fields']>
export function extractPk<TEntityDef extends EntityDef<TypeCollection>>(entityDef: TEntityDef): PkFieldsOf<TEntityDef> {
const fields: RecordDef<TypeCollection> = entityDef.fields;
return Object.fromEntries(entityDef.pk.map(name => [name, fields[name]])) as PkFieldsOf<TEntityDef>;
}
type FlattenPks<TPks extends readonly (readonly string[])[]> =
TPks extends readonly [infer THead extends readonly string[], ...infer TRest extends readonly (readonly string[])[]]
? readonly [...THead, ...FlattenPks<TRest>]
: readonly []
type DedupPk<TPk extends readonly string[], TSeen extends string = never> =
TPk extends readonly [infer THead extends string, ...infer TRest extends readonly string[]]
? THead extends TSeen
? DedupPk<TRest, TSeen>
: readonly [THead, ...DedupPk<TRest, TSeen | THead>]
: readonly []
export type MergedPk<TPks extends readonly (readonly string[])[]> = DedupPk<FlattenPks<TPks>>
export function mergePk<const TPks extends readonly (readonly string[])[]>(...pks: TPks): MergedPk<TPks> {
const merged: string[] = [];
for (const pk of pks) {
for (const name of pk) {
if (!merged.includes(name)) merged.push(name);
}
}
return merged as unknown as MergedPk<TPks>;
}
/* the Info side of an entity: everything explicit, and in only one form. The fks lose the
array shorthand: fields is always the source → target map. The pk is deduplicated, so
overlapping pks can be spread in the Def without mergePk. */
export type FkInfo = {
entity: string
fields: Readonly<Record<string, string>>
}
export type FkInfoOf<TFk extends FkDef> = {
entity: TFk['entity']
fields: TFk['fields'] extends readonly (infer TNames extends string)[] ? {[K in TNames]: K} : TFk['fields']
}
export type EntityInfo<TypeDefs extends TypeCollection = typeof commonTypeDefs> = {
fields: RecordInfo<TypeDefs>
pk: readonly string[]
fks: Readonly<Record<string, FkInfo>>
uks: Readonly<Record<string, readonly string[]>>
}
export type EntityInfoOf<TEntityDef extends EntityDef<TypeCollection>> = {
fields: RecordInfoOf<TEntityDef['fields']>
pk: DedupPk<TEntityDef['pk']>
fks: TEntityDef['fks'] extends Readonly<Record<string, FkDef>>
? {[F in keyof TEntityDef['fks']]: FkInfoOf<TEntityDef['fks'][F]>}
: {}
uks: TEntityDef['uks'] extends Readonly<Record<string, readonly string[]>> ? TEntityDef['uks'] : {}
}
function completeFk(fkDef: FkDef): FkInfo {
return {
entity: fkDef.entity,
fields: Array.isArray(fkDef.fields)
? Object.fromEntries(fkDef.fields.map(name => [name, name]))
: fkDef.fields,
};
}
export function completeEntity<const TEntityDef extends EntityDef<TypeCollection>>(entityDef: TEntityDef): EntityInfoOf<TEntityDef> {
return {
fields: completeRecord(entityDef.fields),
pk: mergePk(entityDef.pk),
fks: Object.fromEntries(Object.entries(entityDef.fks ?? {}).map(([name, fkDef]) => [name, completeFk(fkDef)])),
uks: entityDef.uks ?? {},
} as EntityInfoOf<TEntityDef>;
}
type SameKeySet<TA extends string, TB extends string> = [TA] extends [TB] ? ([TB] extends [TA] ? true : false) : false
type FkTargetFields<TFk extends FkDef> =
TFk['fields'] extends readonly string[] ? TFk['fields'][number]
: TFk['fields'] extends Readonly<Record<string, string>> ? TFk['fields'][keyof TFk['fields']]
: never
type FkMatchesTargetKey<TFk extends FkDef, TTarget extends EntityDef<TypeCollection>> =
SameKeySet<FkTargetFields<TFk>, TTarget['pk'][number]> extends true ? true
: true extends {[U in keyof NonNullable<TTarget['uks']>]: SameKeySet<FkTargetFields<TFk>, NonNullable<TTarget['uks']>[U][number]>}[keyof NonNullable<TTarget['uks']>] ? true
: false
type ValidatedFks<TFks extends Readonly<Record<string, FkDef>>, TEntities extends Readonly<Record<string, EntityDef<TypeCollection>>>> = {
[F in keyof TFks]: TFks[F]['entity'] extends keyof TEntities
? FkMatchesTargetKey<TFks[F], TEntities[TFks[F]['entity'] & keyof TEntities]> extends true
? TFks[F]
: never
: never
}
export type ValidatedEntities<TEntities extends Readonly<Record<string, EntityDef<TypeCollection>>>> = {
[E in keyof TEntities]: {fks?: ValidatedFks<NonNullable<TEntities[E]['fks']>, TEntities>}
}
/* system-level checks, where all the entities are known: every fk must point to an entity
of the system, and its target fields must be the complete pk or one of the uks of it */
export function defineEntities<const TEntities extends Readonly<Record<string, EntityDef<TypeCollection>>>>(
entityDefs: TEntities & ValidatedEntities<TEntities>
): TEntities {
return entityDefs;
}

Sorry, the diff of this file is not supported yet

import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import { encode } from "@toon-format/toon";
import { strict as LikeAr } from "like-ar";
import { RecordInstanceType, completeRecord, completeEntity, defineEntity, defineEntities, extractPk, mergePk,
EntityDef, TypeCollection
} from "../src/common/system-design";
import { typeDefs, cargo, materia, curso, clase, cursos, clases, opcion, opciones, inscripciones, presencia, presencias, docentes, materias, mesas, entityDefs } from "../examples/common/aida";
describe("aida example", function(){
it("deduces the record instance type", function(){
type Cargo = {
cargo : string,
denominacion : string,
orden : number,
puede_dirigir: boolean
}
type CargoDeducido = RecordInstanceType<typeof typeDefs, typeof cargo>
var jtp: Cargo = {
cargo : 'JTP',
denominacion : 'Jefe de Trabajos Prácticos',
orden : 4,
puede_dirigir: true,
}
// both assignments must compile: Cargo and CargoDeducido are mutually assignable
var cargoDeducido: CargoDeducido = jtp;
var obtained: Cargo = cargoDeducido;
assert.deepStrictEqual(cargoDeducido, jtp);
assert.deepStrictEqual(obtained, jtp);
})
it("completes a record def into a record info", function(){
var materiaInfo = completeRecord(materia);
assert.deepStrictEqual(materiaInfo, {
materia : {type: 'text', label: 'materia' , nullable: true , description: '', isName: false},
denominacion : {type: 'text', label: 'denominación', nullable: false, description: 'si corresponde a más de una carrera, aclarar en el nombre', isName: true},
});
})
it("completes preserving the field set and the type literals", function(){
var cargoInfo = completeRecord(cargo);
// the type literals from the def must survive the completion:
var cargoType: 'text' = cargoInfo.cargo.type;
// @ts-expect-error
cargoInfo.cargo.type = 'integer'
assert.equal(cargoType, 'text');
assert.throws(()=>{
// @ts-expect-error Must know which fields exists
var dummy = cargoInfo.inexistente.type
})
type CargoInfoExpected = {
cargo : {type: 'text' , label: string, nullable: boolean, description: string, isName: boolean},
denominacion : {type: 'text' , label: string, nullable: boolean, description: string, isName: boolean},
orden : {type: 'integer', label: string, nullable: boolean, description: string, isName: boolean},
puede_dirigir: {type: 'boolean', label: string, nullable: boolean, description: string, isName: boolean},
}
// both assignments must compile: expected and deduced are mutually assignable
// (this also checks that label, nullable and description are required, not optional)
var expected: CargoInfoExpected = cargoInfo;
var deducedBack: typeof cargoInfo = expected;
assert.deepStrictEqual(deducedBack, expected);
})
})
describe("aida entities", function(){
it("keeps the pk literal tuple, in both directions", function(){
var cursosPk: readonly ['periodo', 'materia'] = cursos.pk;
var cursosPkBack: typeof cursos.pk = cursosPk;
var clasesPk: readonly ['periodo', 'materia', 'orden'] = clases.pk;
var clasesPkBack: typeof clases.pk = clasesPk;
assert.deepStrictEqual(cursosPk, ['periodo', 'materia']);
assert.deepStrictEqual(clasesPk, ['periodo', 'materia', 'orden']);
assert.deepStrictEqual(cursosPkBack, cursosPk);
assert.deepStrictEqual(clasesPkBack, clasesPk);
})
it("rejects pk keys that are not keys of fields", function(){
// @ts-expect-error 'inexistente' is not a field
var wrong = defineEntity({pk: ['inexistente'], fields: materia});
// @ts-expect-error a wrong key among valid ones is also rejected
var wrong2 = defineEntity({pk: ['materia', 'inexistente'], fields: materia});
// (the check is compile-time only: at runtime defineEntity is the identity)
assert.deepStrictEqual(wrong.pk, ['inexistente']);
assert.deepStrictEqual(wrong2.pk, ['materia', 'inexistente']);
})
it("extracts the pk fields with their exact types and order", function(){
var cursosPkFields = extractPk(cursos);
type CursosPkExpected = {
periodo : {type: 'text', description: string},
materia : {type: 'text'},
}
// both assignments must compile: expected and extracted are mutually assignable
var expected: CursosPkExpected = cursosPkFields;
var extractedBack: typeof cursosPkFields = expected;
// @ts-expect-error 'docente' is not part of the pk
var noDocente = cursosPkFields.docente;
assert.deepStrictEqual(cursosPkFields, {periodo: curso.periodo, materia: curso.materia});
assert.deepStrictEqual(Object.keys(cursosPkFields), ['periodo', 'materia']);
assert.deepStrictEqual(extractedBack, expected);
assert.equal(noDocente, undefined);
})
it("inherits pk fields into other entities", function(){
// curso got all its fields from the periodos, materias and docentes pks:
assert.deepStrictEqual(Object.keys(curso), ['periodo', 'materia', 'docente']);
// clase extends the cursos pk with its own fields:
assert.deepStrictEqual(Object.keys(clase), ['periodo', 'materia', 'orden', 'fecha', 'tema']);
// the inherited fields keep their type literals:
var periodoType: 'text' = clases.fields.periodo.type;
// @ts-expect-error the literal is preserved, not widened to string
var wrongType: 'integer' = clases.fields.periodo.type;
assert.equal(periodoType, 'text');
assert.equal(wrongType, 'text');
})
it("chains pk inheritance (clases → preguntas → opciones)", function(){
var opcionesPk: readonly ['periodo', 'materia', 'orden', 'pregunta', 'opcion'] = opciones.pk;
var opcionesPkBack: typeof opciones.pk = opcionesPk;
assert.deepStrictEqual(opciones.pk, ['periodo', 'materia', 'orden', 'pregunta', 'opcion']);
assert.deepStrictEqual(Object.keys(opcion), ['periodo', 'materia', 'orden', 'pregunta', 'opcion', 'detalle']);
assert.deepStrictEqual(opcionesPkBack, opcionesPk);
})
it("merges overlapping pks without repeating (inscripciones + clases)", function(){
// periodo and materia are in both pks and must appear once, in order
var merged = mergePk(inscripciones.pk, clases.pk);
var mergedExpected: readonly ['periodo', 'materia', 'alumno', 'orden'] = merged;
var mergedBack: typeof merged = mergedExpected;
assert.deepStrictEqual(merged, ['periodo', 'materia', 'alumno', 'orden']);
// presencias uses that merge as its pk:
var presenciasPk: readonly ['periodo', 'materia', 'alumno', 'orden'] = presencias.pk;
assert.deepStrictEqual(presencias.pk, ['periodo', 'materia', 'alumno', 'orden']);
// and the fields spread dedups the shared fields by itself:
assert.deepStrictEqual(Object.keys(presencia), ['periodo', 'materia', 'alumno', 'orden']);
assert.deepStrictEqual(presenciasPk, mergedBack);
// the whole chain still deduces the instance type:
type Presencia = RecordInstanceType<typeof typeDefs, typeof presencia>
var unaPresencia: Presencia = {periodo: '2026-1c', materia: 'AlgoI', alumno: 'L1234', orden: 1};
var presenciaBack: {periodo: string, materia: string, alumno: string, orden: number} = unaPresencia;
assert.deepStrictEqual(presenciaBack, unaPresencia);
})
})
describe("aida fks, uks and isName", function(){
it("keeps the fks with their literal types, in both directions", function(){
type PresenciasFksExpected = {
inscripciones: {entity: 'inscripciones', fields: readonly ['periodo', 'materia', 'alumno']},
clases : {entity: 'clases' , fields: readonly ['periodo', 'materia', 'orden']},
}
var expected: PresenciasFksExpected = presencias.fks;
var fksBack: typeof presencias.fks = expected;
assert.deepStrictEqual(fksBack, {
inscripciones: {entity: 'inscripciones', fields: ['periodo', 'materia', 'alumno']},
clases : {entity: 'clases' , fields: ['periodo', 'materia', 'orden']},
});
})
it("represents a reflexive fk with renamed fields (jefe → docente)", function(){
var jefeFk: {entity: 'docentes', fields: {jefe: 'docente'}} = docentes.fks.jefe;
var jefeFkBack: typeof docentes.fks.jefe = jefeFk;
assert.deepStrictEqual(jefeFkBack, {entity: 'docentes', fields: {jefe: 'docente'}});
})
it("represents two fks to the same entity (mesas: presidente y vocal)", function(){
assert.deepStrictEqual(mesas.fks.presidente, {entity: 'docentes', fields: {presidente: 'docente'}});
assert.deepStrictEqual(mesas.fks.vocal , {entity: 'docentes', fields: {vocal: 'docente'}});
var presidenteTarget: 'docente' = mesas.fks.presidente.fields.presidente;
assert.equal(presidenteTarget, 'docente');
})
it("marks the isName field and completes it as false elsewhere", function(){
var denominacionIsName: true = materia.denominacion.isName;
// @ts-expect-error the code field has no isName mark
var codigoIsName = materia.materia.isName;
assert.equal(denominacionIsName, true);
assert.equal(codigoIsName, undefined);
})
it("rejects fk source fields and uk fields that are not fields", function(){
// @ts-expect-error 'inexistente' is not a field (array form)
var wrongFk = defineEntity({pk: ['materia'], fks: {x: {entity: 'materias', fields: ['inexistente']}}, fields: materia});
// @ts-expect-error 'inexistente' is not a field (map form: the source is the key)
var wrongFkMap = defineEntity({pk: ['materia'], fks: {x: {entity: 'materias', fields: {inexistente: 'materia'}}}, fields: materia});
// @ts-expect-error uk fields must be fields too
var wrongUk = defineEntity({pk: ['materia'], uks: {u: ['inexistente']}, fields: materia});
// (the checks are compile-time only)
assert.equal(wrongFk.fks.x.entity, 'materias');
assert.deepStrictEqual(wrongUk.uks, {u: ['inexistente']});
assert.equal(wrongFkMap.fks.x.entity, 'materias');
})
it("cross-checks the fks of the whole system", function(){
// the aida entityDefs already went through defineEntities; spot-check it kept everything:
assert.deepStrictEqual(Object.keys(entityDefs).length, 11);
assert.equal(entityDefs.presencias, presencias);
// a fk against a uk of the target entity is accepted:
const apuntes = defineEntity({pk: ['apunte'], fks: {materia_por_nombre: {entity: 'materias', fields: {denominacion_materia: 'denominacion'}}}, fields: {apunte: {type: 'text'}, denominacion_materia: {type: 'text'}}});
const miniSystem = defineEntities({materias, apuntes});
assert.deepStrictEqual(Object.keys(miniSystem), ['materias', 'apuntes']);
// a fk to an entity that is not part of the system is rejected:
const huerfanos = defineEntity({pk: ['x'], fks: {rota: {entity: 'inexistentes', fields: {x: 'algo'}}}, fields: {x: {type: 'text'}}});
// @ts-expect-error 'inexistentes' is not an entity of the system
defineEntities({huerfanos});
// a fk that references only a part of a composite pk (and no uk) is rejected:
const franjas = defineEntity({pk: ['dia', 'hora'], fields: {dia: {type: 'text'}, hora: {type: 'integer'}}});
const eventos = defineEntity({pk: ['evento'], fks: {franja: {entity: 'franjas', fields: {dia: 'dia'}}}, fields: {evento: {type: 'text'}, dia: {type: 'text'}}});
// @ts-expect-error 'hora' is missing: the fk must reference the complete pk or a uk
defineEntities({franjas, eventos});
})
})
describe("aida entity completion (Def → Info)", function(){
it("normalizes array-form fks to the source→target map form", function(){
var cursosInfo = completeEntity(cursos);
type CursosFksExpected = {
periodos : {entity: 'periodos', fields: {periodo: 'periodo'}},
materias : {entity: 'materias', fields: {materia: 'materia'}},
responsable: {entity: 'docentes', fields: {docente: 'docente'}},
}
// both assignments must compile: expected and completed are mutually assignable
var expected: CursosFksExpected = cursosInfo.fks;
var fksBack: typeof cursosInfo.fks = expected;
// @ts-expect-error 'inexistente' is not a fk
var noFk = cursosInfo.fks.inexistente;
// @ts-expect-error the target field literal is preserved, not widened to string
var wrongTarget: 'materia' = cursosInfo.fks.periodos.fields.periodo;
assert.deepStrictEqual(fksBack, {
periodos : {entity: 'periodos', fields: {periodo: 'periodo'}},
materias : {entity: 'materias', fields: {materia: 'materia'}},
responsable: {entity: 'docentes', fields: {docente: 'docente'}},
});
assert.equal(noFk, undefined);
assert.equal(wrongTarget, 'periodo');
})
it("keeps map-form fks as they are", function(){
var mesasInfo = completeEntity(mesas);
var presidenteFk: {entity: 'docentes', fields: {presidente: 'docente'}} = mesasInfo.fks.presidente;
var presidenteFkBack: typeof mesasInfo.fks.presidente = presidenteFk;
// @ts-expect-error after completion the array form is gone: fields is always a map
var noArray: readonly string[] = mesasInfo.fks.cursos.fields;
assert.deepStrictEqual(presidenteFkBack, {entity: 'docentes', fields: {presidente: 'docente'}});
assert.deepStrictEqual(mesasInfo.fks.cursos.fields, {periodo: 'periodo', materia: 'materia'});
assert.deepStrictEqual(noArray, {periodo: 'periodo', materia: 'materia'});
})
it("dedups the pk, so overlapping pks can be spread without mergePk", function(){
var presenciasAlt = defineEntity({
// periodo and materia appear twice in the spread:
pk: [...inscripciones.pk, ...clases.pk],
fields: presencia,
});
var presenciasAltInfo = completeEntity(presenciasAlt);
var pkExpected: readonly ['periodo', 'materia', 'alumno', 'orden'] = presenciasAltInfo.pk;
var pkBack: typeof presenciasAltInfo.pk = pkExpected;
assert.deepStrictEqual(presenciasAltInfo.pk, ['periodo', 'materia', 'alumno', 'orden']);
assert.deepStrictEqual(pkBack, pkExpected);
})
it("completes the fields and keeps the uks", function(){
var materiasInfo = completeEntity(materias);
assert.deepStrictEqual(materiasInfo.fields, completeRecord(materia));
var uksExpected: {denominacion: readonly ['denominacion']} = materiasInfo.uks;
var uksBack: typeof materiasInfo.uks = uksExpected;
assert.deepStrictEqual(uksBack, {denominacion: ['denominacion']});
// the defaulted empty fks stay explicit and empty:
assert.deepStrictEqual(materiasInfo.fks, {});
})
})
describe("aida design snapshot", function(){
it("matches aida-design.toon", function(){
/* provisional flattening until TOLON exists: toon only formats arrays of uniform
objects as tables, so the fields map becomes an array with the name inside */
function designSnapshot(eds: Record<string, EntityDef<TypeCollection>>){
return LikeAr(eds).map(ed => {
var entityInfo = completeEntity(ed);
return {
...entityInfo,
fields: LikeAr(entityInfo.fields).map((fieldInfo, name)=>({name, ...fieldInfo})).array(),
};
}).plain();
}
var design = designSnapshot(entityDefs);
var generated = encode(design) + '\n';
var snapshotPath = (prefix:string) => path.join(__dirname, '..', '..', 'test', prefix+'aida-design.toon');
fs.writeFileSync(snapshotPath('local-'), generated);
var expected = fs.readFileSync(snapshotPath(''), 'utf8').replace(/\r\n/g, '\n');
assert.equal(generated, expected);
})
})
{
"compilerOptions": {
"declaration": true,
"outDir": "dist",
"module": "nodenext",
"target": "es2022",
"sourceMap": true,
"strict": true,
"types": ["node", "mocha"]
},
"include": [
"src/**/*.ts",
"examples/**/*.ts",
"test/**/*.ts"
]
}
+15
-6
{
"name": "system-definition",
"version": "0.0.1",
"description": "Definint based on single source of truth",
"description": "Defining system based on SSOT",
"version": "0.0.2",
"license": "MIT",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "npm test"
"build": "tsc",
"test": "tsc && mocha --exit --node-option enable-source-maps dist/test/",
"test-cov": "tsc && c8 mocha --node-option enable-source-maps dist/test/"
},
"devDependencies": {
"@toon-format/toon": "^2.3.0",
"@types/mocha": "^10.0.10",
"@types/node": "^25.9.1",
"best-globals": "^2.2.2",
"c8": "^11.0.0",
"like-ar": "^0.5.3",
"mocha": "^11.7.5",
"typescript": "^7.0.2"
}
}