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
72 changes: 67 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,75 @@ jobs:
STARDUST_TEST_PASS: "root"
run: vendor/bin/phpunit --testsuite Smoke

mariadb-smoke:
name: Smoke against MariaDB ${{ matrix.mariadb-version }} on PHP ${{ matrix.php-version }} (must pass)
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
php-version: ["8.1", "8.2", "8.3", "8.4"]
mariadb-version: ["10.11", "11"]

services:
mariadb:
image: mariadb:${{ matrix.mariadb-version }}
env:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: stardust_test
ports:
- 3307:3306
options: >-
--health-cmd="mariadb-admin ping -h 127.0.0.1 -uroot -proot"
--health-interval=5s
--health-timeout=5s
--health-retries=20

steps:
- uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: pdo, pdo_mysql
coverage: none

- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: ${{ runner.os }}-php${{ matrix.php-version }}-composer-${{ hashFiles('composer.json') }}
restore-keys: ${{ runner.os }}-php${{ matrix.php-version }}-composer-

- name: Composer install
run: composer install --no-progress --no-interaction --prefer-dist

- name: Wait for MariaDB
run: |
for i in $(seq 1 30); do
if mariadb-admin ping -h 127.0.0.1 -P 3307 -uroot -proot --silent 2>/dev/null \
|| mysqladmin ping -h 127.0.0.1 -P 3307 -uroot -proot --silent 2>/dev/null; then
echo "MariaDB is up"; exit 0
fi
sleep 1
done
echo "MariaDB did not become ready in time"; exit 1

- name: Run smoke suite
env:
STARDUST_TEST_DSN: "mysql:host=127.0.0.1;port=3307;dbname=stardust_test"
STARDUST_TEST_USER: "root"
STARDUST_TEST_PASS: "root"
run: vendor/bin/phpunit --testsuite Smoke

mariadb-rejection:
name: Smoke against MariaDB (must fail)
name: Smoke against below-floor MariaDB (must fail)
runs-on: ubuntu-latest

services:
mariadb:
image: mariadb:11
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: stardust_test
Expand Down Expand Up @@ -162,14 +224,14 @@ jobs:
done
echo "MariaDB did not become ready in time"; exit 1

- name: Smoke suite must reject MariaDB
- name: Smoke suite must reject below-floor MariaDB
env:
STARDUST_TEST_DSN: "mysql:host=127.0.0.1;port=3307;dbname=stardust_test"
STARDUST_TEST_USER: "root"
STARDUST_TEST_PASS: "root"
run: |
if vendor/bin/phpunit --testsuite Smoke; then
echo "::error::Smoke suite unexpectedly passed against MariaDB"
echo "::error::Smoke suite unexpectedly passed against below-floor MariaDB"
exit 1
fi
echo "Smoke suite rejected MariaDB as expected"
echo "Smoke suite rejected below-floor MariaDB as expected"
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ here.
this codebase resolves to `SDDPG/adrs/`. Search there before treating a design
question as open — most already have a ruling, and the record wins over any
doc that disagrees with it.
- **MySQL 8.0.13+ only.** MariaDB is deliberately rejected, and a CI job asserts
the suite fails against it.
- **MySQL 8.0.13+ or MariaDB 10.11+, detected not configured.** MariaDB 10.6 and
older, and MySQL 5.7 and older, are rejected and a CI job asserts the suite
fails against a below-floor MariaDB.
- **PHP 8.1 is the floor**, even though CI also tests up to 8.4.
- **Run the three checks** in CONTRIBUTING.md before claiming a change is done.
Several conventions are enforced by tests and will tell you when you break them.
Expand Down
15 changes: 8 additions & 7 deletions CLAUDE.md

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ run, and which conventions check themselves so you don't have to memorise them.
- **PHP 8.1 or newer.** 8.1 is the floor, and CI runs the test suite on 8.1, 8.2,
8.3, and 8.4. Do not use syntax newer than 8.1 — it will compile on your machine
and fail on the oldest matrix job.
- **MySQL 8.0.13+ or Percona 8.0.13+.** The floor is non-negotiable; the schema
registry depends on functional partial unique indexes introduced in 8.0.13.
- **MariaDB is actively rejected**, and a CI job exists specifically to assert that
the suite *fails* against it. That is a feature, not a bug — see the README's
Requirements section for why.
- **MySQL 8.0.13+ or Percona 8.0.13+, or MariaDB 10.11+.** Whichever engine you
point it at is detected, not configured. MySQL's floor is non-negotiable — the
schema registry depends on functional partial unique indexes introduced in
8.0.13; MariaDB has no such index type at any version and gets a generated-column
substitute instead, which is why its own floor (10.11) was set independently.
- **MariaDB 10.6 and older is actively rejected**, and a CI job exists specifically
to assert that the suite *fails* against it. That is a feature, not a bug — see
the README's Requirements section for why.
- Composer, and Node (only if you want to run the markdown linter locally).

## Setup
Expand Down Expand Up @@ -44,8 +47,9 @@ npx --yes markdownlint-cli2@0.23.2 "*.md" "src/**/*.md" ".agent/**/*.md" "docs/*
vendor/bin/phpunit --testsuite Smoke
```

CI runs four jobs: PHPStan, markdownlint, the suite across the full PHP matrix,
and the MariaDB rejection check.
CI runs five jobs: PHPStan, markdownlint, the suite across the full PHP matrix
against MySQL, the same matrix against MariaDB 10.11+ (must pass, same as MySQL),
and the MariaDB-below-floor rejection check (targets 10.6, must fail).

Two notes on static analysis. PHPStan runs at level 8 over `src/` and `bin/`, and
it is pinned to analyse the whole supported PHP range rather than your local
Expand Down
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,13 @@ Some vocabulary here is specific to StarDust — *slot*, *page*, *spread*, *back
**A good fit if you:**

- Need user-defined or per-tenant dynamic fields that are still **filterable at native SQL index speed**, without standing up a separate search cluster.
- Already run **MySQL 8.0.13+ (or Percona)**, either as persistent background processes (systemd, supervisor, or containers) or as a scheduled `bin/stardust tick` on a host with no persistent-process capability.
- Already run **MySQL 8.0.13+ (or Percona)**, or **MariaDB 10.11+**, either as persistent background processes (systemd, supervisor, or containers) or as a scheduled `bin/stardust tick` on a host with no persistent-process capability.
- Want a **framework-neutral** engine you can drop into any PHP app via Composer — no ORM, query builder, or framework pulled in.
- Can tolerate a newly defined or retyped filterable field becoming queryable **shortly after** the fact rather than instantly.

**Probably not a fit if you:**

- Are tied to **MariaDB or MySQL ≤ 5.7** — both are actively rejected (see [Requirements](#requirements)).
- Are tied to **MariaDB ≤ 10.6 or MySQL ≤ 5.7** — both are actively rejected (see [Requirements](#requirements)). MariaDB 10.11+ is supported, with one caveat: range filters and field sorts order supplementary-plane characters (rare outside emoji) at the opposite end from MySQL.
- Need **strong read-after-write consistency on filters immediately after a retype or filterability promotion.** The field is served from the JSON payload (and is not filterable) until its backfill completes.
- Need **full-text, fuzzy, or substring search** out of the box. The default MySQL driver ships exact-match, comparison, range, set-membership, and *anchored*-prefix (`LIKE 'x%'`) operators — but no substring/suffix matching, no fuzzy matching, and no relevance ranking. Fuzzy/full-text is a capability you'd supply via a custom driver.
- Need **page numbers, jump-to-page navigation, or a total result count.** Reads are cursor-paginated and forward-sequential: every page hands you an opaque cursor for the next one, and the absence of a cursor means you have reached the end. There is no offset parameter and no total count, and that is deliberate rather than pending — both require the database to read the entire matching set, so a query that is quick today would slow down purely because the tenant grew. Infinite scroll and a Next button work naturally; a Back button means holding on to the cursors you have already used, and "Page 7 of 214" or a deep link to an arbitrary page cannot be served at all. A driver backed by an external search service can maintain its own index and supply them.
Expand All @@ -166,7 +166,7 @@ Some vocabulary here is specific to StarDust — *slot*, *page*, *spread*, *back
- **Writes** — single-entry, synchronous chunked bulk (≤ 1 000 per call), and async submission for larger batches. Writes stay available even when slot capacity is exhausted: the value still lands in the JSON payload and is queued for backfill.
- **Entry updates and deletes** — `updateEntry()` replaces an entry's fields wholesale, rewriting both the JSON payload and the indexed slot columns, and clearing the slot of any field the new payload omits so a filter can never match a stale value. `deleteEntry()` soft-deletes: one timestamp, after which the entry is gone from reads, filters, point-reads, and exports alike.
- **Reads** — cursor-paginated, two-query bounded read; tenant-isolated SQL on every `WHERE` and `JOIN`; an in-process schema-version cache.
- **Search** — a unified `search()` surface; JSON wire format decoded into a closed filter AST (twelve operators, full AND/OR/NOT); three-stage pre-flight validation on the filter tree (field resolution, capability, value type) plus a fourth stage that validates the sort key and cursor agreement; a swappable driver (MySQL-native default keeps pure-AND filters on indexed joins and switches to `EXISTS` subqueries for OR/NOT — inject your own to delegate to an external search service).
- **Search** — a unified `search()` surface; JSON wire format decoded into a closed filter AST (twelve operators, full AND/OR/NOT); three-stage pre-flight validation on the filter tree (field resolution, capability, value type) plus a fourth stage that validates the sort key and cursor agreement; a swappable driver (the built-in native driver keeps pure-AND filters on indexed joins and switches to `EXISTS` subqueries for OR/NOT — inject your own to delegate to an external search service).
- **Background daemons** (all runnable via `bin/stardust`): the **Watcher** keeps slot capacity provisioned and indexes each new page for the fields currently waiting on one, the **Reconciler** drains six work sources (sync queue, async imports, retype backfills, rename rewrites, field-deletion purges, and model-deletion purges), claiming a slot for any filterable field still waiting on one, with a dead-letter queue and operator replay, and auto-recovery of import jobs abandoned by a crashed worker — resumed from the last committed checkpoint — the **Liberator** reclaims tombstoned slots, and the **Chronicler** streams CSV/JSON exports to disk.
- **Field lifecycle** — online field retype, and filterability promotion and demotion, through a type-coercion matrix, with JSON-payload fallback throughout the backfill window. Demotion is registry-only and takes effect immediately: the slot is tombstoned for the Liberator to reclaim, and reads fall straight back to the payload.
- **Model rename** — `renameModel()` is immediate and complete when it returns: a model's name is a label, not an identity, so entries, slots, filters and exports all keep working untouched and there is no background catch-up to wait for. One caveat: `schemaBuilder()`'s `createModel()` / `defineModel()` find a model by name, so a setup or seed script still using the old name will create a **second** model rather than finding the renamed one — update those scripts in step with the rename.
Expand All @@ -191,15 +191,19 @@ If you need a working library today, stay on `^0.2.0-alpha.x`.

- **PHP:** 8.1 or later
- **PHP extensions:** `ext-pdo`, `ext-pdo_mysql`
- **Database:** MySQL 8.0.13+ **or** Percona Server 8.0.13+
- **Database:** MySQL 8.0.13+ **or** Percona Server 8.0.13+ **or** MariaDB 10.11+

The 8.0.13 floor is firm: StarDust leans on functional/conditional unique indexes, which don't exist below 8.0.13. We'd rather refuse to start than corrupt your registry on an engine that silently does the wrong thing.
The engine detects which one it's talking to at boot — there is no configuration flag to set. MySQL's floor is firm: StarDust leans on functional/conditional unique indexes, which don't exist below 8.0.13. MariaDB has no equivalent index type at any version; the same "at most one live slot per field" invariant is instead enforced there by a generated column plus a plain unique index, which is why MariaDB's own floor (10.11) was chosen independently rather than by mirroring MySQL's. We'd rather refuse to start than corrupt your registry on an engine that silently does the wrong thing.

**One documented behavioral difference on MariaDB:** range filters (`lt`, `lte`, `gt`, `gte`, and a `between` whose bounds straddle it) and field sorts order supplementary-plane Unicode characters — mostly emoji, well outside everyday text — at the opposite end from MySQL. Every other comparison, and ordinary text in any language, is unaffected.

**Not supported:**

- **MariaDB** — its partial-index syntax diverges from MySQL's in a way that would break the slot registry. StarDust detects this and refuses to run, and CI keeps us honest with a dedicated job that *expects* the smoke suite to fail on MariaDB. You find out at boot, not in production.
- **MariaDB 10.6 and older** — a JSON-column collation divergence found below the 10.11 floor has no configuration-only fix.
- **MySQL 5.7 and older** — no partial-unique-index feature, which the schema registry depends on.

Either unsupported engine is detected and refused at boot, not discovered in production.

---

## Deployment Requirements
Expand Down Expand Up @@ -266,7 +270,7 @@ use StarDust\Slot\SlotReserver;
// Provision a page carrying the two slots the filterable fields will use.
// The page is created with exactly these columns, each with its own
// composite (tenant_id, slot) index — the list may not be empty.
(new PageProvisioner($pdo, $engine->config()->clock, $engine->logger()))
(new PageProvisioner($pdo, $engine->config()->clock, $engine->logger(), $engine->serverEngine()))
->provision(filterableSlots: ['i_str_01', 'i_int_01']);

// Reserve one slot per field (free → assigned). Reservation takes the
Expand Down Expand Up @@ -420,7 +424,7 @@ The framework-neutral `bin/stardust` entry point — bootstrap, the four daemons

## Testing

StarDust is covered by a smoke suite that runs against a **real MySQL** — no mocked databases. It skips cleanly when no test database is configured, so a fresh clone runs green out of the box:
StarDust is covered by a smoke suite that runs against a **real MySQL or MariaDB** — no mocked databases. It skips cleanly when no test database is configured, so a fresh clone runs green out of the box:

```bash
composer install
Expand All @@ -430,7 +434,7 @@ vendor/bin/phpunit --testsuite Smoke

A handful of the suite's tests need no database at all (e.g. the wire-format decoder, the event-vocabulary guard, and the schema-conformance cross-check), so they run even on a bare clone.

GitHub Actions runs the same suite on every push, plus a second job that asserts the suite **fails** against MariaDB.
GitHub Actions runs the same suite on every push against MySQL and against MariaDB 10.11+ (both **must pass**), plus a job that asserts the suite **fails** against MariaDB 10.6, which is below the supported floor.

For the full setup guide and a phase-by-phase breakdown of exactly what each behaviour the suite proves, see **[TESTING.md](TESTING.md)**.

Expand Down
Loading
Loading