diff --git a/backend/bootstrap/framework.php b/backend/bootstrap/framework.php index 61e687e24..580fd2e9a 100644 --- a/backend/bootstrap/framework.php +++ b/backend/bootstrap/framework.php @@ -129,6 +129,7 @@ $settings->get('global.debugMode'), $settings->get('global.productionEnv') ); +$mysqlDB->setDeltaTracking($settings->get('transactions.deltaConjunctMaintenance', 'off') !== 'off'); $ampersandApp->setDefaultStorage($mysqlDB); $ampersandApp->setConjunctCache(new MysqlConjunctCache($mysqlDB)); diff --git a/backend/src/Ampersand/Core/Relation.php b/backend/src/Ampersand/Core/Relation.php index 2f9eb5da7..bd56457e5 100644 --- a/backend/src/Ampersand/Core/Relation.php +++ b/backend/src/Ampersand/Core/Relation.php @@ -104,7 +104,14 @@ class Relation * Contains information about mysql table and columns in which this relation is administrated */ private MysqlDBRelationTable $mysqlTable; - + + /** + * Name of the table that holds this relation's touched pairs during a + * transaction, for delta-scoped re-evaluation (issue Ampersand#1684). + * Null when the compiler did not emit one. + */ + protected ?string $deltaTable = null; + /** * Constructor */ @@ -113,6 +120,8 @@ public function __construct(array $relationDef, LoggerInterface $logger, Ampersa $this->logger = $logger; $this->app = $app; + $this->deltaTable = $relationDef['deltaTable'] ?? null; + $this->name = $relationDef['name']; $this->srcConcept = $app->getModel()->getConcept($relationDef['srcConceptName']); $this->tgtConcept = $app->getModel()->getConcept($relationDef['tgtConceptName']); @@ -177,6 +186,14 @@ public function getMysqlTable(): MysqlDBRelationTable return $this->mysqlTable; } + /** + * Delta table for this relation, or null when the compiler did not emit one + */ + public function getDeltaTable(): ?string + { + return $this->deltaTable; + } + /** * Get registered plugs for this relation * diff --git a/backend/src/Ampersand/Misc/defaultSettings.yaml b/backend/src/Ampersand/Misc/defaultSettings.yaml index 53eb8ce44..465866513 100644 --- a/backend/src/Ampersand/Misc/defaultSettings.yaml +++ b/backend/src/Ampersand/Misc/defaultSettings.yaml @@ -54,6 +54,13 @@ settings: ### Transaction settings transactions.ignoreInvariantViolations: false # for debugging can be set to true (transactions will be committed regardless off invariant violations) transactions.skipUniInjConjuncts: false # TODO: remove after fix for issue #535 + # Delta-scoped re-evaluation of conjunct violations (issue Ampersand#1684). + # 'off' = full re-evaluation as always; 'shadow' = maintain the cache with the + # delta protocol AND evaluate in full, log any difference, full result stays + # authoritative; 'on' = delta protocol maintains the cache, full evaluation + # only for conjuncts outside the supported class. Requires generics produced + # by a compiler that emits deltaQueries; without those the setting is a no-op. + transactions.deltaConjunctMaintenance: 'off' transactions.skipCleanConjuncts: false # skip the close's re-evaluation of conjuncts that were evaluated in this transaction with no mutations registered afterwards (issue #443) transactions.interfaceAutoSaveChanges: true # specifies whether changes in interface are directly communicated (saved) to server diff --git a/backend/src/Ampersand/Plugs/MysqlConjunctCache/MysqlConjunctCache.php b/backend/src/Ampersand/Plugs/MysqlConjunctCache/MysqlConjunctCache.php index b6d0dad9c..a3d10ee13 100644 --- a/backend/src/Ampersand/Plugs/MysqlConjunctCache/MysqlConjunctCache.php +++ b/backend/src/Ampersand/Plugs/MysqlConjunctCache/MysqlConjunctCache.php @@ -41,6 +41,16 @@ class MysqlConjunctCache implements CacheItemPoolInterface /** * Constructor */ + public function getTableName(): string + { + return $this->tableName; + } + + public function getDatabase(): MysqlDB + { + return $this->database; + } + public function __construct(MysqlDB $database, string $tableName = '__conj_violation_cache__') { $this->database = $database; diff --git a/backend/src/Ampersand/Plugs/MysqlDB/MysqlDB.php b/backend/src/Ampersand/Plugs/MysqlDB/MysqlDB.php index 2cffa7160..7f71e927d 100644 --- a/backend/src/Ampersand/Plugs/MysqlDB/MysqlDB.php +++ b/backend/src/Ampersand/Plugs/MysqlDB/MysqlDB.php @@ -101,6 +101,39 @@ class MysqlDB implements ConceptPlugInterface, RelationPlugInterface, IfcPlugInt * Attribute is reset to 0 on start of (new) transaction */ protected int $queryCount = 0; + + /** + * Specifies if touched pairs are recorded in the relations' delta tables + * (delta-scoped re-evaluation, issue Ampersand#1684). Enabled from + * bootstrap when transactions.deltaConjunctMaintenance is not 'off'. + */ + protected bool $deltaTracking = false; + + /** + * Pairs recorded in delta tables within the open transaction, keyed by + * delta table name; per table a map "src|tgt" => [src, tgt] (DB + * representation). Used to dedupe inserts and to remove exactly these rows + * before commit, so concurrent transactions never touch each other's rows. + * + * @var array> + */ + protected array $deltaRecordedPairs = []; + + /** + * Touched relations within the open transaction: signature => delta table + * + * @var array + */ + protected array $deltaTouchedRelations = []; + + /** + * Relations that underwent a bulk mutation (deleteAllLinks/emptyRelation) + * in the open transaction; their conjuncts need full re-evaluation because + * the removed pairs are not individually recorded. + * + * @var array + */ + protected array $deltaBulkMutated = []; /** * Constructor @@ -394,11 +427,12 @@ public function startTransaction(Transaction $transaction): void public function commitTransaction(Transaction $transaction): void { $this->logger->info("Commit mysql database transaction for {$transaction}"); + $this->cleanupDeltaTables(); // remove this transaction's delta rows before COMMIT, so they never leak $this->execute("COMMIT"); $this->dbTransactionActive = false; $this->logger->info("{$this->queryCount} queries executed in this transaction"); } - + /** * Function to rollback changes made in the open database transaction */ @@ -407,8 +441,104 @@ public function rollbackTransaction(Transaction $transaction): void $this->logger->info("Rollback mysql database transaction for {$transaction}"); $this->execute("ROLLBACK"); $this->dbTransactionActive = false; + $this->clearDeltaAdministration(); // delta rows are rolled back with the transaction $this->logger->info("{$this->queryCount} queries executed in this transaction"); } + + /** + * Enable/disable recording of touched pairs in delta tables + */ + public function setDeltaTracking(bool $enabled): void + { + $this->deltaTracking = $enabled; + } + + /** + * Record a touched pair in the relation's delta table (inside the open + * transaction). Deduplicated per transaction; the exact rows are removed + * again in cleanupDeltaTables() before commit. + */ + protected function recordDeltaPair(Relation $relation, string $srcAtomId, string $tgtAtomId): void + { + if (!$this->deltaTracking) { + return; + } + // Only record inside an open DB transaction: a write in autocommit mode + // (e.g. the session's lastAccess update) commits immediately, so its + // delta row would outlive the request — the pre-commit cleanup never + // runs for it. Conjunct evaluation of such writes does not go through + // Transaction::close anyway. + if (!$this->dbTransactionActive) { + return; + } + $deltaTable = $relation->getDeltaTable(); + if ($deltaTable === null) { + return; + } + $key = "{$srcAtomId}|{$tgtAtomId}"; + if (isset($this->deltaRecordedPairs[$deltaTable][$key])) { + return; + } + $this->deltaRecordedPairs[$deltaTable][$key] = [$srcAtomId, $tgtAtomId]; + $this->deltaTouchedRelations[$relation->signature] = $deltaTable; + // INSERT IGNORE: a leftover row (from an aborted run) only widens the + // candidate set, which stays correct; it must not break the insert. + $this->execute("INSERT IGNORE INTO \"{$deltaTable}\" (\"src\", \"tgt\") VALUES ('{$srcAtomId}', '{$tgtAtomId}')"); + } + + /** + * Mark a relation as bulk-mutated in the open transaction (its removed + * pairs are not individually recorded) + */ + protected function markDeltaBulkMutated(Relation $relation): void + { + if (!$this->deltaTracking) { + return; + } + $this->deltaBulkMutated[$relation->signature] = true; + } + + /** + * Touched relations of the open transaction: signature => delta table name + * + * @return array + */ + public function getDeltaTouchedRelations(): array + { + return $this->deltaTouchedRelations; + } + + /** + * True when the relation underwent a bulk mutation in the open transaction + */ + public function isDeltaBulkMutated(string $relationSignature): bool + { + return isset($this->deltaBulkMutated[$relationSignature]); + } + + /** + * Remove exactly the delta rows this transaction inserted, then forget the + * administration. Runs inside the open transaction (before COMMIT). + */ + protected function cleanupDeltaTables(): void + { + foreach ($this->deltaRecordedPairs as $deltaTable => $pairs) { + foreach ($pairs as [$srcAtomId, $tgtAtomId]) { + $this->execute("DELETE FROM \"{$deltaTable}\" WHERE \"src\" = '{$srcAtomId}' AND \"tgt\" = '{$tgtAtomId}'"); + } + } + $this->clearDeltaAdministration(); + } + + /** + * Forget the per-transaction delta administration + */ + protected function clearDeltaAdministration(): void + { + $this->deltaRecordedPairs = []; + $this->deltaTouchedRelations = []; + $this->deltaBulkMutated = []; + } /************************************************************************************************** * @@ -657,9 +787,11 @@ public function addLink(Link $link): void default: throw new FatalException("Unsupported TableType '{$relTable->inTableOf()->value}' to addLink for for relation '{$relation}'"); } - + // Check if query resulted in an affected row $this->checkForAffectedRows(); + + $this->recordDeltaPair($relation, $srcAtomId, $tgtAtomId); } /** @@ -697,8 +829,10 @@ public function deleteLink(Link $link): void default: throw new FatalException("Unsupported TableType '{$relTable->inTableOf()->value}' to deleteLink for for relation '{$relation}'"); } - + $this->checkForAffectedRows(); // Check if query resulted in an affected row + + $this->recordDeltaPair($relation, $srcAtomId, $tgtAtomId); } /** @@ -733,8 +867,12 @@ public function deleteAllLinks(Relation $relation, Atom $atom, SrcOrTgt $srcOrTg default: throw new FatalException("Unsupported TableType '{$relationTable->inTableOf()->value}' to deleteAllLinks for for relation '{$relation}'"); } - + $this->execute($query); + + // The removed pairs are not individually recorded; conjuncts on this + // relation need full re-evaluation this transaction. + $this->markDeltaBulkMutated($relation); } /** @@ -757,6 +895,10 @@ public function emptyRelation(Relation $relation): void default: throw new FatalException("Unknown 'tableOf' option for relation '{$relation}'"); } + + // The removed pairs are not individually recorded; conjuncts on this + // relation need full re-evaluation this transaction. + $this->markDeltaBulkMutated($relation); } /************************************************************************************************** diff --git a/backend/src/Ampersand/Rule/Conjunct.php b/backend/src/Ampersand/Rule/Conjunct.php index 50a0e4232..cae9217e8 100644 --- a/backend/src/Ampersand/Rule/Conjunct.php +++ b/backend/src/Ampersand/Rule/Conjunct.php @@ -76,7 +76,23 @@ class Conjunct * Specifies if conjunct is already evaluated */ protected bool $isEvaluated = false; - + + /** + * Candidate queries for delta-scoped re-evaluation (issue Ampersand#1684), + * keyed by relation signature. Null when the compiler did not emit them + * (older compiler, or the violation term falls outside the supported class). + * + * @var array|null + */ + protected ?array $deltaQueries = null; + + /** + * True when this conjunct's cache rows were maintained by the delta + * protocol in the current transaction; commit must then not overwrite + * them wholesale from the (unevaluated) in-memory cache item. + */ + protected bool $maintainedByDelta = false; + /** * Constructor */ @@ -91,12 +107,19 @@ public function __construct( $this->logger = $logger; $this->app = $app; $this->database = $database; - + $this->id = $conjDef['id']; $this->query = $conjDef['violationsSQL']; $this->invRuleNames = (array)$conjDef['invariantRuleNames']; $this->sigRuleNames = (array)$conjDef['signalRuleNames']; + if (isset($conjDef['deltaQueries'])) { + $this->deltaQueries = []; + foreach ((array)$conjDef['deltaQueries'] as $dq) { + $this->deltaQueries[$dq['relation']] = $dq; + } + } + $this->cachePool = $cachePool; $this->cacheItem = $cachePool->getItem($this->id); } @@ -227,9 +250,98 @@ public function evaluate(): self public function persistCacheItem(): void { + // Delta-maintained cache rows are already correct in the database + // (updated inside the open transaction); a wholesale replace from the + // unevaluated in-memory item would be wasted work at best. + if ($this->maintainedByDelta) { + return; + } $this->cachePool->save($this->cacheItem); } + /** + * True when the compiler emitted candidate queries for this conjunct + * (delta-scoped re-evaluation, issue Ampersand#1684) + */ + public function hasDeltaQueries(): bool + { + return $this->deltaQueries !== null; + } + + /** + * True when a candidate query exists for the given relation signature + */ + public function hasDeltaQueryFor(string $relationSignature): bool + { + return isset($this->deltaQueries[$relationSignature]); + } + + /** + * Maintain this conjunct's rows in the violation cache table with the + * delta protocol (issue Ampersand#1684): per touched relation, delete the + * cache rows in the candidate set and re-insert the violation rows + * restricted to that candidate set. Runs inside the open DB transaction, + * so the rows commit or roll back together with the data. The candidate + * queries read the relation's delta table, which holds the pairs this + * transaction touched. + * + * @param string[] $relationSignatures touched relations (each must have a candidate query) + */ + public function deltaMaintain(array $relationSignatures, string $cacheTableName): void + { + $violSQL = $this->getQuery(); + foreach ($relationSignatures as $sig) { + $dq = $this->deltaQueries[$sig] ?? null; + if ($dq === null) { + throw new Exception("Conjunct '{$this->id}' has no candidate query for relation '{$sig}'"); + } + $candSQL = str_replace('_SESSION', session_id(), $dq['candidateSQL']); + $inCands = fn (string $alias): string => + "({$alias}.\"src\", {$alias}.\"tgt\") IN (SELECT \"src\", \"tgt\" FROM ({$candSQL}) AS cand)"; + + $this->database->execute( + "DELETE c FROM \"{$cacheTableName}\" AS c" + . " WHERE c.\"conjId\" = '{$this->id}' AND " . $inCands('c') + ); + $this->database->execute( + "INSERT INTO \"{$cacheTableName}\" (\"conjId\", \"src\", \"tgt\")" + . " SELECT '{$this->id}', v.\"src\", v.\"tgt\" FROM ({$violSQL}) AS v" + . " WHERE " . $inCands('v') + ); + } + $this->maintainedByDelta = true; + + // Refresh the in-memory cache item from the just-maintained table rows, + // so the invariant check reads current data even when the item was + // already memoized earlier in this request. Deliberately without + // saveDeferred: the table rows are the source of truth here. + $this->cacheItem->set($this->getViolationsFromDbCache($cacheTableName)); + + $this->logger->debug("Conjunct '{$this->id}' cache maintained by delta protocol for relations: " . implode(', ', $relationSignatures)); + } + + /** + * Read this conjunct's current rows from the violation cache table. + * Inside an open transaction this sees the delta-maintained state. + * + * @return array{conjId: string, src: string, tgt: string}[] + */ + public function getViolationsFromDbCache(string $cacheTableName): array + { + $rows = $this->database->execute( + "SELECT \"conjId\", \"src\", \"tgt\" FROM \"{$cacheTableName}\" WHERE \"conjId\" = '{$this->id}'" + ); + return is_array($rows) ? $rows : []; + } + + /** + * Reset the per-transaction delta administration + */ + public function resetDeltaMaintained(): void + { + $this->maintainedByDelta = false; + } + public function showInfo(): array { return [ 'id' => $this->id diff --git a/backend/src/Ampersand/Transaction.php b/backend/src/Ampersand/Transaction.php index f4b899926..a6efbe4c6 100644 --- a/backend/src/Ampersand/Transaction.php +++ b/backend/src/Ampersand/Transaction.php @@ -10,6 +10,7 @@ use Exception; use Ampersand\Core\Concept; use Ampersand\Core\Relation; +use Ampersand\Plugs\MysqlConjunctCache\MysqlConjunctCache; use Ampersand\Plugs\StorageInterface; use Ampersand\Rule\Conjunct; use Ampersand\Rule\RuleEngine; @@ -64,6 +65,14 @@ class Transaction */ private array $affectedRelations = []; + /** + * Conjuncts whose cache rows were maintained by the delta protocol in + * this transaction (issue Ampersand#1684) + * + * @var \Ampersand\Rule\Conjunct[] + */ + private array $deltaMaintainedConjuncts = []; + /** * Specifies if invariant rules hold * @@ -352,17 +361,16 @@ protected function doClose(bool $dryRun, bool $ignoreInvariantViolations, bool $ } // (Re)evaluate affected conjuncts - $skipCleanConjuncts = $this->app->getSettings()->get('transactions.skipCleanConjuncts', false); - foreach ($this->getAffectedConjuncts() as $conj) { - // A conjunct that was evaluated in this transaction (typically by the ExecEngine's - // last fixpoint iteration) with no mutation registered afterwards would evaluate - // to the same result; its in-memory result already serves the invariant check and - // the cache persist below. See issue #443. - if ($skipCleanConjuncts && $this->isCleanSinceEvaluation($conj)) { - $this->logger->debug("Skip evaluation of conjunct '{$conj}': evaluated in this transaction with no mutations afterwards"); - continue; + $deltaMode = $this->app->getSettings()->get('transactions.deltaConjunctMaintenance', 'off'); + if ($deltaMode === 'off') { + foreach ($this->getAffectedConjuncts() as $conj) { + if ($this->isSkippableCleanConjunct($conj)) { + continue; + } + $conj->evaluate(); // violations are persisted below, only when transaction is committed } - $conj->evaluate(); // violations are persisted below, only when transaction is committed + } else { + $this->evaluateAffectedConjunctsWithDelta($deltaMode === 'shadow'); } // Check invariant rules @@ -386,9 +394,149 @@ protected function doClose(bool $dryRun, bool $ignoreInvariantViolations, bool $ } self::$currentTransaction = null; // unset currentTransaction + + // Reset the per-transaction delta administration of the conjuncts. + // Must happen after commit()/rollback(): commit's persistCacheItem + // consults the maintainedByDelta flag. + foreach ($this->deltaMaintainedConjuncts as $conj) { + $conj->resetDeltaMaintained(); + } + $this->deltaMaintainedConjuncts = []; + return $this; } + /** + * True when transactions.skipCleanConjuncts allows keeping the conjunct's in-memory result: + * it was evaluated in this transaction (typically by the ExecEngine's last fixpoint + * iteration) with no mutation registered afterwards, so it would evaluate to the same + * result; the in-memory result already serves the invariant check and the cache persist + * at commit. See issue #443. + */ + protected function isSkippableCleanConjunct(Conjunct $conj): bool + { + if (!$this->app->getSettings()->get('transactions.skipCleanConjuncts', false) + || !$this->isCleanSinceEvaluation($conj)) { + return false; + } + $this->logger->debug("Skip evaluation of conjunct '{$conj}': evaluated in this transaction with no mutations afterwards"); + return true; + } + + /** + * Evaluate the affected conjuncts with delta-scoped re-evaluation where + * possible (issue Ampersand#1684), full evaluation otherwise. + * + * A conjunct is maintained by the delta protocol only when the compiler + * emitted candidate queries for it, it is not affected via a touched + * concept (the candidate calculus covers relation changes only), and every + * touched relation that affects it has a recorded delta and a candidate + * query. Anything else keeps today's full evaluation — correctness never + * depends on the delta path. + * + * In shadow mode the delta protocol runs first, then the conjunct is + * evaluated in full as before; a difference between the two results is + * logged as an error. The fully evaluated result remains authoritative + * (it is persisted at commit, wholesale, exactly as without this feature). + */ + protected function evaluateAffectedConjunctsWithDelta(bool $shadow): void + { + $pool = $this->app->getConjunctCache(); + if (!$pool instanceof MysqlConjunctCache) { + $this->logger->warning("Delta conjunct maintenance requested, but no MySQL-backed conjunct cache is available; keeping full evaluation"); + foreach ($this->getAffectedConjuncts() as $conj) { + if ($this->isSkippableCleanConjunct($conj)) { + continue; + } + $conj->evaluate(); + } + return; + } + $db = $pool->getDatabase(); + $cacheTable = $pool->getTableName(); + $touched = $db->getDeltaTouchedRelations(); // relation signature => delta table name + + // Conjuncts affected via a touched concept keep full evaluation + $conceptConjunctIds = []; + foreach ($this->affectedConcepts as $concept) { + foreach ($concept->getRelatedConjuncts() as $conj) { + $conceptConjunctIds[$conj->getId()] = true; + } + } + + $countDelta = 0; + $countFull = 0; + $countSkipped = 0; + foreach ($this->getAffectedConjuncts() as $conj) { + // A clean conjunct (see isSkippableCleanConjunct) keeps its in-memory result, + // which commit persists wholesale; neither evaluation nor delta maintenance needed. + if ($this->isSkippableCleanConjunct($conj)) { + $countSkipped++; + continue; + } + $sigs = []; + $eligible = $conj->hasDeltaQueries() && !isset($conceptConjunctIds[$conj->getId()]); + if ($eligible) { + foreach ($this->affectedRelations as $relation) { + if (!in_array($conj, $relation->getRelatedConjuncts())) { + continue; + } + $sig = $relation->signature; + if (!isset($touched[$sig]) || $db->isDeltaBulkMutated($sig) || !$conj->hasDeltaQueryFor($sig)) { + $eligible = false; + break; + } + $sigs[] = $sig; + } + } + + if (!$eligible || empty($sigs)) { + $conj->evaluate(); + $countFull++; + continue; + } + + $conj->deltaMaintain($sigs, $cacheTable); + $this->deltaMaintainedConjuncts[] = $conj; + $countDelta++; + + if ($shadow) { + $deltaRows = array_map( + fn (array $row): string => "{$row['src']}|{$row['tgt']}", + $conj->getViolationsFromDbCache($cacheTable) + ); + sort($deltaRows); + + // Full evaluation stays authoritative: it refreshes the + // in-memory item and is persisted at commit as before. + $conj->resetDeltaMaintained(); + array_pop($this->deltaMaintainedConjuncts); + $conj->evaluate(); + + $fullRows = array_map( + fn (array $row): string => "{$row['src']}|{$row['tgt']}", + $conj->getViolations() + ); + sort($fullRows); + + if ($deltaRows !== $fullRows) { + $this->logger->error( + "DELTA SHADOW MISMATCH in conjunct '{$conj->getId()}': " + . "delta " . count($deltaRows) . " rows, full " . count($fullRows) . " rows. " + . "delta-only: " . implode(', ', array_slice(array_diff($deltaRows, $fullRows), 0, 3)) . "; " + . "full-only: " . implode(', ', array_slice(array_diff($fullRows, $deltaRows), 0, 3)) + ); + } else { + // Notice level: in a shadow run these lines are the evidence + // of the divergence-free period, so they must reach the log. + $this->logger->notice("Delta shadow check for conjunct '{$conj->getId()}': identical (" . count($fullRows) . " rows)"); + } + } + } + $mode = $shadow ? 'shadow' : 'on'; + $this->logger->debug("Delta conjunct maintenance ('{$mode}'): {$countDelta} delta-maintained, {$countFull} evaluated in full, {$countSkipped} skipped as clean"); + } + /** * Commit transaction */ diff --git a/changelog.md b/changelog.md index 6484292ba..3a81ea9f4 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,19 @@ Additional labels for pre-release and build metadata are available as extensions ## Unreleased +* **The violation cache can be maintained incrementally per transaction (feature switch, default off).** + For conjuncts whose compiler emitted candidate queries (Ampersand `delta-sql`, + [Ampersand#1684](https://github.com/AmpersandTarski/Ampersand/issues/1684)), the framework + can maintain `__conj_violation_cache__` by delta-scoped re-evaluation: the touched pairs of + each transaction are recorded in the relation's delta table, and only the candidate rows are + rechecked at commit. The setting `transactions.deltaConjunctMaintenance` knows three modes: + `off` (default — behaviour and performance identical to today, delta recording disabled), + `shadow` (both routes run; the full result stays authoritative and any difference is logged + as `DELTA SHADOW MISMATCH`) and `on` (the delta path maintains the cache for the supported + class; anything touched via a concept, a bulk mutation or a relation without candidate + queries keeps full evaluation). With generics from a compiler without `deltaQueries` the + setting is a no-op. + * **The regression suite is green again on `main`.** `test/projects/project-administration` compiles once more (its model used the pre-v4 `rel :: A * B` relation syntax), and `test/projects/ifc45` — which cannot install until the compiler lays broad tables out diff --git a/test/projects/delta-conjunct-maintenance/README.md b/test/projects/delta-conjunct-maintenance/README.md new file mode 100644 index 000000000..61943c335 --- /dev/null +++ b/test/projects/delta-conjunct-maintenance/README.md @@ -0,0 +1,24 @@ +# delta-conjunct-maintenance + +**Guards:** that `transactions.deltaConjunctMaintenance` (Ampersand#1684) changes no observable +behavior: the booking scenario of `skip-clean-conjuncts` produces an identical digest under +`off`, `shadow` and `on`, and — with the bundled compiler, which emits no `deltaQueries` — the +non-off modes route every conjunct to full evaluation ("0 delta-maintained" in the close's +summary line, no shadow mismatch). + +The project reuses the model of `skip-clean-conjuncts` (`regression.conf` points its entry +there) and the shared scenario in `test/shared/conjunct-parity.mjs`; the spec adds the three-mode +phase logic and the log-based proof that the delta path ran but had nothing to maintain. + +Run it with: + +```bash +test/run-regression.sh delta-conjunct-maintenance +``` + +**Follow-up:** once a compiler that emits `deltaQueries` is bundled, extend the spec so that +the summary line reports delta-maintained conjuncts and the shadow run logs "identical" +checks — that becomes the guard of the delta protocol itself (see Ampersand#1684). + +The spec temporarily replaces `backend/config/project.yaml` and `backend/config/logging.php` +(DEBUG to a bind-mounted file) and restores both afterwards. diff --git a/test/projects/delta-conjunct-maintenance/e2e/parity.mjs b/test/projects/delta-conjunct-maintenance/e2e/parity.mjs new file mode 100644 index 000000000..1461e96e3 --- /dev/null +++ b/test/projects/delta-conjunct-maintenance/e2e/parity.mjs @@ -0,0 +1,123 @@ +/** + * Regression test for transactions.deltaConjunctMaintenance (Ampersand#1684). + * + * The setting selects how the transaction close maintains the violation cache: + * `off` (full re-evaluation), `shadow` (delta protocol plus full evaluation, + * full result authoritative, differences logged) or `on` (delta protocol for + * the supported class). The delta protocol needs candidate queries that only a + * compiler with delta-sql emits; with the compiler bundled in this repository + * every conjunct lacks them, so all three modes must behave identically and the + * non-off modes must route every conjunct to full evaluation. + * + * This spec runs one API-level scenario under each mode and requires: + * 1. byte-identical digests across off, shadow and on; + * 2. inside each run: rollback on a violating edit, commit on valid edits, the + * ExecEngine repair, and signals that follow the data; + * 3. the non-off modes really ran: the close's summary debug line names the + * mode and reports "0 delta-maintained"; no conjunct went through the delta + * protocol and no shadow mismatch was logged. + * + * When a compiler that emits deltaQueries is bundled, extend this spec so the + * summary line reports delta-maintained conjuncts and the shadow run logs + * "identical" checks — that is the guard of the delta path itself. + * + * Run via `test/run-regression.sh delta-conjunct-maintenance`. + */ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { + bookingScenario, countIn, debugLoggingPhp, makeClient, reporter, runInstaller, + settingsYaml, waitForCanary, waitForDebugLog, writeConfig, +} from '../../../shared/conjunct-parity.mjs'; + +const SPEC = 'test/projects/delta-conjunct-maintenance/e2e/parity.mjs'; +const e2eDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(e2eDir, '../../../..'); +const projectYaml = resolve(repoRoot, 'backend/config/project.yaml'); +const loggingPhp = resolve(repoRoot, 'backend/config/logging.php'); +const debugLog = resolve(e2eDir, '.debug.log'); +const baseUrl = process.env.PROTOTYPE_URL ?? 'http://localhost'; + +const originalYaml = readFileSync(projectYaml, 'utf8'); +const originalLogging = readFileSync(loggingPhp, 'utf8'); +const report = reporter(); +const { assert } = report; +const client = makeClient(baseUrl); + +const SUMMARY = (mode) => `Delta conjunct maintenance ('${mode}'):`; +const DELTA_MAINTAINED = 'cache maintained by delta protocol for relations'; +const MISMATCH = 'DELTA SHADOW MISMATCH'; + +// Lines of the summary form "Delta conjunct maintenance (''): N delta-maintained, ..." +function summaryLines(mode) { + if (!existsSync(debugLog)) { + return []; + } + return readFileSync(debugLog, 'utf8') + .split('\n') + .filter((line) => line.includes(SUMMARY(mode))); +} + +const digests = {}; +try { + console.log('▶ Enabling DEBUG logging to a bind-mounted file (temporary logging.php)'); + rmSync(debugLog, { force: true }); + writeConfig(loggingPhp, debugLoggingPhp(`/var/www/${SPEC.replace(/parity\.mjs$/, '.debug.log')}`, SPEC)); + await waitForDebugLog(client, debugLog); + + for (const mode of ['off', 'shadow', 'on']) { + console.log(`\n▶ transactions.deltaConjunctMaintenance: '${mode}'`); + // The menuMode value is a canary: it proves the backend reads this file + writeConfig(projectYaml, settingsYaml({ + 'transactions.deltaConjunctMaintenance': `'${mode}'`, + 'frontend.menuMode': `canary-${mode}`, + }, SPEC)); + await runInstaller(baseUrl); + await waitForCanary(client, `canary-${mode}`); + + // Baselines: the log also holds lines from before this phase (the runner + // installs with whatever project.yaml the working copy had) + const summariesNow = () => (mode === 'off' + ? summaryLines('shadow').length + summaryLines('on').length + : summaryLines(mode).length); + const before = { + delta: countIn(debugLog, DELTA_MAINTAINED), + mismatch: countIn(debugLog, MISMATCH), + summaries: summariesNow(), + }; + digests[mode] = await bookingScenario(client, mode, assert); + + const deltaMaintained = countIn(debugLog, DELTA_MAINTAINED) - before.delta; + const mismatches = countIn(debugLog, MISMATCH) - before.mismatch; + assert(deltaMaintained === 0, `[${mode}] no conjunct went through the delta protocol (saw ${deltaMaintained})`); + assert(mismatches === 0, `[${mode}] no shadow mismatch logged (saw ${mismatches})`); + if (mode === 'off') { + const ran = summariesNow() - before.summaries; + assert(ran === 0, `[off] the delta path did not run (saw ${ran} summary lines)`); + } else { + const lines = summaryLines(mode).slice(before.summaries); + assert(lines.length > 0, `[${mode}] the delta path ran (saw ${lines.length} close summaries)`); + assert(lines.every((l) => l.includes(' 0 delta-maintained,')), + `[${mode}] every close reports 0 delta-maintained (compiler without deltaQueries)`); + } + } + + console.log('\n▶ Parity across modes'); + assert(digests.off === digests.shadow, 'digests are identical for off and shadow'); + assert(digests.off === digests.on, 'digests are identical for off and on'); + for (const mode of ['shadow', 'on']) { + if (digests.off !== digests[mode]) { + console.error(`--- digest off ---\n${digests.off}\n--- digest ${mode} ---\n${digests[mode]}`); + } + } +} catch (e) { + report.fail(e.message); +} finally { + // Leave the working copy as found + writeFileSync(projectYaml, originalYaml); + writeFileSync(loggingPhp, originalLogging); + rmSync(debugLog, { force: true }); +} + +process.exit(report.failures === 0 ? 0 : 1); diff --git a/test/projects/delta-conjunct-maintenance/regression.conf b/test/projects/delta-conjunct-maintenance/regression.conf new file mode 100644 index 000000000..a922e1986 --- /dev/null +++ b/test/projects/delta-conjunct-maintenance/regression.conf @@ -0,0 +1,2 @@ +# Entry model of this project: the booking model of skip-clean-conjuncts (shared scenario) +entry=../skip-clean-conjuncts/model/main.adl diff --git a/test/projects/skip-clean-conjuncts/e2e/parity.mjs b/test/projects/skip-clean-conjuncts/e2e/parity.mjs index 749fbdaef..de27b4045 100644 --- a/test/projects/skip-clean-conjuncts/e2e/parity.mjs +++ b/test/projects/skip-clean-conjuncts/e2e/parity.mjs @@ -20,10 +20,15 @@ * the backend API), or against the dev stack with this project compiled: * node test/projects/skip-clean-conjuncts/e2e/parity.mjs */ -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; +import { + activateAnonymous, bookingScenario, countIn, debugLoggingPhp, makeClient, + reporter, runInstaller, settingsYaml, sleep, writeConfig, +} from '../../../shared/conjunct-parity.mjs'; +const SPEC = 'test/projects/skip-clean-conjuncts/e2e/parity.mjs'; const e2eDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(e2eDir, '../../../..'); const projectYaml = resolve(repoRoot, 'backend/config/project.yaml'); @@ -33,136 +38,27 @@ const baseUrl = process.env.PROTOTYPE_URL ?? 'http://localhost'; const originalYaml = readFileSync(projectYaml, 'utf8'); const originalLogging = readFileSync(loggingPhp, 'utf8'); - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -let failures = 0; -function assert(cond, msg) { - if (cond) { - console.log(` ✅ ${msg}`); - } else { - console.error(` ❌ ${msg}`); - failures++; - } -} - -// ── temporary config, restored in the finally below ───────────────────────────── - -// The dev logging config buffers DEBUG lines and only dumps them on an ERROR -// (FingersCrossed). To observe the skip's debug line we log DEBUG to a file on -// the bind mount, which this spec reads back directly — no docker access needed. -// Effectiveness of these config writes is awaited behaviorally (see -// waitForSettingEffective): the macOS bind mount can serve Apache a stale file -// for a short while after the host wrote it. -const debugLogging = String.raw` 'REMOTE_ADDR', - 'method' => 'REQUEST_METHOD', - 'url' => 'REQUEST_URI', -])]; -$handlers = [ - new StreamHandler('/var/www/test/projects/skip-clean-conjuncts/e2e/.debug.log', level: MonologLogger::DEBUG), - new StreamHandler('php://stderr', level: MonologLogger::WARNING), -]; -foreach (['EXECENGINE', 'IO', 'API', 'APPLICATION', 'DATABASE', 'CORE', 'RULEENGINE', 'TRANSACTION', 'INTERFACING'] as $name) { - Registry::addLogger(new MonologLogger($name, $handlers, $processors)); -} -`; - -function setSettings(settings) { - const lines = Object.entries(settings) - .map(([k, v]) => ` ${k}: ${v}`) - .join('\n'); - const content = `# TEMPORARY test config, written by test/projects/skip-clean-conjuncts/e2e/parity.mjs\nsettings:\n${lines}\n`; - writeFileSync(projectYaml, content); -} - -// ── debug-log observation ─────────────────────────────────────────────────────── +const report = reporter(); +const { assert } = report; +const client = makeClient(baseUrl); const SKIP_LINE = 'Skip evaluation of conjunct'; -function skipCount() { - if (!existsSync(debugLog)) { - return 0; - } - return readFileSync(debugLog, 'utf8').split(SKIP_LINE).length - 1; -} - -// ── API access with one session per scenario run ──────────────────────────────── - -let cookies; -async function api(path, opts = {}) { - const headers = { 'Content-Type': 'application/json', ...opts.headers }; - const cookie = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); - if (cookie) { - headers.Cookie = cookie; - } - const resp = await fetch(`${baseUrl}/api/v1/${path}`, { ...opts, headers }); - for (const sc of resp.headers.getSetCookie?.() ?? []) { - const [k, ...v] = sc.split(';')[0].split('='); - cookies.set(k.trim(), v.join('=')); - } - const text = await resp.text(); - let body; - try { - body = JSON.parse(text); - } catch { - throw new Error(`${path}: HTTP ${resp.status}, non-JSON body: ${text.slice(0, 300)}`); - } - return { status: resp.status, body }; -} - -async function runInstaller() { - const r = await fetch(`${baseUrl}/api/v1/admin/installer`); - if (!r.ok) { - throw new Error(`installer failed: ${r.status} ${await r.text()}`); - } -} - -// Reduce a notifications object to the messages that matter for parity -function messagesOf(notifications, key) { - return (notifications?.[key] ?? []) - .map((n) => (typeof n === 'string' ? n : JSON.stringify(n))) - .sort(); -} - -function record(step, resp) { - return { - step, - status: resp.status, - isCommitted: resp.body.isCommitted, - invariantRulesHold: resp.body.invariantRulesHold, - invariants: messagesOf(resp.body.notifications, 'invariants'), - signals: messagesOf(resp.body.notifications, 'signals'), - }; -} +const skipCount = () => countIn(debugLog, SKIP_LINE); // Create a fresh session (the role activation request carries the // session-creation transaction) and return how many skip lines it produced. async function sessionProbe() { const before = skipCount(); - cookies = new Map(); - await api('app/roles', { - method: 'PATCH', - body: JSON.stringify([{ id: 'Anonymous', active: true }]), - }); + await activateAnonymous(client); return skipCount() - before; } // Wait until the backend's behavior matches the flipped setting: a fresh // session's transaction close skips conjuncts exactly when the setting is on. -// This also covers the temporary logging.php becoming effective, since the -// skip is only observable through it. +// This covers both the project.yaml and the temporary logging.php becoming +// effective (the macOS bind mount can serve Apache a stale file for a short +// while after the host wrote it), since the skip is only observable through +// the log. async function waitForSettingEffective(on) { const deadline = Date.now() + 20000; for (;;) { @@ -177,119 +73,26 @@ async function waitForSettingEffective(on) { } } -// The scenario. Returns a normalized JSON digest that must be identical with -// the setting off and on. -async function scenario(label) { - cookies = new Map(); // fresh session - const records = []; - - // The Overview interface is FOR Anonymous; activate that role in this session - // (a fresh session activates roles per the compiled PrototypeContext, which - // need not include Anonymous). - await api('app/roles', { - method: 'PATCH', - body: JSON.stringify([{ id: 'Anonymous', active: true }]), - }); - - // Discover the interface content and its field keys from a real response, - // instead of assuming the API's field names (they are escaped labels). - const overview = await api('resource/SESSION/1/Overview'); - const sessionAtom = overview.body._id_; - const bookingsKey = Object.keys(overview.body).find((k) => !k.startsWith('_') && Array.isArray(overview.body[k])); - if (!bookingsKey) { - throw new Error(`no list field in Overview; keys: ${Object.keys(overview.body)}`); - } - const listPath = `${overview.body._path_}/${bookingsKey}`; - const rows = () => api(`resource/SESSION/1/Overview`).then((r) => r.body[bookingsKey]); - let bookings = overview.body[bookingsKey]; - const row0 = bookings[0]; - const guestKey = Object.keys(row0).find((k) => k.includes('Guest')); - const confKey = Object.keys(row0).find((k) => k.includes('Confirmed')); - if (!guestKey || !confKey) { - throw new Error(`field keys not found in row; keys: ${Object.keys(row0)}`); - } - const pathOf = (id) => bookings.find((r) => r._id_ === id)._path_; - records.push({ step: 'initial list', ids: bookings.map((r) => r._id_).sort(), keys: [guestKey, confKey] }); - - // 1. Valid edit: name booking1 → must commit; its "needs a name" signal must clear - let r = await api(pathOf('booking1'), { - method: 'PATCH', - body: JSON.stringify([{ op: 'replace', path: guestKey, value: 'Alice' }]), - }); - records.push(record('name booking1', r)); - assert(r.body.isCommitted === true, `[${label}] naming booking1 commits`); - assert(!r.body.notifications.signals.some((s) => JSON.stringify(s).includes('booking1')), - `[${label}] booking1 signal cleared after naming`); - - // 2. Valid edit on a named booking: confirm booking1 → must commit - r = await api(pathOf('booking1'), { - method: 'PATCH', - body: JSON.stringify([{ op: 'replace', path: confKey, value: true }]), - }); - records.push(record('confirm booking1', r)); - assert(r.body.isCommitted === true, `[${label}] confirming named booking1 commits`); - - // 3. Invariant violation: confirm nameless booking2 → must roll back - r = await api(pathOf('booking2'), { - method: 'PATCH', - body: JSON.stringify([{ op: 'replace', path: confKey, value: true }]), - }); - records.push(record('confirm booking2 (violates)', r)); - assert(r.body.isCommitted === false && r.body.invariantRulesHold === false, - `[${label}] confirming nameless booking2 is rejected`); - assert(JSON.stringify(r.body.notifications.invariants).includes('booking2'), - `[${label}] invariant message names booking2`); - - // 4. The rollback is real: booking2 is not confirmed in the database - bookings = await rows(); - const b2 = bookings.find((row) => row._id_ === 'booking2'); - records.push({ step: 'after rollback', booking2Confirmed: b2[confKey] }); - assert(b2[confKey] === false || b2[confKey] == null, `[${label}] booking2 stays unconfirmed after rollback`); - - // 5. Create a booking → must commit, ExecEngine adds it to the session list, - // and its "needs a name" signal appears (violation cache gets the new row) - r = await api(listPath, { - method: 'POST', - body: JSON.stringify({}), - }); - const newId = r.body.content?._id_; - records.push(record('create booking', r)); - assert(r.body.isCommitted === true && typeof newId === 'string', `[${label}] creating a booking commits`); - bookings = await rows(); - records.push({ step: 'final list', ids: bookings.map((row) => row._id_).sort() }); - assert(bookings.some((row) => row._id_ === newId), `[${label}] new booking appears in the session list`); - assert(r.body.notifications.signals.some((s) => JSON.stringify(s).includes(newId)), - `[${label}] new booking raises the needs-a-name signal`); - - // Normalize what differs per run by construction: the generated atom id and - // the session atom id (it can appear in _path_-like strings inside signals) - return JSON.stringify(records, null, 1) - .replaceAll(newId, '«new-booking»') - .replaceAll(sessionAtom, '«session»'); -} - -// ── main ──────────────────────────────────────────────────────────────────────── - try { console.log('▶ Enabling DEBUG logging to a bind-mounted file (temporary logging.php)'); rmSync(debugLog, { force: true }); - writeFileSync(loggingPhp, debugLogging); + writeConfig(loggingPhp, debugLoggingPhp(`/var/www/${SPEC.replace(/parity\.mjs$/, '.debug.log')}`, SPEC)); console.log('\n▶ Phase 1: transactions.skipCleanConjuncts off (default)'); - setSettings({}); - await runInstaller(); + writeConfig(projectYaml, settingsYaml({}, SPEC)); + await runInstaller(baseUrl); await waitForSettingEffective(false); const skipsBeforeOff = skipCount(); - const digestOff = await scenario('off'); + const digestOff = await bookingScenario(client, 'off', assert); const skipsDuringOff = skipCount() - skipsBeforeOff; assert(skipsDuringOff === 0, `no conjunct evaluation skipped with the setting off (saw ${skipsDuringOff})`); console.log('\n▶ Phase 2: transactions.skipCleanConjuncts on'); - setSettings({ 'transactions.skipCleanConjuncts': true }); - await runInstaller(); + writeConfig(projectYaml, settingsYaml({ 'transactions.skipCleanConjuncts': true }, SPEC)); + await runInstaller(baseUrl); await waitForSettingEffective(true); const skipsBeforeOn = skipCount(); - const digestOn = await scenario('on'); + const digestOn = await bookingScenario(client, 'on', assert); const skipsDuringOn = skipCount() - skipsBeforeOn; assert(skipsDuringOn > 0, `the skip fires with the setting on (saw ${skipsDuringOn} skipped evaluations)`); @@ -300,8 +103,7 @@ try { console.error('--- digest on ----\n' + digestOn); } } catch (e) { - console.error(` ❌ ${e.message}`); - failures++; + report.fail(e.message); } finally { // Leave the working copy as found writeFileSync(projectYaml, originalYaml); @@ -309,4 +111,4 @@ try { rmSync(debugLog, { force: true }); } -process.exit(failures === 0 ? 0 : 1); +process.exit(report.failures === 0 ? 0 : 1); diff --git a/test/shared/conjunct-parity.mjs b/test/shared/conjunct-parity.mjs new file mode 100644 index 000000000..101ad5ea4 --- /dev/null +++ b/test/shared/conjunct-parity.mjs @@ -0,0 +1,302 @@ +/** + * Shared pieces for the transaction-close parity specs + * (test/projects/skip-clean-conjuncts, test/projects/delta-conjunct-maintenance). + * + * Both specs run the same API scenario on the same model under different + * settings and require identical digests. This module holds the scenario, the + * cookie-aware API client, the temporary-config writers and the log counters; + * each spec keeps its own phase logic and its own proof that a setting fired. + * + * Everything here is plain Node (no child_process): the specs observe the + * backend through the API and through a DEBUG log written to a bind-mounted + * file, never through docker. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +// ── reporting ─────────────────────────────────────────────────────────────────── + +export function reporter() { + let failures = 0; + return { + assert(cond, msg) { + if (cond) { + console.log(` ✅ ${msg}`); + } else { + console.error(` ❌ ${msg}`); + failures++; + } + }, + fail(msg) { + console.error(` ❌ ${msg}`); + failures++; + }, + get failures() { + return failures; + }, + }; +} + +// ── temporary config ──────────────────────────────────────────────────────────── + +/** Content for backend/config/project.yaml with the given settings */ +export function settingsYaml(settings, writtenBy) { + const lines = Object.entries(settings) + .map(([k, v]) => ` ${k}: ${v}`) + .join('\n'); + return `# TEMPORARY test config, written by ${writtenBy}\nsettings:\n${lines}\n`; +} + +/** + * Content for backend/config/logging.php that logs DEBUG to a file at + * `containerLogPath` (a path on the bind mount, so the spec can read it back). + * The dev config buffers DEBUG lines and only dumps them on an ERROR + * (FingersCrossed), which hides the lines the specs need. + */ +export function debugLoggingPhp(containerLogPath, writtenBy) { + return String.raw` 'REMOTE_ADDR', + 'method' => 'REQUEST_METHOD', + 'url' => 'REQUEST_URI', +])]; +$handlers = [ + new StreamHandler('${containerLogPath}', level: MonologLogger::DEBUG), + new StreamHandler('php://stderr', level: MonologLogger::WARNING), +]; +foreach (['EXECENGINE', 'IO', 'API', 'APPLICATION', 'DATABASE', 'CORE', 'RULEENGINE', 'TRANSACTION', 'INTERFACING'] as $name) { + Registry::addLogger(new MonologLogger($name, $handlers, $processors)); +} +`; +} + +/** Number of occurrences of `needle` in the file (0 when the file does not exist) */ +export function countIn(file, needle) { + if (!existsSync(file)) { + return 0; + } + return readFileSync(file, 'utf8').split(needle).length - 1; +} + +/** Write a file (thin wrapper, so specs import one module) */ +export function writeConfig(path, content) { + writeFileSync(path, content); +} + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── API client ────────────────────────────────────────────────────────────────── + +/** + * A fetch wrapper that keeps the PHP session cookie, so a run of requests + * shares one Ampersand session. newSession() starts a fresh one. + */ +export function makeClient(baseUrl) { + let cookies = new Map(); + return { + newSession() { + cookies = new Map(); + }, + async api(path, opts = {}) { + const headers = { 'Content-Type': 'application/json', ...opts.headers }; + const cookie = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + if (cookie) { + headers.Cookie = cookie; + } + const resp = await fetch(`${baseUrl}/api/v1/${path}`, { ...opts, headers }); + for (const sc of resp.headers.getSetCookie?.() ?? []) { + const [k, ...v] = sc.split(';')[0].split('='); + cookies.set(k.trim(), v.join('=')); + } + const text = await resp.text(); + let body; + try { + body = JSON.parse(text); + } catch { + throw new Error(`${path}: HTTP ${resp.status}, non-JSON body: ${text.slice(0, 300)}`); + } + return { status: resp.status, body }; + }, + }; +} + +export async function runInstaller(baseUrl) { + const r = await fetch(`${baseUrl}/api/v1/admin/installer`); + if (!r.ok) { + throw new Error(`installer failed: ${r.status} ${await r.text()}`); + } +} + +/** + * Wait until the backend reads the project.yaml a spec just wrote. The navbar + * exposes `frontend.menuMode`; a spec writes a phase-specific value for it in + * the same file as the setting under test, so seeing the canary proves the + * backend sees that file (the macOS bind mount can serve Apache a stale copy + * for a short while after the host wrote it). + */ +export async function waitForCanary(client, expectedMenuMode, timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + client.newSession(); + const r = await client.api('app/navbar'); + if (r.body.menuMode === expectedMenuMode) { + return; + } + if (Date.now() > deadline) { + throw new Error(`project.yaml did not become effective (navbar menuMode is '${r.body.menuMode}', expected '${expectedMenuMode}')`); + } + await sleep(500); + } +} + +/** + * Wait until the temporary logging.php is effective: make requests until the + * debug log file exists and holds at least one line. Same bind-mount caveat as + * waitForCanary; the log is the only way to observe this config. + */ +export async function waitForDebugLog(client, debugLog, timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + client.newSession(); + await client.api('app/navbar'); + if (countIn(debugLog, '\n') > 0) { + return; + } + if (Date.now() > deadline) { + throw new Error(`debug log ${debugLog} did not appear (temporary logging.php not effective)`); + } + await sleep(500); + } +} + +// ── the scenario ──────────────────────────────────────────────────────────────── + +// Reduce a notifications object to the messages that matter for parity +function messagesOf(notifications, key) { + return (notifications?.[key] ?? []) + .map((n) => (typeof n === 'string' ? n : JSON.stringify(n))) + .sort(); +} + +function record(step, resp) { + return { + step, + status: resp.status, + isCommitted: resp.body.isCommitted, + invariantRulesHold: resp.body.invariantRulesHold, + invariants: messagesOf(resp.body.notifications, 'invariants'), + signals: messagesOf(resp.body.notifications, 'signals'), + }; +} + +/** + * Activate the Anonymous role in a fresh session. The Overview interface of + * the model is FOR Anonymous; a fresh session activates roles per the compiled + * PrototypeContext, which need not include Anonymous. The request carries the + * session-creation transaction. + */ +export async function activateAnonymous(client) { + client.newSession(); + return client.api('app/roles', { + method: 'PATCH', + body: JSON.stringify([{ id: 'Anonymous', active: true }]), + }); +} + +/** + * One API scenario on the booking model (test/projects/skip-clean-conjuncts/model): + * a committing edit, an ExecEngine repair, an invariant rollback, a create, and + * the accompanying signals. Returns a normalized JSON digest that must be + * identical whatever transaction-close optimisation is switched on. + */ +export async function bookingScenario(client, label, assert) { + const records = []; + const { api } = client; + await activateAnonymous(client); + + // Discover the interface content and its field keys from a real response, + // instead of assuming the API's field names (they are escaped labels). + const overview = await api('resource/SESSION/1/Overview'); + const sessionAtom = overview.body._id_; + const bookingsKey = Object.keys(overview.body).find((k) => !k.startsWith('_') && Array.isArray(overview.body[k])); + if (!bookingsKey) { + throw new Error(`no list field in Overview; keys: ${Object.keys(overview.body)}`); + } + const listPath = `${overview.body._path_}/${bookingsKey}`; + const rows = () => api(`resource/SESSION/1/Overview`).then((r) => r.body[bookingsKey]); + let bookings = overview.body[bookingsKey]; + const row0 = bookings[0]; + const guestKey = Object.keys(row0).find((k) => k.includes('Guest')); + const confKey = Object.keys(row0).find((k) => k.includes('Confirmed')); + if (!guestKey || !confKey) { + throw new Error(`field keys not found in row; keys: ${Object.keys(row0)}`); + } + const pathOf = (id) => bookings.find((r) => r._id_ === id)._path_; + records.push({ step: 'initial list', ids: bookings.map((r) => r._id_).sort(), keys: [guestKey, confKey] }); + + // 1. Valid edit: name booking1 → must commit; its "needs a name" signal must clear + let r = await api(pathOf('booking1'), { + method: 'PATCH', + body: JSON.stringify([{ op: 'replace', path: guestKey, value: 'Alice' }]), + }); + records.push(record('name booking1', r)); + assert(r.body.isCommitted === true, `[${label}] naming booking1 commits`); + assert(!r.body.notifications.signals.some((s) => JSON.stringify(s).includes('booking1')), + `[${label}] booking1 signal cleared after naming`); + + // 2. Valid edit on a named booking: confirm booking1 → must commit + r = await api(pathOf('booking1'), { + method: 'PATCH', + body: JSON.stringify([{ op: 'replace', path: confKey, value: true }]), + }); + records.push(record('confirm booking1', r)); + assert(r.body.isCommitted === true, `[${label}] confirming named booking1 commits`); + + // 3. Invariant violation: confirm nameless booking2 → must roll back + r = await api(pathOf('booking2'), { + method: 'PATCH', + body: JSON.stringify([{ op: 'replace', path: confKey, value: true }]), + }); + records.push(record('confirm booking2 (violates)', r)); + assert(r.body.isCommitted === false && r.body.invariantRulesHold === false, + `[${label}] confirming nameless booking2 is rejected`); + assert(JSON.stringify(r.body.notifications.invariants).includes('booking2'), + `[${label}] invariant message names booking2`); + + // 4. The rollback is real: booking2 is not confirmed in the database + bookings = await rows(); + const b2 = bookings.find((row) => row._id_ === 'booking2'); + records.push({ step: 'after rollback', booking2Confirmed: b2[confKey] }); + assert(b2[confKey] === false || b2[confKey] == null, `[${label}] booking2 stays unconfirmed after rollback`); + + // 5. Create a booking → must commit, ExecEngine adds it to the session list, + // and its "needs a name" signal appears (violation cache gets the new row) + r = await api(listPath, { + method: 'POST', + body: JSON.stringify({}), + }); + const newId = r.body.content?._id_; + records.push(record('create booking', r)); + assert(r.body.isCommitted === true && typeof newId === 'string', `[${label}] creating a booking commits`); + bookings = await rows(); + records.push({ step: 'final list', ids: bookings.map((row) => row._id_).sort() }); + assert(bookings.some((row) => row._id_ === newId), `[${label}] new booking appears in the session list`); + assert(r.body.notifications.signals.some((s) => JSON.stringify(s).includes(newId)), + `[${label}] new booking raises the needs-a-name signal`); + + // Normalize what differs per run by construction: the generated atom id and + // the session atom id (it can appear in _path_-like strings inside signals) + return JSON.stringify(records, null, 1) + .replaceAll(newId, '«new-booking»') + .replaceAll(sessionAtom, '«session»'); +}