From 38e90f752f535164e1ec2380bda84eaea8ed7b02 Mon Sep 17 00:00:00 2001 From: Jephte Clain Date: Fri, 22 Mar 2024 13:20:05 +0400 Subject: [PATCH] wip os/proc et php/coll --- src/os/proc/ManagedTask.php | 576 ++++++++++++++++++++++++++++++ src/os/proc/tasks.php | 75 ++++ src/php/coll/AutoArray.php | 44 +++ src/php/coll/BaseArray.php | 115 ++++++ src/php/iter/AbstractIterator.php | 8 +- src/schema/README.md | 2 +- 6 files changed, 815 insertions(+), 5 deletions(-) create mode 100644 src/os/proc/ManagedTask.php create mode 100644 src/os/proc/tasks.php create mode 100644 src/php/coll/AutoArray.php create mode 100644 src/php/coll/BaseArray.php diff --git a/src/os/proc/ManagedTask.php b/src/os/proc/ManagedTask.php new file mode 100644 index 0000000..416c1c0 --- /dev/null +++ b/src/os/proc/ManagedTask.php @@ -0,0 +1,576 @@ + ["string", null, "identifiant de la tâche"], + "serial" => ["int", null, "numéro de série permettant de distinguer deux occurrences de la tâche"], + "title" => ["?string", null, "description de la tâche"], + "valid" => ["bool", false, "la tâche est-elle valide?"], + "owner_login" => ["?string", null, "compte de la personne qui a lancé la tâche"], + "owner_name" => ["?string", null, "nom de la personne qui a lancé la tâche"], + "owner_page" => ["?string", null, "page qui a créé cette tâche"], + "owner_params" => ["?array", null, "paramètres à passer à la page"], + "cmd" => [null, null, "commande à lancer"], + "logfile" => ["?string", null, "sortie de la commande"], + ]; + + const SCHEMA = [ + "definition" => [ + "?array", null, "définition de la tâche", + "schema" => self::DEFINITION_SCHEMA, + ], + "state" => [ + "?array", null, "instance de la tâche", + "schema" => [ + "definition" => [ + "array", null, "copie de la définition de la tâche", + "schema" => self::DEFINITION_SCHEMA, + ], + "started" => ["bool", false, "la tâche a-t-elle été démarrée?"], + "date_start" => ["?datetime", null, "date du démarrage de la tâche"], + "pid" => ["?int", null, "PID du process contrôleur"], + "status" => ["?string", null, "Message de statut indiqué par la tâche"], + "stopped" => ["bool", false, "la tâche est-elle terminée?"], + "date_stop" => ["?datetime", null, "date de l'arrêt de la tâche"], + "retcode" => ["?int", null, "code de retour de la commande"], + "done" => ["bool", false, "la fin de la tâche a-t-elle été prise en compte?"], + ], + ], + "" => [ + "auto_properties" => [ + "id" => "definition.id", + "serial" => "definition.serial", + "title" => "definition.title", + "valid" => "definition.valid", + "owner_login" => "definition.owner_login", + "owner_name" => "definition.owner_name", + "owner_page" => "definition.owner_page", + "owner_params" => "definition.owner_params", + "cmd" => "definition.cmd", + "logfile" => "definition.logfile", + ] + ], + ]; + + const _AUTO_PROPERTIES = self::SCHEMA[""]["auto_properties"]; + + const _SCHEMA = [ + "id" => ["string", null, "identifiant de la tâche"], + "serial" => ["string", null, "numéro de série permettant de distinguer deux occurrences de la tâche"], + "title" => ["?string", null, "description de la tâche"], + "valid" => ["bool", false, "la tâche est-elle valide?"], + "owner_login" => ["?string", null, "compte de la personne qui a lancé la tâche"], + "owner_name" => ["?string", null, "nom de la personne qui a lancé la tâche"], + "page" => ["?array", null, "page qui a créé cette tâche et paramètres à passer à la page"], + "cmd" => [null, null, "commande à lancer"], + "logfile" => ["?string", null, "sortie de commande"], + "started" => ["bool", false, "la tâche a-t-elle été démarrée?"], + "date_start" => ["?datetime", null, "date du démarrage de la tâche"], + "pid" => ["?int", null, "PID du process contrôleur"], + "stopped" => ["bool", false, "la tâche est-elle terminée?"], + "date_stop" => ["?datetime", null, "date de l'arrêt de la tâche"], + "retcode" => ["?int", null, "code de retour de la commande"], + "done" => ["bool", false, "la fin de la tâche a-t-elle été prise en compte?"], + ]; + + function __construct(string $id, bool $autoUpdate=false, ?callable $init=null) { + # ne pas appeler parent::__construct() + if (file_exists($id)) { + $file = $id; + } else { + $authz = authz::get(); + $this->data = [ + "id" => $id, + "serial" => 0, + "owner_login" => $authz->getUsername(), + "owner_name" => $authz->getDisplayName(), + ]; + $file = tasks::pf("$id.task"); + } + $this->init = $init; + $this->file = $file; + $this->ensureTask($autoUpdate); + } + + /** @var ?callable */ + protected $init; + + /** @var string */ + protected $file; + + private function ensureTask(bool $autoUpdate): void { + lock::exlusive(self::LOCK); + try { + if (is_file($this->file)) { + $this->_reload(); + } else { + $this->_reset(); + $this->_save(); + } + $logfile = $this->getLogfile(); + if ($logfile !== null) os::mkdirof($logfile); + } finally { + lock::release(self::LOCK); + } + if ($autoUpdate) $this->update(); + } + + private function _init(): bool { + $init = $this->init; + if ($init !== null) { + func::call($init, $this); + return true; + } + return false; + } + + function init(): void { + if ($this->init !== null) { + lock::exlusive(self::LOCK); + try { + $this->_init(); + $this->_save(); + } finally { + lock::release(self::LOCK); + } + } + } + + private function _reset(): void { + $authz = authz::get(); + $id = $this->data["id"]; + $serial = A::get($this->data, "serial", 0); + $this->data = $this->ensureData([ + "id" => $id, + "serial" => $serial + 1, + "owner_login" => $authz->getUsername(), + "owner_name" => $authz->getDisplayName(), + "logfile" => logs::pf("$id/latest.log"), + ]); + $this->_init(); + } + + function reset(): void { + lock::exlusive(self::LOCK); + try { + $this->_reset(); + $this->_save(); + } finally { + lock::release(self::LOCK); + } + } + + private function _reload(): void { + $this->data = unserialize(file_get_contents($this->file)); + } + + function reload(): void { + lock::exlusive(self::LOCK); + try { + $this->_reload(); + } finally { + lock::release(self::LOCK); + } + } + + private function _save(): void { + os::mkdirof($this->file); + $outf = fopen($this->file, "w+"); + fwrite($outf, serialize($this->data)); + fclose($outf); + } + + function save(): void { + lock::exlusive(self::LOCK); + try { + $this->_save(); + } finally { + lock::release(self::LOCK); + } + } + + /** vérifier que l'objet est bien initialisé */ + function validate(): void { + if (!$this->isValid()) { + if ($this->getCmd() === null) { + throw new ValueException("cmd is required"); + } + $this->setValid(true); + $this->save(); + } + } + + function _launch(): void { + $args = [ + __DIR__.'/../../lib/launch_task.php', + "--envname", envs::get(), + $this->getId(), + ]; + $logfile = $this->getLogfile(); + if ($logfile !== null) A::merge($args, ["--logfile", $logfile]); + $cmd = new Cmd($args); + $cmd->addRedir("null"); + $cmd->passthru(); + $this->reload(); + } + + function isLaunchable(): bool { + return $this->isStarted(); + } + + function launch(): void { + if (!$this->isStartable()) return; + if ($this->isDone()) $this->reset(); + $this->_launch(); + } + + function isUpdatable(): bool { + return $this->isLaunchable() && !$this->isDone(); + } + + function update(): void { + if ($this->isUpdatable()) $this->_launch(); + else $this->init(); + } + + function kill(): void { + if (!$this->isStarted() || $this->isStopped()) return; + $args = [ + __DIR__.'/../../lib/launch_task.php', + "-e", envs::get(), + "--kill", + $this->getId(), + ]; + $logfile = $this->getLogfile(); + if ($logfile !== null) A::merge($args, ["-L", $logfile]); + $cmd = new Cmd($args); + $cmd->addRedir("null"); + $cmd->passthru(); + $this->reload(); + } + + function isStartable(): bool { + return !$this->isStarted() || $this->isDone(); + } + + /** + * démarrer la commande. doit être lancé depuis launch_task.php + */ + function ltStart(?string $logfile): void { + $pid = pcntl_fork(); + if ($pid == -1) { + # parent, impossible de forker + throw new IllegalAccessException("unable to fork"); + } elseif ($pid) { + # parent, fork ok + $this->setStarted(true); + $this->setDateStart(date::datetime()); + $this->setPid($pid); + $this->save(); + } else { + ## child, fork ok + # Créer un groupe de process, pour pouvoir les tuer toutes en même temps + posix_setsid(); + msg::push($oldMsg, null, [ + "output" => $logfile, + ]); + $retcode = -776; + try { + # tout d'abord synchroniser les fichiers le cas échéant + $command = $this->get("command"); + $append = false; + if ($command !== null) { + $files = $command["files"]; + $forceSync = $this->get("force_sync"); + files::sync($files, $forceSync, $logfile, "wb"); + $append = true; + } + # puis lancer la commande + $cmd = Cmd::with($this->getCmd()); + if ($logfile !== null) $cmd->addRedir("both", $logfile, $append); + $cmd->fork_exec($retcode); + } catch (Exception $e) { + msg::error($e); + } finally { + $this->reload(); + $this->setStopped(true); + $this->setDateStop(date::datetime()); + $this->setRetcode($retcode); + $this->save(); + msg::pop($oldMsg); + } + } + } + + /** arrêter la commande. doit être lancé depuis launch_task.php */ + function ltKill(?string $logfile): void { + msg::push($oldMsg, null, $logfile); + try { + $id = $this->getId(); + $pid = $this->getPid(); + msg::action("$id: $pid"); + if (!posix_kill(-$pid, SIGKILL)) { + switch (posix_get_last_error()) { + case PCNTL_ESRCH: + msg::afailure("process inexistant"); + break; + case PCNTL_EPERM: + msg::afailure("process non accessible"); + break; + case PCNTL_EINVAL: + msg::afailure("signal invalide"); + break; + } + return; + } + $timeout = 10; + while ($this->ltIsUndead()) { + sleep(1); + if (--$timeout == 0) { + msg::afailure("tentative d'arrêt de la tâche"); + return; + } + } + msg::asuccess("tâche arrêtée"); + $this->setStopped(true); + $this->setDateStop(date::datetime()); + $this->setRetcode(-787); + $this->setDone(true); + $this->save(); + } finally { + msg::pop($oldMsg); + } + } + + function isReapable(): bool { + return $this->isStopped() && !$this->isDone(); + } + + /** + * marquer la commande comme terminée. doit être lancé depuis launch_task.php + */ + function ltReap(): void { + pcntl_waitpid($this->getPid(), $status); + $this->setDone(true); + $this->save(); + } + + /** + * vérifier si on est dans le cas où la tâche est censée tourner mais en + * réalité ce n'est pas le cas. doit être lancé depuis launch_task.php + */ + function ltIsUndead(): bool { + if (!posix_kill($this->getPid(), 0)) { + switch (posix_get_last_error()) { + case PCNTL_ESRCH: + # process inexistant + return true; + case PCNTL_EPERM: + # process auquel on n'a pas accès: ce doit être un autre process qui a + # réutilisé le PID + return true; + case PCNTL_EINVAL: + # ne devrait pas se produire + return false; + } + } + # process existant + return false; + } + + /** + * marquer la tâche comme terminée avec un code d'erreur si elle n'existe + * plus. doit être lancé depuis launch_task.php + */ + function ltCleanUndead(): void { + if (!$this->isStopped()) { + $this->setStopped(true); + $this->setDateStop(date::datetime()); + $this->setRetcode(-777); + } + $this->setDone(true); + $this->save(); + } + + function getIdTitle(): string { + $idTitle = $this->getId(); + $title = $this->getTitle(); + if ($title) $idTitle .= " -- $title"; + return $idTitle; + } + + function getNameOrLogin(): string { + $nameOrLogin = $this->getOwnerName(); + if ($nameOrLogin === null) $nameOrLogin = $this->getOwnerLogin(); + if ($nameOrLogin === null) $nameOrLogin = "(unknown)"; + return $nameOrLogin; + } + + const MAX_LOG_SIZE = 256 * 1024; + const CACTION_NONE = "n"; + const CACTION_REPLACE = "r"; + const CACTION_UPDATE = "u"; + + function export(?int $serial=null, ?int $cs=null, ?int $ce=null): array { + $task = $this->array(); + $dateStart = new Datetime($this->getDateStart()); + $dateStop = new Datetime($this->getDateStop()); + # $ca = action à faire par le client: replace ou update + # ls = local start, le = local end (local === server en l'occurrence) + # cs = client start, ce = client end (pour CACTION_REPLACE) + # $ps = plus start, $pe = plus end (pour CACTION_UPDATE) + # $rs = read start, $re = read end + if ($serial !== null && $cs !== null && $ce !== null && $this->isStarted()) { + lock::exlusive(self::LOCK); + $inf = false; + try { + $logfile = $this->getLogfile(); + if (!file_exists($logfile)) { + # s'assurer que le fichier existe (il peut avoir été nettoyé entre temps) + f::close(f::open($logfile, "cb")); + } + $inf = f::open($logfile, "rb"); + $le = f::seek($inf, 0, SEEK_END); + $ls = $le - self::MAX_LOG_SIZE; + if ($ls <= 0) { + $ls = 0; + } else { + # trouver le premier saut de ligne + $ls = f::find_nl($inf, $ls); + } + + if ($serial != $this->getSerial()) { + # nouvelle tâche, on recommence tout + $rs = $cs = $ls; + $re = $ce = $le; + $ca = self::CACTION_REPLACE; + } elseif ($ls <= $cs) { + # cas courant, on rajoute du contenu, mais pas plus que MAX_LOG_SIZE + $ls = $cs; + $ps = 0; + $pe = $le - $ce; + $rs = $ce; + $re = $le; + $ca = self::CACTION_UPDATE; + $cs = $ps; + $ce = $pe; + } elseif ($ls <= $ce) { + # on a dépassé MAX_LOG_SIZE, il faut recalculer + # garder une partie des logs précédents + $ps = $ls - $cs; + $pe = $le - $ce; + $rs = $ce; + $re = $le; + $ca = self::CACTION_UPDATE; + $cs = $ps; + $ce = $pe; + } else { + # ne rien garder des logs précédents + $rs = $cs = $ls; + $re = $ce = $le; + $ca = self::CACTION_REPLACE; + } + + $logSize = $re - $rs; + if ($logSize > 0) { + f::seek($inf, $rs, SEEK_SET); + $log = f::read($inf, $logSize); + $lf = new BaseF(); #XXX + $lf->formatContent($log); + } elseif ($ca == self::CACTION_REPLACE) { + $log = ""; + } else { + $log = false; + } + } finally { + if ($inf) f::close($inf); + lock::release(self::LOCK); + } + } else { + $cs = $ce = false; + $ca = self::CACTION_NONE; + $log = false; + } + $page = $this->getPage(); + if ($page !== null) { + $dest = A::get($page, 0); + $params = A::get($page, 1); + $pageUrl = page::bu($dest, $params); + } else { + $pageUrl = false; + } + A::merge($task, [ + "id_title" => $this->getIdTitle(), + "name_or_login" => $this->getNameOrLogin(), + "page_url" => $pageUrl, + "elapsed_start" => $dateStart->getElapsed()->formatAt(), + "elapsed_stop" => $dateStop->getElapsed()->formatSince(), + "elapsed_total" => $dateStart->getElapsed($dateStop)->formatDelay(), + "launchable" => $this->isLaunchable(), + "updatable" => $this->isUpdatable(), + "startable" => $this->isStartable(), + "reapable" => $this->isReapable(), + "working" => $this->isStarted() && !$this->isDone(), + "ok" => $this->isDone() && $this->getRetcode() == 0, + "ko" => $this->isDone() && $this->getRetcode() != 0, + "log" => $log, + ]); + return [$task, $ca, $cs, $ce]; + } + + ############################################################################# + const _AUTOGEN_CONSTS = [ + "_AUTO_GETTERS" => [Autogen::class, "auto_getters", self::SCHEMA], + "_AUTO_SETTERS" => [Autogen::class, "auto_setters", self::SCHEMA], + ]; + const _AUTOGEN_METHODS = [ + [Autogen::class, "auto_getters_methods", self::SCHEMA], + [Autogen::class, "auto_setters_methods", self::SCHEMA], + ]; + const _AUTO_GETTERS = /*autogen*/[ + 'getId' => 'id', + 'getSerial' => 'serial', + 'getTitle' => 'title', + 'isValid' => 'valid', + 'getOwnerLogin' => 'owner_login', + 'getOwnerName' => 'owner_name', + 'getPage' => 'page', + 'getCmd' => 'cmd', + 'getLogfile' => 'logfile', + 'isStarted' => 'started', + 'getDateStart' => 'date_start', + 'getPid' => 'pid', + 'isStopped' => 'stopped', + 'getDateStop' => 'date_stop', + 'getRetcode' => 'retcode', + 'isDone' => 'done', + ]; + const _AUTO_SETTERS = /*autogen*/[ + 'setId' => 'id', + 'setSerial' => 'serial', + 'setTitle' => 'title', + 'setValid' => 'valid', + 'setOwnerLogin' => 'owner_login', + 'setOwnerName' => 'owner_name', + 'setPage' => 'page', + 'setCmd' => 'cmd', + 'setLogfile' => 'logfile', + 'setStarted' => 'started', + 'setDateStart' => 'date_start', + 'setPid' => 'pid', + 'setStopped' => 'stopped', + 'setDateStop' => 'date_stop', + 'setRetcode' => 'retcode', + 'setDone' => 'done', + ]; +} diff --git a/src/os/proc/tasks.php b/src/os/proc/tasks.php new file mode 100644 index 0000000..2df5784 --- /dev/null +++ b/src/os/proc/tasks.php @@ -0,0 +1,75 @@ +isValid()) { + continue; + } + $tasks[] = [$taskfile, $task]; + } + if ($sort) { + clearstatcache(); + usort($tasks, function ($fta, $ftb) { + /** + * @var ManagedTask $ta + * @var ManagedTask $tb + */ + [$fa, $ta] = $fta; + [$fb, $tb] = $ftb; + # comparer l'état "running" + $wa = $ta->isStarted() && !$ta->isDone(); + $wb = $tb->isStarted() && !$tb->isDone(); + $c = -base::compare($wa, $wb); + if ($c != 0) return $c; + # comparer la date de dernière modification du fichier + $mta = filemtime($fa); + $mtb = filemtime($fb); + return -base::compare($mta, $mtb); + }); + } + return $tasks; + } + + /** supprimer toutes les tâches */ + static function delete_all(): void { + lock::exlusive(ManagedTask::LOCK); + try { + foreach (self::_list(true, false) as [$taskfile, $task]) { + unlink($taskfile); + } + } finally { + lock::release(ManagedTask::LOCK); + } + } + + /** + * retourner la liste des tâches valides + * @return ManagedTask[] + */ + static function list(?string $selectId=null): array { + $tasks = []; + lock::exlusive(ManagedTask::LOCK); + try { + foreach (self::_list(false, true) as [$taskfile, $task]) { + $id = $task->getId(); + if ($selectId !== null && $id !== $selectId) continue; + $tasks[$id] = $task; + } + } finally { + lock::release(ManagedTask::LOCK); + } + return $tasks; + } +} diff --git a/src/php/coll/AutoArray.php b/src/php/coll/AutoArray.php new file mode 100644 index 0000000..d7b91ad --- /dev/null +++ b/src/php/coll/AutoArray.php @@ -0,0 +1,44 @@ +has($name)) return true; + $properties = self::_AUTO_PROPERTIES(); + if ($properties === null) return false; + return array_key_exists($name, $properties); + } + function __get($name) { + $properties = self::_AUTO_PROPERTIES(); + if ($this->has($name)) return $this->get($name); + $pkey = cl::get($properties, $name, $name); + return cl::pget($this->data, $pkey); + } + function __set($name, $value) { + $properties = self::_AUTO_PROPERTIES(); + if ($this->has($name)) { + $this->set($name, $value); + } else { + $pkey = cl::get($properties, $name, $name); + cl::pset($this->data, $pkey, $value); + } + } + function __unset($name) { + $properties = self::_AUTO_PROPERTIES(); + if ($this->has($name)) { + $this->del($name); + } else { + $pkey = cl::get($properties, $name, $name); + cl::pdel($this->data, $pkey); + } + } +} diff --git a/src/php/coll/BaseArray.php b/src/php/coll/BaseArray.php new file mode 100644 index 0000000..d0a402f --- /dev/null +++ b/src/php/coll/BaseArray.php @@ -0,0 +1,115 @@ +reset($data); + } + + /** @var array */ + protected $data; + + function __toString(): string { return var_export($this->data, true); } + #function __debugInfo() { return $this->data; } + function reset(?array &$data): void { $this->data =& $data; } + function &array(): ?array { return $this->data; } + function count(): int { return $this->data !== null? count($this->data): 0; } + function keys(): array { return $this->data !== null? array_keys($this->data): []; } + + ############################################################################# + # base + + function has($key): bool { + return $this->data !== null && array_key_exists($key, $this->data); + } + function &get($key, $default=null) { + if ($this->data !== null && array_key_exists($key, $this->data)) { + return $this->data[$key]; + } else return $default; + } + function set($key, $value): void { + if ($key === null) $this->data[] = $value; + else $this->data[$key] = $value; + } + function del($key): void { + unset($this->data[$key]); + } + + function offsetExists($offset): bool { return $this->has($offset); } + function &offsetGet($offset) { return $this->get($offset); } + function offsetSet($offset, $value) { $this->set($offset, $value); } + function offsetUnset($offset) { $this->del($offset); } + + function __isset($name) { return $this->has($name); } + function &__get($name) { return $this->get($name); } + function __set($name, $value) { $this->set($name, $value); } + function __unset($name) { $this->del($name); } + + ############################################################################# + # iterator + + /** @var bool */ + private $valid = false; + + function rewind() { + if ($this->data !== null) { + $first = reset($this->data); + $this->valid = $first !== false || key($this->data) !== null; + } else { + $this->valid = false; + } + } + function valid(): bool { return $this->valid; } + function key() { return key($this->data); } + function current() { return current($this->data); } + function next() { + $next = next($this->data); + $this->valid = $next !== false || key($this->data) !== null; + } + + ############################################################################# + # divers + + function phas($pkey): bool { return cl::phas($this->data, $pkey); } + function pget($pkey, $default=null): bool { return cl::pget($this->data, $pkey, $default); } + function pset($pkey, $value): void { cl::pset($this->data, $pkey, $value); } + function pdel($pkey): void { cl::pdel($this->data, $pkey); } + + function contains($value, bool $strict=false): bool { + if ($value === null || $this->data === null) return false; + return in_array($value, $this->data, $strict); + } + + function add($value, bool $unique=true, bool $strict=false): bool { + if ($unique && $this->contains($value, $strict)) return false; + $this->set(null, $value); + return true; + } + + function addAll(?array $values, bool $unique=true, bool $strict=false): void { + if ($values === null) return; + $index = 0; + foreach ($values as $key => $value) { + if ($key === $index) { + $this->add($value, $unique, $strict); + $index++; + } else { + $this->set($key, $value); + } + } + } + + function resetAll(?array $values): void { + $this->data = null; + $this->addAll($values); + } +} diff --git a/src/php/iter/AbstractIterator.php b/src/php/iter/AbstractIterator.php index a7e3691..0911695 100644 --- a/src/php/iter/AbstractIterator.php +++ b/src/php/iter/AbstractIterator.php @@ -3,7 +3,7 @@ namespace nur\sery\php\iter; use Exception; use Iterator; -use nur\sery\os\EOFException; +use nulib\DataException; use nur\sery\php\ICloseable; /** @@ -29,12 +29,12 @@ abstract class AbstractIterator implements Iterator, ICloseable { protected function beforeIter() {} /** - * retourner le prochain élément. lancer l'exception {@link EOFException} pour + * retourner le prochain élément. lancer l'exception {@link DataException} pour * indiquer que plus aucun élément n'est disponible * * le cas échéant, initialiser $key * - * @throws EOFException + * @throws DataException */ abstract protected function _next(&$key); @@ -94,7 +94,7 @@ abstract class AbstractIterator implements Iterator, ICloseable { $this->valid = false; try { $item = $this->_next($key); - } catch (EOFException $e) { + } catch (DataException $e) { $this->beforeClose(); try { $this->_teardown(); diff --git a/src/schema/README.md b/src/schema/README.md index 4320cdb..b908a2b 100644 --- a/src/schema/README.md +++ b/src/schema/README.md @@ -136,7 +136,7 @@ nature liste si: Un tableau associatif est modélisée de cette manière: ~~~php -const LIST_SCHEMA = [ +const ASSOC_SCHEMA = [ KEY => VALUE_SCHEMA, ... "" => ["assoc"],