small/swoole-entity-manager-strates

Snapshot-style stratified persistence for Small Swoole Entity Manager with release pointers and garbage collection.

Maintainers

Package info

git.small-project.dev/lib/small-swoole-entity-manager-strates

pkg:composer/small/swoole-entity-manager-strates

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

1.0.0 2026-08-19 11:08 UTC

This package is not auto-updated.

Last update: 2026-08-19 17:55:15 UTC


README

  

Snapshot-based persistence for small/swoole-entity-manager-core.

The package lets an application build a complete version of an entity graph, keep that version invisible while it is being written, then publish it by switching one release pointer. Readers always query the currently released strate for a scope and scope identifier.

Features

  • Snapshot-style persistence grouped by scope and scopeId.
  • UUID v7 identifiers for build strates.
  • Explicit build lifecycle: building, released, failed, and garbage_collected.
  • Constant-size release operation through one central pointer in released_strates.
  • Idempotent release of an already released strate.
  • Released-data finder and query-builder API.
  • Multiple stratified managers in the same scope.
  • Explicit root manager selection for released queries.
  • One-to-one, one-to-many, and many-to-many relation graphs isolated by buildStrate.
  • Preservation of non-null application-generated primary keys on inserts.
  • MySQL 8 and PostgreSQL 16 metadata schemas.
  • Microsecond precision for build and release metadata timestamps.
  • Default garbage collection by buildStrate deletion.
  • Optional partition-based garbage collection for MySQL and PostgreSQL.
  • Custom garbage-collector strategies through a public interface.
  • Cleanup of obsolete build metadata during garbage-collection runs.
  • Configurable cleanup delay for failed builds.
  • Validation of registered managers, required fields, entity contracts, scopes, and roots.
  • Docker development environment with PHP 8.4, Swoole, MySQL, and PostgreSQL.
  • Unit and database integration test suites with Pest.
  • PHPStan level 9 analysis and PHP syntax validation.
  • Git-tag-based release helper with semantic version sorting.

Requirements

  • PHP 8.3 or later.
  • small/swoole-entity-manager-core 2.7.x.
  • A MySQL or PostgreSQL connection supported by Small Swoole Entity Manager.
  • Swoole or OpenSwoole as required by the underlying entity manager runtime.

Installation

composer require small/swoole-entity-manager-strates

Install the metadata schema matching the application database:

mysql < vendor/small/swoole-entity-manager-strates/schema/mysql.sql
psql < vendor/small/swoole-entity-manager-strates/schema/postgres.sql

The package creates two metadata tables:

TablePurpose
stratified_buildStores every build strate, its scope, scope identifier, status, and timestamps.
released_stratesStores the currently released strate for each (scope, scope_id) pair.

Business tables remain owned by the application. Each stratified business table must contain its own buildStrate field.

Core concepts

Scope

A scope identifies one versioned business domain, for example booking, catalog, or configuration.

Scope identifier

A scope identifier selects one independent instance inside a scope, for example booking 42 or shop 8.

Build strate

A build strate is a UUID v7 identifying one complete candidate snapshot. New rows are written with that UUID in their buildStrate field.

Released strate

A released strate is the active build for a (scope, scopeId) pair. The active UUID is stored centrally in released_strates.validated_strate.

Scope root

A scope may contain several managers. One manager is the released-query root. Set scopeRoot: true explicitly when a scope contains more than one manager. For backward compatibility, the first registered manager is used when no explicit root is declared.

Entity definition

A stratified entity must:

  1. extend AbstractEntity;
  2. implement StratifiedEntityInterface;
  3. expose an id primary key;
  4. expose a buildStrate field;
  5. expose the configured scope identifier field;
  6. use StratifiedEntityTrait, or provide equivalent accessors manually.
<?php

declare(strict_types=1);

use Small\SwooleEntityManager\Entity\AbstractEntity;
use Small\SwooleEntityManager\Entity\Attribute\Field;
use Small\SwooleEntityManager\Entity\Attribute\OrmEntity;
use Small\SwooleEntityManager\Entity\Attribute\PrimaryKey;
use Small\SwooleEntityManager\Entity\Enum\FieldValueType;
use Small\SwooleEntityManager\Strates\Contract\StratifiedEntityInterface;
use Small\SwooleEntityManager\Strates\Entity\StratifiedEntityTrait;

#[OrmEntity]
final class BookingItemEntity extends AbstractEntity implements StratifiedEntityInterface
{
    use StratifiedEntityTrait;

    #[PrimaryKey]
    private ?int $id = null;

    #[Field(type: FieldValueType::int)]
    private ?int $bookingId = null;

    #[Field(type: FieldValueType::string)]
    private ?string $sku = null;
}

StratifiedEntityTrait provides:

public function getBuildStrate(): ?string;
public function setBuildStrate(string $buildStrate): static;

It also includes ManualPrimaryKeyPersistenceTrait.

Application-generated primary keys

Small Swoole Entity Manager may remove primary keys from PostgreSQL insert payloads. Stratified entities commonly use application-generated UUIDs, so ManualPrimaryKeyPersistenceTrait restores non-null primary keys when an entity is inserted.

The trait does not change these cases:

  • a null database-generated key remains omitted from an insert;
  • primary keys remain omitted from update payloads;
  • entities loaded from the database keep the normal core behavior.

When an entity declares buildStrate itself instead of using StratifiedEntityTrait, add the primary-key trait explicitly:

use Small\SwooleEntityManager\Strates\Entity\ManualPrimaryKeyPersistenceTrait;

final class CatalogEntity extends AbstractEntity implements StratifiedEntityInterface
{
    use ManualPrimaryKeyPersistenceTrait;

    #[PrimaryKey]
    private ?string $id = null;

    #[Field(type: FieldValueType::string)]
    private ?string $buildStrate = null;

    public function getBuildStrate(): ?string
    {
        return $this->buildStrate;
    }

    public function setBuildStrate(string $buildStrate): static
    {
        $this->buildStrate = $buildStrate;

        return $this;
    }
}

Manager definition

The entity manager remains a standard relational manager:

<?php

declare(strict_types=1);

use Small\SwooleEntityManager\EntityManager\AbstractRelationnalManager;
use Small\SwooleEntityManager\EntityManager\Attribute\Connection;
use Small\SwooleEntityManager\EntityManager\Attribute\Entity;

#[Connection(dbTableName: 'booking_item')]
#[Entity(BookingItemEntity::class)]
final class BookingItemManager extends AbstractRelationnalManager
{
}

Service configuration

Register every stratified manager with a StratifiedEntityConfig:

<?php

declare(strict_types=1);

use Small\SwooleEntityManager\Strates\Config\StratifiedEntityConfig;
use Small\SwooleEntityManager\Strates\Service\StratifiedPersist;

$stratifiedPersist = new StratifiedPersist(
    $entityManagerFactory,
    [
        new StratifiedEntityConfig(
            managerClass: BookingItemManager::class,
            scope: 'booking',
            scopeIdField: 'bookingId',
            scopeRoot: true,
        ),
    ],
);

StratifiedEntityConfig validates that:

  • the scope is not empty;
  • the scope identifier field name is not empty;
  • the manager extends AbstractRelationnalManager;
  • the manager contains id, buildStrate, and the configured scope identifier field;
  • the managed entity implements StratifiedEntityInterface;
  • a manager is not registered twice;
  • a scope has at most one explicit root manager.

The internal StratifiedBuildManager and ReleasedStrateManager use the default ORM connection. Metadata managers and business managers must therefore resolve to the intended database connection.

Creating, persisting, and releasing a snapshot

Create a build

$strate = $stratifiedPersist->createNewVersion(
    [BookingItemManager::class],
    'booking',
    42,
);

createNewVersion():

  • validates the scope and manager contracts;
  • generates a UUID v7;
  • creates a stratified_build row;
  • assigns status building;
  • stores creation and update timestamps with microsecond precision;
  • returns the new strate UUID.

createNewStrate() is an equivalent domain-language alias:

$strate = $stratifiedPersist->createNewStrate(
    [BookingItemManager::class],
    'booking',
    42,
);

Persist one entity

$item = $entityManagerFactory
    ->get(BookingItemManager::class)
    ->newEntity();

$item->setBookingId(42);
$item->setSku('BIKE-001');

$stratifiedPersist->persist($item, $strate);

persist() assigns the supplied UUID to buildStrate before persisting the entity.

Persist several entities

$stratifiedPersist->persistMany($items, $strate);

persistMany() accepts any iterable of entities implementing StratifiedEntityInterface.

Release the build

$stratifiedPersist->release('booking', 42, $strate);

The release operation:

  • checks that the build exists for the supplied scope and scope identifier;
  • accepts builds in building or released state;
  • rejects failed, garbage-collected, unknown, or mismatched builds;
  • creates or updates the unique release pointer for (scope, scopeId);
  • changes the build status to released;
  • persists the pointer and build status through one persistence thread.

Releasing the same strate again is supported and leaves the same active pointer in place.

For PDO-style connections, release persistence uses the entity-manager commit path. With the PostgreSQL connection from core 2.7.x, it uses a direct flush to avoid checking out an unused native connection during the core commit fallback.

Failure handling

Mark an incomplete build as failed when snapshot construction throws:

try {
    $strate = $stratifiedPersist->createNewVersion(
        [BookingItemManager::class],
        'booking',
        42,
    );

    // Persist the complete snapshot.
    $stratifiedPersist->release('booking', 42, $strate);
} catch (Throwable $exception) {
    if (isset($strate)) {
        $stratifiedPersist->markBuildAsFailed($strate);
    }

    throw $exception;
}

markBuildAsFailed() finds the build by strate UUID, changes its status to failed, updates its timestamp, and persists it.

Failed-build metadata is removed by garbage collection after 24 hours by default.

Reading released data

Finder

$releasedItems = $stratifiedPersist->findReleasedByScopeId(
    'booking',
    42,
);

The finder returns an EntityCollection from the configured root manager. It filters by both the scope identifier and the currently released buildStrate.

When no release pointer exists, the generated query uses an impossible internal strate value and returns no released business rows.

Query builder

Use the released query builder to add business filtering or ordering:

$query = $stratifiedPersist->createReleasedQueryBuilder(
    'booking',
    'item',
    42,
);

$query->addOrderBy('sku');

$releasedItems = $entityManagerFactory
    ->get(BookingItemManager::class)
    ->getResult($query);

The adapter first resolves the central release pointer, then creates a business query constrained by:

  • the configured scope identifier field;
  • the released buildStrate.

A scope identifier value is mandatory for this adapter.

Stratified relation graphs

A scope can contain several versioned managers:

$configs = [
    new StratifiedEntityConfig(
        CatalogManager::class,
        'catalog',
        'shopId',
        scopeRoot: true,
    ),
    new StratifiedEntityConfig(
        CatalogSettingsManager::class,
        'catalog',
        'shopId',
    ),
    new StratifiedEntityConfig(
        ProductManager::class,
        'catalog',
        'shopId',
    ),
    new StratifiedEntityConfig(
        TagManager::class,
        'catalog',
        'shopId',
    ),
    new StratifiedEntityConfig(
        ProductTagManager::class,
        'catalog',
        'shopId',
    ),
];

Every relation between stratified tables must include buildStrate in its key mapping. This prevents a relation loader from joining rows belonging to different snapshots.

One-to-one

#[ToOne(
    CatalogSettingsManager::class,
    ['id' => 'catalogId', 'buildStrate' => 'buildStrate'],
)]
private ?CatalogSettingsEntity $settings = null;

One-to-many

#[ToMany(
    ProductManager::class,
    ['id' => 'catalogId', 'buildStrate' => 'buildStrate'],
)]
private ?EntityCollection $products = null;

Many-to-many

Small Swoole Entity Manager represents many-to-many relations with a join entity. The join entity owns two ToOne relations, and each side exposes a ToMany relation to the join rows.

The join entity must also be stratified and must use the same buildStrate in both foreign-key mappings. This prevents links from crossing snapshot boundaries.

Persistence order

Persist parents before children so foreign keys can be validated inside the same build. Release only after the complete graph has been written:

$strate = $stratifiedPersist->createNewVersion(
    [
        CatalogManager::class,
        CatalogSettingsManager::class,
        ProductManager::class,
        TagManager::class,
        ProductTagManager::class,
    ],
    'catalog',
    $shopId,
);

$stratifiedPersist->persist($catalog, $strate);
$stratifiedPersist->persist($settings, $strate);
$stratifiedPersist->persistMany($products, $strate);
$stratifiedPersist->persistMany($tags, $strate);
$stratifiedPersist->persistMany($productTags, $strate);
$stratifiedPersist->release('catalog', $shopId, $strate);

Build statuses

StratifiedBuildStatus defines four values:

CaseStored valueMeaning
BuildingbuildingThe snapshot is being constructed and is not visible to released queries.
ReleasedreleasedThe snapshot has been published at least once.
FailedfailedSnapshot construction failed.
GarbageCollectedgarbage_collectedObsolete business rows were collected and the metadata is ready for removal.

Garbage collection

Run garbage collection with all managers whose obsolete snapshot rows must be removed:

$stratifiedPersist->garbageCollector([
    BookingItemManager::class,
]);

For relation graphs, pass every manager in the graph. Managers not registered in the service configuration are ignored.

A build is considered obsolete when:

  • it belongs to the same scope and scope identifier as a current release;
  • it is older than the currently released build;
  • its status is building, released, or failed.

After business rows are collected, the build is marked garbage_collected. Garbage-collected build metadata is then deleted. Failed build metadata older than the configured retention period is also deleted.

Default delete strategy

The default DeleteByBuildStrategy issues one delete query per obsolete UUID and manager:

DELETE FROM business_table WHERE build_strate = :buildStrate

The actual column mapping is resolved by the entity manager field metadata.

Partition strategy

Use PartitionGarbageCollectorStrategy when each build is stored in a dedicated database partition or child table:

<?php

declare(strict_types=1);

use Small\SwooleEntityManager\Strates\Config\StratifiedPersistOptions;
use Small\SwooleEntityManager\Strates\GarbageCollector\PartitionGarbageCollectorStrategy;
use Small\SwooleEntityManager\Strates\Service\StratifiedPersist;

$stratifiedPersist = new StratifiedPersist(
    $entityManagerFactory,
    $configs,
    new StratifiedPersistOptions(
        garbageCollectorStrategy: new PartitionGarbageCollectorStrategy(),
        failedBuildMaxAgeHours: 24,
    ),
);

Partition names are derived from the UUID:

strate:     0195f88b-43fd-7f2d-a8fc-3f1fa48f8d29
partition:  p_0195f88b43fd7f2da8fc3f1fa48f8d29

Database behavior:

  • MySQL: ALTER TABLE <table> DROP PARTITION <partition>;
  • PostgreSQL: DROP TABLE IF EXISTS <table>_<partition>.

The application is responsible for creating and routing rows to partitions using the same naming convention.

Custom strategy

Implement GarbageCollectorStrategyInterface:

<?php

declare(strict_types=1);

use Small\SwooleEntityManager\Strates\Contract\GarbageCollectorStrategyInterface;
use Small\SwooleEntityManager\Strates\GarbageCollector\GarbageCollectorContext;

final class CustomGarbageCollector implements GarbageCollectorStrategyInterface
{
    public function collect(GarbageCollectorContext $context): void
    {
        $manager = $context->manager;
        $config = $context->config;
        $obsoleteStrates = $context->obsoleteBuildStrates;

        // Implement the storage-specific cleanup operation.
    }
}

GarbageCollectorContext exposes the manager, its StratifiedEntityConfig, and the list of obsolete strate UUIDs.

Options

StratifiedPersistOptions accepts:

OptionTypeDefaultPurpose
garbageCollectorStrategy?GarbageCollectorStrategyInterfacenullUses DeleteByBuildStrategy when omitted.
failedBuildMaxAgeHoursint24Failed build metadata retention before cleanup. Must be greater than zero.

UUID v7 support

UuidV7::generate() creates RFC-compatible version 7 UUIDs using a 48-bit Unix millisecond timestamp and cryptographically secure random bytes.

use Small\SwooleEntityManager\Strates\Support\UuidV7;

$uuid = UuidV7::generate();

A fixed Unix millisecond value can be supplied for deterministic tests:

$uuid = UuidV7::generate(1_718_000_000_000);

Negative timestamps and timestamps larger than 48 bits are rejected.

Database support

MySQL

  • Tested with MySQL 8.4.
  • Uses DATETIME(6) for metadata timestamps.
  • Uses backtick identifier escaping for partition cleanup.

PostgreSQL

  • Tested with PostgreSQL 16.
  • Uses TIMESTAMP(6) WITHOUT TIME ZONE for metadata timestamps.
  • Uses double-quote identifier escaping for partition cleanup.
  • Uses direct release-thread flushing with entity-manager core 2.7.x to avoid the native PostgreSQL connection-pool cleanup issue in the core commit fallback.

DialectFactory rejects unsupported connection types with UnsupportedDatabaseTypeException.

Exceptions and validation failures

The package exposes dedicated exceptions for common configuration and lifecycle errors:

ExceptionTrigger
UnknownStratesScopeExceptionA method references an unregistered scope.
InvalidStratifiedEntityExceptionA managed entity lacks required fields or does not implement the contract.
BuildNotFoundExceptionA release references a build that does not match the supplied scope and scope identifier.
UnsupportedDatabaseTypeExceptionPartition cleanup receives an unsupported database connection.

Standard InvalidArgumentException, RuntimeException, and LogicException are used for invalid options, duplicate configuration, invalid lifecycle transitions, and inconsistent manager connections.

Public API summary

StratifiedPersist

MethodPurpose
createNewVersion()Creates a building strate and returns its UUID.
createNewStrate()Alias of createNewVersion().
persist()Assigns a strate to one entity and persists it.
persistMany()Persists an iterable of stratified entities.
release()Publishes a build by updating the central release pointer.
markBuildAsFailed()Marks a build as failed.
createReleasedQueryBuilder()Creates a query constrained to the active strate.
findReleasedByScopeId()Returns released root entities for one scope identifier.
garbageCollector()Removes obsolete business rows and metadata.

Supporting public types

  • StratifiedEntityConfig
  • StratifiedPersistOptions
  • StratifiedEntityInterface
  • GarbageCollectorStrategyInterface
  • GarbageCollectorContext
  • DeleteByBuildStrategy
  • PartitionGarbageCollectorStrategy
  • StratifiedBuildStatus
  • ManualPrimaryKeyPersistenceTrait
  • StratifiedEntityTrait
  • UuidV7

Development environment

The repository includes a Docker Compose environment containing:

  • PHP 8.4 CLI;
  • Swoole;
  • Composer 2;
  • MySQL 8.4;
  • PostgreSQL 16.

The project is mounted at:

/usr/lib/src/small-entity-manager-strates

Install dependencies and run commands through the wrapper:

./bin/composer install
./bin/composer validate --strict
./bin/composer lint
./bin/composer phpstan
./bin/composer test

The wrapper starts and builds the Compose stack when the package container is not already running. It forwards the host user and group identifiers so generated files remain writable by the host user.

Tests

Unit tests

./bin/composer test:unit

The unit suite covers:

  • UUID v7 format, timestamp encoding, variant, and bounds;
  • stratified entity metadata;
  • manual primary-key persistence;
  • relation metadata and manual keys;
  • microsecond date metadata;
  • PostgreSQL release persistence behavior;
  • partition naming and SQL generation.

Integration tests

./bin/composer test:integration

Run one database engine only:

./bin/composer test:integration:mysql
./bin/composer test:integration:postgres

The integration suite recreates an empty schema and verifies the same relation and lifecycle behavior against MySQL and PostgreSQL, including:

  • one-to-one loading in both directions;
  • optional one-to-one relations;
  • one-to-one uniqueness per parent and build;
  • one-to-many loading in both directions;
  • empty one-to-many collections;
  • rejection of children without a parent in the same build;
  • many-to-many loading through a join entity;
  • join-row uniqueness;
  • rejection of cross-build links;
  • foreign-key cascade behavior;
  • invisibility of unreleased builds;
  • release-pointer switching;
  • isolation between scope identifiers;
  • idempotent release;
  • one release pointer per (scope, scopeId);
  • rejection of failed, unknown, and mismatched builds;
  • garbage collection across the complete dependency graph.

Integration schemas are stored in:

tests/Fixture/Database/mysql.sql
tests/Fixture/Database/postgres.sql

Complete quality checks

./bin/composer qa

The Composer scripts provide:

ScriptCommand
lintPHP syntax validation for src and tests.
phpstanPHPStan level 9 analysis.
test:unitPest unit suite.
test:integrationPest integration suite.
testComplete Pest suite.
qa / checkLint, PHPStan, and complete tests.

Release process

Releases are created from main or master:

./bin/release --patch
./bin/release --minor
./bin/release --major

The release helper uses Git tags as the only source for version calculation.

Accepted tag formats:

X.Y.Z
X.Y.Z-N

Tags are sorted with version-aware ordering, so 0.10.0 is newer than 0.9.9. When tags with numeric suffixes exist, the highest version-sorted tag is used and the suffix is removed before calculating the next semantic version.

When the repository has no Git tags, the first release is always:

0.1.0

Before creating a commit and tag, the script runs:

composer validate --strict
composer lint
composer phpstan
composer test
composer test:integration

A failed command aborts the release before the version commit and tag are created. On success, .version is updated as generated release metadata, then the commit and tags are pushed.

Operational constraints

  • Write a complete snapshot before calling release().
  • Register every stratified manager used by a scope.
  • Declare one explicit root when a scope contains several managers.
  • Include buildStrate in every relation key mapping between stratified tables.
  • Persist relation parents before dependent rows.
  • Pass every graph manager to garbage collection.
  • Keep metadata and business managers on the intended database connection.
  • Treat released_strates as the source of truth for active snapshots.
  • Configure database partitions before enabling partition-based garbage collection.

Contributing

Development requirements and contribution rules are documented in CONTRIBUTE.txt.

Author

Sébastien Kus

License

Copyright © 2026 Sébastien Kus.

This project is released under the MIT License. See LICENCE.txt.