From 35bbfa3f0ef4d6e6c28f7a7b7182612fcca3fe21 Mon Sep 17 00:00:00 2001 From: Josiah King Date: Tue, 15 Sep 2026 18:03:30 +0100 Subject: [PATCH] Add Insight SQLite diagnostic store --- CHANGELOG.md | 1 + packages/insight/README.md | 8 +- .../Storage/DiagnosticBatchSnapshotCodec.php | 212 ++++++++++++++++++ .../Storage/SqliteDiagnosticBatchStore.php | 149 ++++++++++++ .../DiagnosticBatchSnapshotCodecTest.php | 181 +++++++++++++++ .../SqliteDiagnosticBatchStoreTest.php | 209 +++++++++++++++++ .../EvolvePhp2PackageSkeletonTest.php | 2 + 7 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 packages/insight/src/Storage/DiagnosticBatchSnapshotCodec.php create mode 100644 packages/insight/src/Storage/SqliteDiagnosticBatchStore.php create mode 100644 packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotCodecTest.php create mode 100644 packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8e965..90a526f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Added `evolvephp/insight` as a public experimental diagnostic-batch collection foundation. The package consumes Core execution observations through the existing observation sink boundary, collects immutable bounded per-execution batches and keeps persistence, retention, redaction, dashboards, watchers, OpenTelemetry, Evolve Observe, runtime wiring and independent package release deferred. - Added storage-neutral Insight batch snapshots, projection from finalized diagnostic batches, a minimal diagnostic batch store contract, an in-memory store for tests and short-lived local development, and a sink adapter that projects and stores accepted batches while keeping persistent storage and retention deferred. +- Added a versioned primitive diagnostic batch snapshot codec and an optional SQLite diagnostic batch store for explicit local-development persistence through caller-supplied SQLite `PDO` connections, while keeping runtime wiring, retention, pruning and application database integration deferred. ### Repository diff --git a/packages/insight/README.md b/packages/insight/README.md index 62d717f..c857481 100644 --- a/packages/insight/README.md +++ b/packages/insight/README.md @@ -24,11 +24,15 @@ Current storage behavior: - `DiagnosticBatchStore` defines minimal save, exact execution-identifier lookup and newest-first bounded reads - `InMemoryDiagnosticBatchStore` keeps snapshots in insertion order and returns newest batches first +- `SqliteDiagnosticBatchStore` provides an optional persistent local-development adapter for caller-supplied SQLite `PDO` connections +- `DiagnosticBatchSnapshotCodec` stores snapshots as a versioned primitive JSON payload and rejects malformed, unsupported or unexpected persisted data during reads - duplicate execution identifiers are rejected and never replace the original snapshot - `StoringDiagnosticBatchSink` projects accepted batches and saves the detached snapshot through a configured store - storage remains optional and unwired; installing Insight does not create storage automatically -The in-memory store is unbounded and suitable only for tests or short-lived local development. It is not a persistent runtime store. +The in-memory store is unbounded and suitable only for tests or short-lived local development. The SQLite store creates its diagnostic table only when explicitly constructed with a SQLite `PDO`; it does not discover a default path, read application database configuration or automatically use application storage. SQLite reads use deterministic insertion-order sequence values for newest-first results, not diagnostic timestamps. Corrupt stored payloads are not decoded during construction, but the affected `find()` or `latest()` read fails explicitly. Neither store provides retention, pruning or eviction. + +`pdo_sqlite` is a runtime requirement only for applications that explicitly use `SqliteDiagnosticBatchStore`; it is not required for installing or using the non-SQLite Insight functionality. ## Requirements @@ -46,7 +50,7 @@ https://github.com/josiahking/evolvephp ## Current Limitations -This package does not provide filesystem or database persistence, retention, pruning, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, persistent storage adapters or production-ready diagnostics. +This package does not provide retention, pruning, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, application database integration or production-ready diagnostics. ## Licence diff --git a/packages/insight/src/Storage/DiagnosticBatchSnapshotCodec.php b/packages/insight/src/Storage/DiagnosticBatchSnapshotCodec.php new file mode 100644 index 0000000..f64cee4 --- /dev/null +++ b/packages/insight/src/Storage/DiagnosticBatchSnapshotCodec.php @@ -0,0 +1,212 @@ +requireAllowedValue($snapshot->executionKind(), self::EXECUTION_KINDS, 'Execution kind'); + + return json_encode( + array( + 'version' => self::VERSION, + 'execution_identifier' => $snapshot->executionIdentifier(), + 'execution_kind' => $snapshot->executionKind(), + 'observations' => array_map( + fn (DiagnosticObservationSnapshot $observation): array => $this->encodeObservation($observation), + $snapshot->observations(), + ), + 'dropped_observation_count' => $snapshot->droppedObservationCount(), + ), + JSON_THROW_ON_ERROR, + ); + } + + /** + * @return array{type: string, outcome: ?string, error_type: ?string, reuse_decision: ?string} + */ + private function encodeObservation(DiagnosticObservationSnapshot $observation): array + { + $this->requireAllowedValue($observation->type(), self::OBSERVATION_TYPES, 'Observation type'); + + $outcome = $observation->outcome(); + if ($outcome !== null) { + $this->requireAllowedValue($outcome, self::OBSERVATION_OUTCOMES, 'Observation outcome'); + } + + $reuseDecision = $observation->reuseDecision(); + if ($reuseDecision !== null) { + $this->requireAllowedValue($reuseDecision, self::REUSE_DECISIONS, 'Process reuse decision'); + } + + return array( + 'type' => $observation->type(), + 'outcome' => $outcome, + 'error_type' => $observation->errorType(), + 'reuse_decision' => $reuseDecision, + ); + } + + public function decode(string $payload): DiagnosticBatchSnapshot + { + try { + $decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \InvalidArgumentException('Diagnostic batch snapshot payload must be valid JSON.', 0, $exception); + } + + if (!is_array($decoded) || array_is_list($decoded)) { + throw new \InvalidArgumentException('Diagnostic batch snapshot payload must be a JSON object.'); + } + + $this->requireKeys($decoded, self::PAYLOAD_KEYS, 'Diagnostic batch snapshot payload'); + + if ($decoded['version'] !== self::VERSION) { + throw new \InvalidArgumentException('Diagnostic batch snapshot payload version is not supported.'); + } + + $identifier = $this->requireString($decoded['execution_identifier'], 'Execution identifier'); + $kind = $this->requireString($decoded['execution_kind'], 'Execution kind'); + $this->requireAllowedValue($kind, self::EXECUTION_KINDS, 'Execution kind'); + + if (!is_array($decoded['observations']) || !array_is_list($decoded['observations'])) { + throw new \InvalidArgumentException('Diagnostic batch snapshot observations must be a list.'); + } + + $droppedObservationCount = $decoded['dropped_observation_count']; + if (!is_int($droppedObservationCount)) { + throw new \InvalidArgumentException('Dropped observation count must be an integer.'); + } + + return new DiagnosticBatchSnapshot( + $identifier, + $kind, + array_map( + fn (mixed $observation): DiagnosticObservationSnapshot => $this->decodeObservation($observation), + $decoded['observations'], + ), + $droppedObservationCount, + ); + } + + private function decodeObservation(mixed $observation): DiagnosticObservationSnapshot + { + if (!is_array($observation) || array_is_list($observation)) { + throw new \InvalidArgumentException('Diagnostic observation snapshot payload must be a JSON object.'); + } + + $this->requireKeys($observation, self::OBSERVATION_KEYS, 'Diagnostic observation snapshot payload'); + + $type = $this->requireString($observation['type'], 'Observation type'); + $this->requireAllowedValue($type, self::OBSERVATION_TYPES, 'Observation type'); + + $outcome = $this->requireNullableString($observation['outcome'], 'Observation outcome'); + if ($outcome !== null) { + $this->requireAllowedValue($outcome, self::OBSERVATION_OUTCOMES, 'Observation outcome'); + } + + $reuseDecision = $this->requireNullableString($observation['reuse_decision'], 'Process reuse decision'); + if ($reuseDecision !== null) { + $this->requireAllowedValue($reuseDecision, self::REUSE_DECISIONS, 'Process reuse decision'); + } + + return new DiagnosticObservationSnapshot( + $type, + $outcome, + $this->requireNullableString($observation['error_type'], 'Observation error type'), + $reuseDecision, + ); + } + + /** + * @param array $payload + * @param list $expectedKeys + */ + private function requireKeys(array $payload, array $expectedKeys, string $context): void + { + $actualKeys = array_keys($payload); + sort($actualKeys); + + $sortedExpectedKeys = $expectedKeys; + sort($sortedExpectedKeys); + + if ($actualKeys !== $sortedExpectedKeys) { + throw new \InvalidArgumentException($context . ' has an invalid schema.'); + } + } + + private function requireString(mixed $value, string $field): string + { + if (!is_string($value)) { + throw new \InvalidArgumentException($field . ' must be a string.'); + } + + return $value; + } + + private function requireNullableString(mixed $value, string $field): ?string + { + if ($value === null) { + return null; + } + + return $this->requireString($value, $field); + } + + /** + * @param list $allowedValues + */ + private function requireAllowedValue(string $value, array $allowedValues, string $field): void + { + if (!in_array($value, $allowedValues, true)) { + throw new \InvalidArgumentException($field . ' is not supported by this diagnostic snapshot format.'); + } + } +} diff --git a/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php b/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php new file mode 100644 index 0000000..e9c9b02 --- /dev/null +++ b/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php @@ -0,0 +1,149 @@ +pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) !== 'sqlite') { + throw new \InvalidArgumentException('Sqlite diagnostic batch store requires a SQLite PDO connection.'); + } + + $this->codec = $codec ?? new DiagnosticBatchSnapshotCodec(); + $this->createSchema(); + } + + public function save(DiagnosticBatchSnapshot $snapshot): void + { + $identifier = $snapshot->executionIdentifier(); + + if ($this->identifierExists($identifier)) { + throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.'); + } + + $statement = $this->prepare( + 'INSERT INTO ' . self::TABLE . ' (execution_identifier, snapshot_payload) VALUES (:execution_identifier, :snapshot_payload)' + ); + $this->execute($statement, array( + 'execution_identifier' => $identifier, + 'snapshot_payload' => $this->codec->encode($snapshot), + )); + } + + public function find(string $executionIdentifier): ?DiagnosticBatchSnapshot + { + $statement = $this->prepare( + 'SELECT execution_identifier, snapshot_payload FROM ' . self::TABLE . ' WHERE execution_identifier = :execution_identifier' + ); + $this->execute($statement, array('execution_identifier' => $executionIdentifier)); + + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if ($row === false) { + return null; + } + + return $this->decodeRow($row); + } + + public function latest(int $limit): array + { + if ($limit <= 0) { + throw new \InvalidArgumentException('Latest limit must be positive.'); + } + + $statement = $this->prepare( + 'SELECT execution_identifier, snapshot_payload FROM ' . self::TABLE . ' ORDER BY sequence DESC LIMIT :limit' + ); + $statement->bindValue('limit', $limit, \PDO::PARAM_INT); + + if (!$statement->execute()) { + throw new \RuntimeException('Failed to read latest diagnostic batch snapshots.'); + } + + $snapshots = array(); + + while (($row = $statement->fetch(\PDO::FETCH_ASSOC)) !== false) { + $snapshots[] = $this->decodeRow($row); + } + + return $snapshots; + } + + private function createSchema(): void + { + $this->exec( + 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + execution_identifier TEXT NOT NULL UNIQUE, + snapshot_payload TEXT NOT NULL + )' + ); + $this->exec( + 'CREATE INDEX IF NOT EXISTS insight_diagnostic_batches_execution_identifier_idx + ON ' . self::TABLE . ' (execution_identifier)' + ); + } + + private function identifierExists(string $identifier): bool + { + $statement = $this->prepare( + 'SELECT 1 FROM ' . self::TABLE . ' WHERE execution_identifier = :execution_identifier' + ); + $this->execute($statement, array('execution_identifier' => $identifier)); + + return $statement->fetchColumn() !== false; + } + + /** + * @param array $row + */ + private function decodeRow(array $row): DiagnosticBatchSnapshot + { + if (!is_string($row['execution_identifier'] ?? null) || !is_string($row['snapshot_payload'] ?? null)) { + throw new \UnexpectedValueException('Persisted diagnostic batch row has an invalid shape.'); + } + + $snapshot = $this->codec->decode($row['snapshot_payload']); + + if ($snapshot->executionIdentifier() !== $row['execution_identifier']) { + throw new \UnexpectedValueException('Persisted diagnostic batch identifier does not match its index.'); + } + + return $snapshot; + } + + private function exec(string $sql): void + { + if ($this->pdo->exec($sql) === false) { + throw new \RuntimeException('Failed to initialize SQLite diagnostic batch store schema.'); + } + } + + private function prepare(string $sql): \PDOStatement + { + $statement = $this->pdo->prepare($sql); + + if (!$statement instanceof \PDOStatement) { + throw new \RuntimeException('Failed to prepare SQLite diagnostic batch store statement.'); + } + + return $statement; + } + + /** + * @param array $parameters + */ + private function execute(\PDOStatement $statement, array $parameters): void + { + if (!$statement->execute($parameters)) { + throw new \RuntimeException('Failed to execute SQLite diagnostic batch store statement.'); + } + } +} diff --git a/packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotCodecTest.php b/packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotCodecTest.php new file mode 100644 index 0000000..b765242 --- /dev/null +++ b/packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotCodecTest.php @@ -0,0 +1,181 @@ +snapshot(); + + self::assertSame($codec->encode($snapshot), $codec->encode($snapshot)); + self::assertSame( + '{"version":1,"execution_identifier":"execution-1","execution_kind":"http-request","observations":[{"type":"execution-started","outcome":null,"error_type":null,"reuse_decision":null}],"dropped_observation_count":0}', + $codec->encode($snapshot), + ); + } + + public function testFullValidSnapshotRoundTrips(): void + { + $codec = new DiagnosticBatchSnapshotCodec(); + $snapshot = new DiagnosticBatchSnapshot( + 'execution-1', + 'queue-message', + array( + new DiagnosticObservationSnapshot('handler-completed', 'failed', 'RuntimeException', 'quarantine-required'), + ), + 4, + ); + + self::assertEquals($snapshot, $codec->decode($codec->encode($snapshot))); + } + + public function testOrderedMultipleObservationsRoundTrip(): void + { + $codec = new DiagnosticBatchSnapshotCodec(); + $snapshot = new DiagnosticBatchSnapshot( + 'execution-1', + 'worker-task', + array( + new DiagnosticObservationSnapshot('execution-started', null, null, null), + new DiagnosticObservationSnapshot('scope-close-started', null, null, null), + new DiagnosticObservationSnapshot('scope-close-completed', 'succeeded', null, 'reusable'), + new DiagnosticObservationSnapshot('execution-completed', 'succeeded', null, 'reusable'), + ), + 0, + ); + + $roundTripped = $codec->decode($codec->encode($snapshot)); + + self::assertSame( + array('execution-started', 'scope-close-started', 'scope-close-completed', 'execution-completed'), + array_map( + static fn (DiagnosticObservationSnapshot $observation): string => $observation->type(), + $roundTripped->observations(), + ), + ); + } + + public function testNullableFieldsAndDroppedCountRoundTrip(): void + { + $codec = new DiagnosticBatchSnapshotCodec(); + $snapshot = new DiagnosticBatchSnapshot( + 'execution-1', + 'scheduled-job', + array(new DiagnosticObservationSnapshot('quarantine-required', null, null, null)), + 7, + ); + + $roundTripped = $codec->decode($codec->encode($snapshot)); + + self::assertSame(7, $roundTripped->droppedObservationCount()); + self::assertNull($roundTripped->observations()[0]->outcome()); + self::assertNull($roundTripped->observations()[0]->errorType()); + self::assertNull($roundTripped->observations()[0]->reuseDecision()); + } + + public function testMalformedJsonIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Diagnostic batch snapshot payload must be valid JSON.'); + + (new DiagnosticBatchSnapshotCodec())->decode('{'); + } + + public function testUnsupportedVersionIsRejected(): void + { + $this->expectDecodeRejection('{"version":2,"execution_identifier":"execution-1","execution_kind":"http-request","observations":[],"dropped_observation_count":0}'); + } + + public function testMissingRequiredFieldIsRejected(): void + { + $this->expectDecodeRejection('{"version":1,"execution_identifier":"execution-1","observations":[],"dropped_observation_count":0}'); + } + + public function testUnexpectedFieldIsRejected(): void + { + $this->expectDecodeRejection('{"version":1,"execution_identifier":"execution-1","execution_kind":"http-request","observations":[],"dropped_observation_count":0,"extra":true}'); + } + + public function testIncorrectPrimitiveTypeIsRejected(): void + { + $this->expectDecodeRejection('{"version":1,"execution_identifier":"execution-1","execution_kind":"http-request","observations":[],"dropped_observation_count":"0"}'); + } + + public function testMalformedObservationIsRejected(): void + { + $this->expectDecodeRejection('{"version":1,"execution_identifier":"execution-1","execution_kind":"http-request","observations":[{"type":"execution-started","outcome":null,"error_type":null}],"dropped_observation_count":0}'); + } + + public function testEncodeRejectsUnsupportedExecutionKind(): void + { + $this->expectEncodeRejection(new DiagnosticBatchSnapshot( + 'execution-1', + 'unsupported-kind', + array(), + 0, + )); + } + + public function testEncodeRejectsUnsupportedObservationType(): void + { + $this->expectEncodeRejection(new DiagnosticBatchSnapshot( + 'execution-1', + 'http-request', + array(new DiagnosticObservationSnapshot('unsupported-type', null, null, null)), + 0, + )); + } + + public function testEncodeRejectsUnsupportedNonNullOutcome(): void + { + $this->expectEncodeRejection(new DiagnosticBatchSnapshot( + 'execution-1', + 'http-request', + array(new DiagnosticObservationSnapshot('execution-started', 'unsupported-outcome', null, null)), + 0, + )); + } + + public function testEncodeRejectsUnsupportedNonNullReuseDecision(): void + { + $this->expectEncodeRejection(new DiagnosticBatchSnapshot( + 'execution-1', + 'http-request', + array(new DiagnosticObservationSnapshot('execution-started', null, null, 'unsupported-decision')), + 0, + )); + } + + private function expectDecodeRejection(string $payload): void + { + $this->expectException(\InvalidArgumentException::class); + + (new DiagnosticBatchSnapshotCodec())->decode($payload); + } + + private function expectEncodeRejection(DiagnosticBatchSnapshot $snapshot): void + { + $this->expectException(\InvalidArgumentException::class); + + (new DiagnosticBatchSnapshotCodec())->encode($snapshot); + } + + private function snapshot(): DiagnosticBatchSnapshot + { + return new DiagnosticBatchSnapshot( + 'execution-1', + 'http-request', + array(new DiagnosticObservationSnapshot('execution-started', null, null, null)), + 0, + ); + } +} diff --git a/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php b/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php new file mode 100644 index 0000000..b3acb0a --- /dev/null +++ b/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php @@ -0,0 +1,209 @@ +pdo()))->latest(1)); + } + + public function testNonSqlitePdoConnectionIsRejected(): void + { + $pdo = new class extends \PDO { + public function __construct() {} + + public function getAttribute(int $attribute): mixed + { + if ($attribute === \PDO::ATTR_DRIVER_NAME) { + return 'mysql'; + } + + return null; + } + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Sqlite diagnostic batch store requires a SQLite PDO connection.'); + + new SqliteDiagnosticBatchStore($pdo); + } + + public function testConstructionCreatesSchema(): void + { + $pdo = $this->pdo(); + + new SqliteDiagnosticBatchStore($pdo); + + self::assertSame( + 'insight_diagnostic_batches', + $pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'insight_diagnostic_batches'")->fetchColumn(), + ); + } + + public function testSaveThenExactFind(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo()); + $snapshot = $this->snapshot('execution-1'); + + $store->save($snapshot); + + self::assertEquals($snapshot, $store->find('execution-1')); + self::assertNull($store->find('execution')); + self::assertNull($store->find('')); + } + + public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo()); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + $third = $this->snapshot('execution-3'); + + $store->save($first); + $store->save($second); + $store->save($third); + + self::assertEquals(array($third, $second, $first), $store->latest(10)); + self::assertEquals(array($third, $second), $store->latest(2)); + self::assertEquals(array($third, $second, $first), $store->latest(10)); + } + + public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo()); + $snapshot = $this->snapshot('execution-1'); + + $store->save($snapshot); + + self::assertEquals(array($snapshot), $store->latest(5)); + } + + public function testNonPositiveLatestLimitIsRejected(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Latest limit must be positive.'); + + $store->latest(0); + } + + public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo()); + $original = $this->snapshot('execution-1', 'http-request'); + $duplicate = $this->snapshot('execution-1', 'cli-command'); + + $store->save($original); + + try { + $store->save($duplicate); + self::fail('Expected duplicate execution identifier to be rejected.'); + } catch (\LogicException $exception) { + self::assertSame('Diagnostic batch snapshot already exists for execution identifier.', $exception->getMessage()); + } + + self::assertEquals($original, $store->find('execution-1')); + self::assertEquals(array($original), $store->latest(10)); + } + + public function testPayloadIndexIdentifierMismatchIsRejectedAsCorruption(): void + { + $pdo = $this->pdo(); + new SqliteDiagnosticBatchStore($pdo); + $this->insertRaw($pdo, 'indexed-id', '{"version":1,"execution_identifier":"payload-id","execution_kind":"http-request","observations":[],"dropped_observation_count":0}'); + $store = new SqliteDiagnosticBatchStore($pdo); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Persisted diagnostic batch identifier does not match its index.'); + + $store->find('indexed-id'); + } + + public function testCorruptPayloadReadFailsExplicitly(): void + { + $pdo = $this->pdo(); + new SqliteDiagnosticBatchStore($pdo); + $this->insertRaw($pdo, 'execution-1', '{'); + $store = new SqliteDiagnosticBatchStore($pdo); + + $this->expectException(\InvalidArgumentException::class); + + $store->find('execution-1'); + } + + public function testConstructorDoesNotEagerlyDecodeCorruptExistingRows(): void + { + $pdo = $this->pdo(); + new SqliteDiagnosticBatchStore($pdo); + $this->insertRaw($pdo, 'execution-1', '{'); + + self::assertNull((new SqliteDiagnosticBatchStore($pdo))->find('missing-execution')); + } + + public function testObservationOrderAndNullableFieldsSurvivePersistence(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo()); + $snapshot = new DiagnosticBatchSnapshot( + 'execution-1', + 'http-request', + array( + new DiagnosticObservationSnapshot('execution-started', null, null, null), + new DiagnosticObservationSnapshot('handler-completed', 'failed', 'RuntimeException', null), + new DiagnosticObservationSnapshot('execution-completed', 'succeeded', null, 'reusable'), + ), + 2, + ); + + $store->save($snapshot); + $found = $store->find('execution-1'); + + self::assertEquals($snapshot, $found); + self::assertSame( + array('execution-started', 'handler-completed', 'execution-completed'), + array_map( + static fn (DiagnosticObservationSnapshot $observation): string => $observation->type(), + $found?->observations() ?? array(), + ), + ); + self::assertNull($found?->observations()[0]->outcome()); + self::assertNull($found?->observations()[1]->reuseDecision()); + } + + private function pdo(): \PDO + { + return new \PDO('sqlite::memory:'); + } + + private function snapshot(string $identifier, string $kind = 'http-request'): DiagnosticBatchSnapshot + { + return new DiagnosticBatchSnapshot($identifier, $kind, array(), 0); + } + + private function insertRaw(\PDO $pdo, string $identifier, string $payload): void + { + $statement = $pdo->prepare( + 'INSERT INTO insight_diagnostic_batches (execution_identifier, snapshot_payload) VALUES (:execution_identifier, :snapshot_payload)' + ); + $statement->execute(array( + 'execution_identifier' => $identifier, + 'snapshot_payload' => $payload, + )); + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 75044c1..8bba960 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -1018,9 +1018,11 @@ private function acceptedPackageSourceInventories() 'DiagnosticBatchSink.php', 'Storage/DiagnosticBatchProjector.php', 'Storage/DiagnosticBatchSnapshot.php', + 'Storage/DiagnosticBatchSnapshotCodec.php', 'Storage/DiagnosticBatchStore.php', 'Storage/DiagnosticObservationSnapshot.php', 'Storage/InMemoryDiagnosticBatchStore.php', + 'Storage/SqliteDiagnosticBatchStore.php', 'Storage/StoringDiagnosticBatchSink.php', ), 'packages/dev-tools/src' => array(