Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions PHP_PERSISTENCE_PACKAGE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ The [README](README.md#installation) records the exact installation target and t

## 2. Source topology

```text
Source Topology: Multi Capability

Capabilities:
- Ordering
- Pagination
- Transaction
```

One Composer package, three independent capability folders under `src/`, per [DEC-010](docs/decisions/DEC-010-package-topology-multi-capability.md) and [DEC-011](docs/decisions/DEC-011-responsibility-placement-and-savepoint-helper.md). Dependency direction: `Ordering → Transaction`; `Pagination` and `Transaction` do not depend on each other or on `Ordering`.

```text
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

# PHP Persistence

![Maatify.dev](https://www.maatify.dev/assets/img/img/maatify_logo_white.svg)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.maatify.dev/company-profile/maatify_logo_white.svg">
<source media="(prefers-color-scheme: light)" srcset="https://www.maatify.dev/company-profile/maatify_logo_dark.svg">
<img alt="Maatify.dev" src="https://www.maatify.dev/company-profile/maatify_logo_dark.svg">
</picture>

[![Status at preparation](https://img.shields.io/badge/Status%20at%20preparation-Development-blue)](README.md#overview)
[![PHP](https://img.shields.io/badge/PHP-%5E8.4-8892BF)](composer.json)
Expand All @@ -28,7 +32,7 @@ Direct-PDO Transaction, Pagination, and Ordering utilities for MySQL and MariaDB

`maatify/php-persistence` is a small, direct-PDO toolkit with three independent capabilities over a **caller-supplied** `PDO` connection: running callbacks inside a transaction or a caller's savepoint (with optional bounded deadlock replay), parameterized whitelist-sorted bounded pagination, and locking next-position/move operations for integer display-order columns. It is developed as a separate package from the earlier, published `maatify/persistence` ([coexistence rules](docs/decisions/DEC-005-coexistence-with-legacy-package.md)), not a drop-in replacement.

This tree prepares the **Release Candidate artifact `1.0.0-rc.1`**. The live project is in **Development** while the target is unpublished. The publication state recorded for this preparation is **Unpublished**.
This tree is prepared as the **Release Candidate artifact `1.0.0-rc.1`**. Its **publication state at qualification** is **Unpublished**.

## Key Features

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
"test:integration": "@php scripts/integration.php",
"examples:smoke": "@php scripts/examples-smoke.php",
"test:consumer": "@php scripts/consumer-verification.php",
"release:verify": "@php scripts/release-artifact.php"
"release:verify": "@php scripts/release-artifact.php",
"release:verify-published": "@php scripts/published-artifact.php"
},
"config": {
"optimize-autoloader": true,
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Each work unit also re-verifies the legacy source commit ([DEC-007](../decisions

| Item | Record | Blocks |
|---|---|---|
| Safe MariaDB contract for `1020` in caller-owned transactions | [DEC-009](../decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md) `PROPOSED` | WU-8 acceptance for MariaDB; any MariaDB claim |
| Safe MariaDB contract for `1020` in caller-owned transactions | [DEC-009](../decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md) `PROPOSED` | An approved recovery contract for MariaDB error `1020`. WU-8 is complete, and DEC-013 remains a test-only matrix. |
| Which two independent Hosts validate the release candidate, and when | none yet | WU-13 |
| Central standards change, consumer migration, legacy archive | none (separate authorizations) | Any statement that ownership transferred |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,11 @@ The version-matrix rows that this record first proposed (endpoints, intermediate
4. The package does not change `innodb_snapshot_isolation`, the isolation level, or any connection attribute.
5. If an honest and safe contract cannot be demonstrated for caller-owned transactions, MariaDB support is not advertised unconditionally; the claim is narrowed or withheld.

## Open decisions and what they block
## Deferred contract and WU-8 evidence

| Open decision | Must be settled before |
|---|---|
| Whether the safe `1020` contract in item 5 is achievable | WU-8 (Ordering) acceptance for MariaDB, and any MariaDB claim |
At proposal time, Ordering acceptance for MariaDB and broader MariaDB claims were listed as pending. WU-8 later completed with test-only evidence while the Owner kept the recovery contract deferred.

Observed engine facts that inform it (defaults of `innodb_snapshot_isolation` per version, error `1020` raised with the setting `ON`) are in [CI_AND_INTEGRATION.md](../architecture/CI_AND_INTEGRATION.md); what the server does after `1020` is still not established.
In the cases exercised by the [WU-8 Ordering integration test](https://github.com/Maatify-ltd/php-persistence/blob/2212fb0a74e8f44c82b2c508f3585e2b87ed5a8c/tests/Integration/Ordering/ScopedOrderingManagerMariaDbSnapshotIsolationHazardTest.php), the server rolls back a prior caller-owned write after error `1020`. The test also attempts a subsequent statement on the same connection and observes whether it is rejected or commits independently. [DEC-013](./DEC-013-test-only-database-version-boundary.md) governs the tested matrix. A general recovery guarantee or supported range remains outside the accepted contract.

## Consequences

Expand Down
10 changes: 9 additions & 1 deletion docs/guides/RELEASE_ARTIFACT_VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@ The verifier creates an isolated checkout of the selected commit. It generates a

In pull-request CI the archive job checks out and verifies the exact PR HEAD. On a push it verifies the exact pushed commit. Qualification requires Full Applicable CI and Release Artifact Verification on the same actual, Owner-authorized `main` commit and exact target. The [Consumer Verification Harness](CONSUMER_VERIFICATION.md) separately exercises runtime consumption.

After separately authorized publication, the approved Composer channel provides the version and distribution evidence for Published Artifact Verification. Its archive and install mode must be observed there. This preparation step makes no assumption about a source-only distribution mode.
## Published Artifact Verification

After authorized publication makes the exact version available through Packagist, run the following from a repository checkout containing the exact qualified commit and its existing `v1.0.0-rc.1` release tag. Set `QUALIFIED_SHA` to the full SHA from the recorded qualification evidence.

```sh
composer release:verify-published -- --target=1.0.0-rc.1 --sha="$QUALIFIED_SHA"
```

The command uses a fresh external Composer root, home, and cache. It resolves the exact version through Packagist, checks the GitHub dist and source references against the qualified SHA, and requires Composer's installed metadata to report an actual `dist` installation. It compares the complete installed file inventory and bytes with a Git archive of the qualified commit, and logs version, installation mode, installed path, resolved metadata, and file hashes before cleaning up. Save the output with the release evidence. A missing version or dist, a source fallback, or any content difference fails verification. The command is read-only with respect to GitHub and Packagist. It is separate from the Consumer Verification Harness.
207 changes: 207 additions & 0 deletions scripts/published-artifact.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php

declare(strict_types=1);

require __DIR__ . '/integration-support.php';

const PAV_PACKAGE = 'maatify/php-persistence';
const PAV_TARGET = '1.0.0-rc.1';
const PAV_REPOSITORY = 'Maatify-ltd/php-persistence';

/** @param list<string> $command */
function publishedCommand(array $command, string $directory): string
{
[$status, $output, $errors] = execute($command, [], $directory, 180);
if ($status !== 0) {
throw new RuntimeException(sprintf('%s failed (%d): %s', $command[0], $status, trim($errors)));
}

return trim($output);
}

/** @param list<string> $arguments */
function publishedComposer(array $arguments, string $workspace): string
{
return publishedCommand([
'env', '-i', 'PATH=' . (getenv('PATH') ?: '/usr/bin:/bin'), 'HOME=' . $workspace,
'COMPOSER_HOME=' . $workspace . '/composer-home',
'COMPOSER_CACHE_DIR=' . $workspace . '/composer-cache',
'COMPOSER_NO_INTERACTION=1', 'GIT_CONFIG_NOSYSTEM=1',
'composer', '--no-interaction', '--no-plugins', '--no-scripts', ...$arguments,
], $workspace);
}

/** @return array<array-key, mixed> */
function publishedJson(string $json): array
{
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || array_is_list($data)) {
throw new RuntimeException('Expected a JSON object in Composer metadata');
}

return $data;
}

function publishedRead(string $path): string
{
if (!is_file($path) || is_link($path) || ($contents = file_get_contents($path)) === false) {
throw new RuntimeException('Cannot read required file: ' . $path);
}

return $contents;
}

function publishedVersion(mixed $version): bool
{
// Composer can normalize the separator before the RC number in registry metadata.
return is_string($version) && preg_match('/^v?1\.0\.0-rc\.?1$/iD', $version) === 1;
}

/** @return array<string, string> */
function publishedInventory(string $directory): array
{
if (!is_dir($directory) || is_link($directory)) {
throw new RuntimeException('Expected an installed or reference directory');
}
$files = [];
$entries = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($entries as $entry) {
if (!$entry instanceof SplFileInfo) {
throw new RuntimeException('Cannot read artifact directory entry');
}
$path = $entry->getPathname();
$relative = substr($path, strlen($directory) + 1);
if ($entry->isLink() || (!$entry->isDir() && !$entry->isFile())) {
throw new RuntimeException('Unexpected artifact entry: ' . $relative);
}
if ($entry->isFile()) {
$files[$relative] = hash('sha256', publishedRead($path));
}
}
ksort($files);

return $files;
}

/** @param array<array-key, mixed> $metadata */
function publishedReference(array $metadata, string $sha): void
{
$source = $metadata['source'] ?? null;
$dist = $metadata['dist'] ?? null;
if (!is_array($dist) || ($dist['type'] ?? null) !== 'zip'
|| ($dist['url'] ?? null) !== 'https://api.github.com/repos/' . PAV_REPOSITORY . '/zipball/' . $sha
|| ($dist['reference'] ?? null) !== $sha
|| !is_array($source) || ($source['type'] ?? null) !== 'git'
|| ($source['url'] ?? null) !== 'https://github.com/' . PAV_REPOSITORY . '.git'
|| ($source['reference'] ?? null) !== $sha
) {
throw new RuntimeException('Packagist must expose GitHub dist and source for the exact qualified SHA');
}
}

$arguments = $_SERVER['argv'] ?? [];
if (!is_array($arguments) || count($arguments) !== 3 || ($arguments[1] ?? null) !== '--target=' . PAV_TARGET
|| !is_string($arguments[2] ?? null) || preg_match('/^--sha=([0-9a-f]{40})$/D', $arguments[2], $matches) !== 1
) {
fail(EXIT_USAGE, 'Usage: composer release:verify-published -- --target=' . PAV_TARGET . ' --sha=<full qualified SHA>');
}

$sha = $matches[1];
$root = dirname(__DIR__);
$workspace = sys_get_temp_dir() . '/pp-published-' . bin2hex(random_bytes(12));
if (!mkdir($workspace, 0700)) {
fail(EXIT_INFRASTRUCTURE, 'Cannot create published verification workspace');
}

$status = EXIT_TEST_FAILURE;
try {
$tag = 'refs/tags/v' . PAV_TARGET;
if (publishedCommand(['git', 'rev-parse', '--verify', $tag . '^{commit}'], $root) !== $sha) {
throw new RuntimeException('The release tag must resolve to the exact qualified SHA');
}
$manifest = json_encode([
'require' => [PAV_PACKAGE => PAV_TARGET],
'repositories' => ['packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']],
'config' => ['preferred-install' => 'dist', 'allow-plugins' => false],
], JSON_THROW_ON_ERROR);
if (file_put_contents($workspace . '/composer.json', $manifest) !== strlen($manifest)) {
throw new RuntimeException('Cannot create isolated consumer manifest');
}

echo publishedComposer(['--version'], $workspace) . "\n";
echo "PAV source=https://repo.packagist.org preference=dist plugins=off scripts=off isolated-home-and-cache=true\n";
$metadata = publishedJson(publishedComposer(['show', '--all', PAV_PACKAGE, PAV_TARGET, '--format=json'], $workspace));
$versions = $metadata['versions'] ?? null;
if (($metadata['name'] ?? null) !== PAV_PACKAGE || !is_array($versions) || count($versions) !== 1
|| !publishedVersion($versions[0] ?? null)
) {
throw new RuntimeException('Packagist did not resolve the exact package and release version');
}
publishedReference($metadata, $sha);

publishedComposer(['update', '--prefer-dist', '--no-progress'], $workspace);
$installation = publishedJson(publishedRead($workspace . '/vendor/composer/installed.json'));
$packages = $installation['packages'] ?? null;
if (!is_array($packages)) {
throw new RuntimeException('Composer did not record installed packages');
}
$matches = array_values(array_filter($packages, static fn(mixed $package): bool
=> is_array($package) && ($package['name'] ?? null) === PAV_PACKAGE));
$package = $matches[0] ?? null;
if (count($matches) !== 1 || !is_array($package) || !publishedVersion($package['version'] ?? null)
|| ($package['installation-source'] ?? null) !== 'dist'
|| ($package['install-path'] ?? null) !== '../' . PAV_PACKAGE
) {
throw new RuntimeException('Composer did not install the exact release from dist at the expected path');
}
publishedReference($package, $sha);

$reference = $workspace . '/qualified';
$archive = $workspace . '/qualified.tar';
if (!mkdir($reference, 0700)) {
throw new RuntimeException('Cannot create reference directory');
}
publishedCommand(['git', 'archive', '--format=tar', '--output=' . $archive, $sha], $root);
publishedCommand(['tar', '-xf', $archive, '-C', $reference], $workspace);
$installed = $workspace . '/vendor/' . PAV_PACKAGE;
$expectedFiles = publishedInventory($reference);
$installedFiles = publishedInventory($installed);
foreach (['composer.json', 'README.md', 'CHANGELOG.md', 'SECURITY.md', 'LICENSE',
'PHP_PERSISTENCE_PACKAGE_REFERENCE.md', 'docs/guides/USAGE_GUIDE.md', 'llms.txt',
'examples/_bootstrap.php', 'examples/transaction_basic_usage.php', 'examples/pagination_basic_usage.php',
'examples/ordering_basic_usage.php',
] as $required) {
if (!isset($expectedFiles[$required])) {
throw new RuntimeException('The qualified reference omits a required file: ' . $required);
}
}
$runtime = publishedCommand(['git', 'ls-tree', '-r', '--name-only', $sha, '--', 'src'], $root);
if ($runtime === '' || array_diff(explode("\n", $runtime), array_keys($expectedFiles)) !== []) {
throw new RuntimeException('The qualified reference omits runtime source');
}
if ($installedFiles !== $expectedFiles) {
$missingOrChanged = array_diff_assoc($expectedFiles, $installedFiles);
$extra = array_diff_key($installedFiles, $expectedFiles);
throw new RuntimeException('Installed dist differs from the qualified archive: '
. implode(', ', [...array_keys($missingOrChanged), ...array_keys($extra)]));
}
echo 'PAV EVIDENCE ' . json_encode([
'package' => PAV_PACKAGE, 'target' => PAV_TARGET, 'sha' => $sha, 'tag' => $tag,
'installedVersion' => $package['version'], 'installationSource' => 'dist', 'installedPath' => $installed,
'dist' => $package['dist'], 'source' => $package['source'], 'files' => $installedFiles,
], JSON_THROW_ON_ERROR) . "\n";
$status = 0;
} catch (Throwable $exception) {
fwrite(STDERR, 'Published artifact verification failed: ' . $exception->getMessage() . "\n");
} finally {
[$cleanup] = execute(['rm', '-rf', '--', $workspace]);
if ($cleanup !== 0 || file_exists($workspace)) {
fwrite(STDERR, "Published verification workspace cleanup failed\n");
$status = EXIT_INFRASTRUCTURE;
}
}

exit($status);
4 changes: 4 additions & 0 deletions src/Pagination/Criteria/PageRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
*/
final readonly class PageRequest
{
/**
* Store request values unchanged. Null leaves the corresponding page,
* page size, sort key, or sort direction unspecified for the paginator.
*/
public function __construct(
public int|string|null $page = null,
public int|string|null $perPage = null,
Expand Down
13 changes: 12 additions & 1 deletion src/Pagination/DTO/PageResultDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,25 @@
use Maatify\PhpPersistence\Pagination\Exception\PaginationExecutionException;

/**
* Immutable mapped page and navigation metadata shared by the array and JSON
* representations. Counts come from separate reads and can differ under
* concurrent writes.
*
* @template T of array<array-key, mixed>|object
*/
final readonly class PageResultDTO implements JsonSerializable
{
private const PUBLIC_KEY_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*$/';

/**
* @param list<T> $data
* Validates a one-based page with a positive size, non-negative counts,
* and at most one page of mapped items. The page count and navigation
* flags must agree with the filtered count and page size. A zero filtered
* count requires empty data, page one, and no navigation.
*
* @param list<T> $data Mapped items in page order.
*
* @throws PaginationExecutionException When the result is inconsistent.
*/
public function __construct(
public array $data,
Expand Down
18 changes: 18 additions & 0 deletions src/Pagination/Repository/PdoPaginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
use PDO;
use PDOStatement;

/**
* Paginates caller-supplied SQL on a caller-owned PDO connection.
*
* Request values select bounded page sizes and whitelisted sort identifiers.
* Total, filtered count, and data are separate reads. The caller controls
* transaction ownership and any shared snapshot.
*/
final readonly class PdoPaginator
{
private const PUBLIC_KEY_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*$/';
Expand All @@ -21,6 +28,17 @@
private const OFFSET_PARAMETER = '__pagination_offset';

/**
* Normalizes the request against the configuration, reads both counts, and
* maps rows from the sorted, bounded data query. The descriptor supplies
* trusted SQL and filter parameters. Zero filtered rows skip the data query
* and return an empty first page. A page above the last page resets to one.
*
* Leaves the caller's transaction and isolation level intact. PDO and
* mapper exceptions propagate unchanged.
*
* @throws PaginationExecutionException When a statement fails without throwing
* or a count, row, or mapped item is invalid.
*
* @template TItem of array<array-key, mixed>|object
*
* @param callable(array<string, mixed>): TItem $mapper
Expand Down
Loading