Changelog
0.9.0-alpha.1, 2026-10-03
refiroo moves to PostgreSQL, so many people can write at the same time without getting in each other's way.
Releases are numbered by plan, not by date, so a higher number can ship before a lower one. All releases
Full changelog
Interim release, published 2026-10-03. This release was not tagged in the repository. This is the changelog's own text for the release; its links to files point at those files at the tag.
Everything since 0.8.0: Base 0.9 packages Q1–Q4, C1, V1, B1 and B2, and the PostgreSQL runtime (PG1–PG3b, PG5; PG4 dropped) with the test harness (H1–H4d-1).
Added
- Base 0.9 Q1: cross-type lookup
GET /api/v1/objects. It lists records of every object type, or of those a repeatableobjectTypeKeynames, in live data, a change set or a publication, with the object list's paging, seek and cursors.applicationKey,parentObjectIdortopLevelnarrow it,searchmatches the name, and filters and sorts take built-in fields. A model-declared field is refused (422 invalid_filterorinvalid_sort) with the per-type route that answers it. Items have the per-type list's shape. Over the 258,000-record volume fixture a first page takes 5–9 ms and a name search with its total 72–80 ms in every context. - Base 0.9 Q2: the workspace edge list.
GET /api/v1/workspaces/{key}/mappingstakespublicationId(withchangeSetIdit is422 mutually_exclusive_context), and the same list is published asGET /api/v1/workspaces/{key}/relations: every edge of any relation type in live data, a change set or a publication, with one response shape. Each route's cursors are its own. A publication's rows carrymappingVersion0.GET /api/v1/relationsis unchanged. - Base 0.9 Q3: the model-aware query
POST /api/v1/workspaces/{key}/queries/objects. It lists one object type's records in live data, a change set or a publication by criteria combined with AND: the object list's field criteria with typed JSON values,ancestor:<type>by ID, and one relation hop (related, inverted withnot). Onesort;selectreturns chosen values underfields;expandadds up to 20 related records per item with theircountand a cursor that continues onGET /relations, one statement per expansion for the page. Paging by cursor, position orfrom, with cursors bound to the body. Model-dependent mistakes are422 invalid_querywith a JSON pointer;asOfwas refused asas_of_not_supporteduntil Q4. Decisions D17–D19. Over the 100,000-item volume fixture every measured query takes 4–91 ms. - Base 0.9 C1: schema 0.8 with consumer API definitions (
consumerApis). A definition declares itskey(theapiKey),contractVersion, exposed types with public names, the shared object's roles and attributes with their sources, projections over relation routes, scoring weights, parameters of kindobjectType,ancestor,relatedandprojection, and lists. Every reference is validated against the release and refused at its JSON pointer; a field's sources share one type (C-D19), the identity isidor a unique attribute, and match fields are searchable or unique (C-D20). Draft compile and release diff reportconsumerContractsagainst the active release, and activation refuses a breaking change without a highercontractVersion(409 consumer_contract_version_required; C-D17, C-D18). Schema 0.5–0.7 models and their hashes are unchanged. Decisions C-D17–C-D20. - Base 0.9 Q4: effective periods and
asOf. Schema 0.8 lets an object type declare oneeffectivePeriodover twodateattributes it binds (inclusive start, exclusive end, either open). Writes refuse an end not after its start with422 invalid_effective_period, live and in a change set, and change-set validation reports it.asOf=YYYY-MM-DDonGET /objects/{objectTypeKey}and in the model-aware query keeps the records effective on that date in live data, a change set or a publication; a type without a period, and the cross-type lookup, refuse it asas_of_not_supported. Activating a release that declares a period over records it would refuse ismodel_migration_requiredwith their count, and removing a period from a type a consumer API exposes needs a highercontractVersion. The starter is content release 0.9.0 on schema 0.8, with a period on master values. Decisions D20–D22. - Base 0.9 V1: the API reference at
/docsis Scalar (MIT) over the management OpenAPI document, replacing Swagger UI and ReDoc (/redocnow answers404). The Scalar script (1.72.2) is bundled with the engine, so the page works without internet access and loads nothing from another host;RDM_API_REFERENCE_SCRIPT=cdnloads the same version from jsDelivr under its integrity hash. The page is outside the OpenAPI document, which is unchanged. - Base 0.9 B1: bulk object writes as jobs.
POST /api/v1/batchesrunscreate,update,upsertanddeleteitems inline, live or in a change set, under a required policy:atomic(all or nothing) orindependent(each item alone, in order, seeing earlier applied items). Each item goes through its single write and is refused with the same code; records are named by ID or by a unique attribute's value, an upsert by an ID or a unique attribute, an item that would change nothing isunchanged, and an upsert that finds a record needsexpectedVersionoronExisting: "overwrite". The response is the job with its counts andresultand its outcomes in item order, each with a JSON pointer when refused.Idempotency-Keyanswers a retry with the first job.GET /api/v1/jobs,/jobs/{id}and/jobs/{id}/itemsread jobs back; object history names the job as each change'scause. Limits and retention are settings reported underintegrationJobsinGET /capabilities;INTEGRATION_JOBS_ENABLED=falseremoves job creation. Decisions D23–D26; D13 confirmed: 1,000 upserts take 3.3 s live and 6.8 s in a change set on the volume fixture. - Base 0.9 B2: background batches.
run: backgroundqueues the batch (up to 1,000,000 items in a 50 MB body) and answers202with the job; a worker in the API process applies it in chunks of 500, each committing its changes, outcomes and progress together, so a job resumes after a crash or restart at its first item without an outcome and never applies one twice. The worker holds a lease on the job (leaseOwner,leaseExpiresAt), which another worker or the restarted process takes over once it expires.POST /api/v1/jobs/{jobId}/cancelstops a job at its next chunk (an atomic job rolls back completely); a model release activated meanwhile, a change set written by someone else or an internal error fail it, and the items it did not reach areunprocessedwith the reason. Finished jobs expire after their retention with their summary kept. New settingsBACKGROUND_JOB_MAX_ITEMS,BATCH_BACKGROUND_MAX_BYTES,JOB_CHUNK_ITEMSandJOB_LEASE_SECONDS. Decisions D27–D30. On the volume fixture a 100,000-item background batch runs at 433 items/s live and 151 in a change set; on SQLite, other writes wait until such a job ends.
Changed
-
0.9.0-alpha.1 PG1: PostgreSQL is the only database.
DATABASE_URLis required and must name a PostgreSQL database with the ICU root collationund(PG-D11: a language-independent Unicode order, so names sort case- and accent-aware,Elan, élan, Élan, Emil, where SQLite sorted by code point). Startup refuses a missing or non-PostgreSQL URL, another driver, an unreachable server, another collation, an unknown schema revision and apg_trgmit cannot create, each with what to do and/health503. The 23 SQLite migrations are one PostgreSQL baseline (20261001_01); a migration runs in one transaction.scripts/postgres.sh start|stop|status|backupruns a development server from Homebrew'spostgresql@17; the backend tests start their own and reuse seeded template databases until the migrations, model or seed code change (python -m tests.seeds list|build). The Docker Compose database and CI use ICUund. Existing SQLite databases are carried over in PG4. -
0.9.0-alpha.1 PG1: the relation list's sort by
ancestor:<type>works on PostgreSQL (it used a SQLite-only grouping); the ancestor chosen is unchanged. -
0.9.0-alpha.1 PG2: the PostgreSQL forms. Starting a change set from current live data (
source: currentLive) works on PostgreSQL: the base is read from one snapshot while share locks keep a retirement or an activation from committing, and a change the snapshot cannot hold answers409 change_set_snapshot_retry(change_set_snapshot_unsupportedis gone). Activation and workspace deletion wait for row locks instead of failing on a busy database, somodel_activation_retryandworkspace_delete_retryare no longer returned, and theSQLITE_LOCK_WAIT_SECONDSsetting is removed. Search readspg_trgmGIN indexes over text values (revision20261001_02) with the same results, and case-insensitive search folds every script (ünalfindsÜnal). Foreign keys are enforced; everyondeleterule is pinned intest_foreign_keys, and a record written into a workspace deleted meanwhile still answers404 workspace_not_found. Relation matrix states and pages walk each cell record up to its column once, with nothing joined back: building the states of the volume fixture at scale 0.005 took 14 s and grew about four times per doubling; it now takes 0.3 s, 1.0 s at 0.02 and 7 s at 0.1, and a matrix page at 0.01 went from about 120 ms to 40 ms. Relation expansions and the restructuring planner order names as the database does. The browser runners run on the development server (scripts/postgres.sh start), each on its own database cloned from a seed built once (tests/browser_seeds.py). -
0.9.0-alpha.1 H2: the backend tests run in parallel with
pytest -n auto(pytest-xdist, a dev dependency; each module on one worker, each worker with its own session database). Timing-based tests carry aserialmarker (thevolumegroup and two timer-decided tests); a parallel run leaves them out and names the command that runs them without-n. The 968 parallel tests take 1 min 49 s on 10 cores. -
0.9.0-alpha.1 H3: the full-size batch budgets (
test_batch_volume,test_background_volume) carry abudgetmarker and run only when-mnamesbudget.serialholds only the three tests whose outcome depends on wall time; the counting volume modules run in parallel. The run summary lists every test over 10 s and the wall time. The full suite withoutbudgettakes 2 min 41 s. -
0.9.0-alpha.1 H4b: tests are of two kinds, journey tests in
backend/tests/journeys/that name the user journeys they walk (@pytest.mark.journey, checked against USER_JOURNEYS.md;--journey J02selects them) and unit tests inbackend/tests/unit/that mirror the module they test.pytest --testmonruns only the tests whose code changed,python -m tests.affectedsays what a change requires (migrations, the starter model and the harness select everything), andpython -m tests.journey_traceshows the code each journey reaches and the code no test reaches.test_api_journeyskeeps the API inventory (docs/testing/API_JOURNEYS.md) complete: every operation, parameter, error code and setting with its journeys. -
0.9.0-alpha.1 H4c: every route refuses a query parameter it does not take with
422 unknown_parameternaming each indetails.parameters(a misspeltpagesizewas silently ignored, as was apublicationIdon a write, which then wrote live data).GET /api/v1/workspaces/{key}/mappingsis removed;GET /api/v1/workspaces/{key}/relationsis the same list, and the path keeps the replacement'sPUT. API.md documents the 32 error codes it lacked. -
0.9.0-alpha.1 H4c: journey tests for every journey and variant of the catalogue with an API or operator side (160 tests, 345 runs on two model configurations each, about 1 minute in parallel), written from the catalogue and API.md before the code was read. Each runs on a generated model and on the starter model or a second generated one, against a seed built once through the API. The operator's journey starts the service on prepared databases and runs its commands as processes.
-
0.9.0-alpha.1 H4c, decided from the journey tests: a stale
expectedVersionon a record or relation write answers409 version_conflictwithdetails.currentVersion, as a workspace update did. A live write naming an unknown relation type is422 unknown_relation_type(was 404), and a live create whoseinitialRelationsrepeat one relationship is422 duplicate_initial_relation(was409 cardinality_violationorduplicate_relation), as in a change set. The relation matrix answers an unknownapplicationKeywith404 unknown_application./healthasks the database and answers503while it is unreachable; it recovers without a restart. -
0.9.0-alpha.1 H4c: a duplicate unique value is refused alike live and in a change set: the message names the attribute,
detailscarryattributeTypeKeyandconflictingObjectId(live had no details). Empty text in a unique attribute is no value in a change set too, so records may share it there as live. A change set holds a breach of a binding's ancestor scope written directly (a relation create, initial relations, an edit, a move or a set replacement) as it already held one a moved container brought: the matrix shows the conflict, validation reports it and publishing refuses it, so a change set's edits can come in any order. Live still refuses it at once. -
0.9.0-alpha.1 H4c: a live relation create or initial relation naming a record that does not exist answers
422 relation_endpoint_missingnaming it (was404 object_not_found), as a change set does. -
0.9.0-alpha.1 H4d-1: the jobs and batches tests are journey and unit tests. Four old modules (73 cases) are removed; their checks are in J11, J12 and J24 or in unit tests of
engine/jobs/*,records/audit.py,models/commands.py,config.pyandmain.py(62 cases), which run the engine on a clone of a journey seed without starting the application. 14 faults planted in the area's rules were each caught by the tests that remain. -
0.9.0-alpha.1: no SQLite copy command (PG4 dropped). Starting on a SQLite
DATABASE_URLis refused with the instruction to name a PostgreSQL URL; the message no longer points to a copy command. -
0.9.0-alpha.1 H4: the testing consolidation stops after H4d-1 (H4d-2 onwards and H4e postponed, no dependency for 1.0). Journey tests, unit tests and the older modules form the suite; a package selects its checks with testmon and the journeys it touches, and its test work is capped in proportion to the feature (AGENTS.md).
-
0.9.0-alpha.1 PG3: live writes of one workspace serialize on its row lock, and workspaces write in parallel (PG-D5). Every live write locks its workspace before it reads, so two writes racing for one unique value, a parent cycle, a cardinality limit, a required relation, a relation-set version or one relation matrix cell no longer both commit: the second reads what the first committed and is refused with its single write's code. A relation create and a mapping save on one edge no longer deadlock (a 500); the mapping save answers
409 mapping_version_conflict. A workspace deleted or retired while a write waited is refused with404 workspace_not_foundor409 workspace_retired, nothing saved. A background job holds the lock for one chunk, so a live write in its workspace waits for that chunk only; a cancel request is answered while a chunk runs. The cascade deletion no longer locks three whole tables. Retirement, default switching, deletion and publishing take their locks in one documented order (ARCHITECTURE). A change-set write now locks its workspace against retirement and deletion too: a retirement waits for it instead of committing beside it, and a later write is refused409 workspace_retired. Publishing no longer holds up writes in other change sets of its workspace. -
0.9.0-alpha.1 PG3b: batch items are decided in memory and written in bulk. The create, update and upsert items of an inline batch or a background chunk are checked one after another by their single writes' rules, each against what the items before it left, and the accepted ones are written together: records, attribute values, relations, relation-set versions and one audit event per item, each kind in multi-row statements. A chunk now costs 30–49 statements for 100 or 500 items, at two dataset sizes (a live upsert item alone cost about 20), and a chunk of 500 items takes 0.25–0.46 s; a live write beside a background job waits that long at most. Items are refused with the same codes, messages and details as their single writes, and single writes go through the same code as a batch of one. Delete items keep their per-item path. A bulk write that fails on a row the rules could not see, such as an ID another workspace took meanwhile, is redone item by item, so only that item is refused. An
atomicjob looks for a cancel request before every 500 items and before it writes, instead of before every item, and still rolls back completely. A chunk checks its model release before it writes relation matrix states, so an activation that removes a matrix view can no longer deadlock with it; the chunk failsmodel_release_changed. -
Base 0.9 B1: a change set write's record lookups probe the change set's own rows by key instead of listing all of them on every statement, so a write costs the same however many records the change set holds (a lookup took 361 µs at 4,000 own rows, now 39 µs; page reads unchanged or faster). On SQLite, a write waits up to
SQLITE_LOCK_WAIT_SECONDS(60) for another transaction's writer lock instead of failing after the driver's 5 s. -
Base 0.9 B2: a live write's unique-value check and a record reference by key seek to the value through a new index (
ix_attribute_values_key_text) instead of reading every value of the attribute, so their cost no longer grows with the records holding it (creates into a workspace gaining 30,000 records went from 2.3 to 5.2 ms each; now a flat 2.0 to 2.1 ms). -
Backend boundaries (E1–E3, record).
backend/appis split into tiers:common/,engine/(no FastAPI, no API schemas),management/(the/api/v1routes, schemas, presenters and projections) and theconsumer/slot, checked bytests/test_boundaries.pywith no exceptions, no import cycles and no function-level imports. The write rules are written once (engine/records/rules.py) and read live data or a change set through a lookup; where the two contexts word a refusal differently the difference is one entry inengine/records/refusals.py, pinned bytests/test_rule_parity.py(31 violations, both contexts, two models). Every record, relation and attribute-value row is written throughengine/records/writes.py, the seam F1's feed position goes into. The live and change-set mapping replacement is one flow, andchange_setsis a package. Both OpenAPI documents are unchanged. When one request breaks two parent or relation rules, which one is reported can differ from before. -
A live page's ancestor paths read only the columns a path shows, not whole records. A page of deep records loaded 163 ORM rows for 20 records and now loads 84, on the per-type list and the lookup alike.
-
0.9.0-alpha.1 PG5: every volume budget re-measured on PostgreSQL. Statement and row budgets hold for every read; 143 of the 154 full-volume reads stay within 100 ms. The eleven over it (change-set matrix state filters, change-set searches matching nearly every record, the attribute sort under a 20,000-child parent, the live history-only matrix filter) are deferred to the first fixes after 1.0;
docs/POSTGRESQL_BUDGETS.mdholds the figures, causes and fix plan. Batches: 1,000 upserts in 0.8 s, 10,000 atomic creates in 5.4 s live and 17.8 s in a change set, 100,000 background items in 52 s live and 64 s in a change set (on SQLite 231 s and 663 s), with writes beside the job waiting 0.35 s at most. -
0.9.0-alpha.1 PG5: search reads trigram indexes on record names as well as values (revision
20261003_01). The per-type list reads a search's candidates first and, with at most 1,000, reads only those records, in live data, a change set or a publication: a name or code search with its total over 100,000 records takes 12–23 ms in every context (was 50–226 ms on PostgreSQL before). Results are unchanged; a search matching very many records, or of one or two characters, matches every record as before. A search costs one statement more. -
0.9.0-alpha.1 PG5: the relation matrix's state filter sends its matching rows to the database once per statement instead of once per part of it (change-set filters 210 → 136–154 ms), and the query constructs written to steer SQLite's planner are removed, which made change-set object pages, the ancestor criterion and history sorts faster on PostgreSQL (for example the ancestor criterion in a change set 141 → 69 ms).
-
0.9.0-alpha.1 PG5: version 0.9.0-alpha.1 (backend, API document, frontend package).
Fixed
- 0.9.0-alpha.1 PG5: a 20-row relation expansion of a record with 20,000 relations
(
expandin the model query) took about 0.4 s on PostgreSQL: it walked the name index over every record of the workspace until 20 belonged to the record. It reads the record's relations first and their records by ID: about 70 ms. - 0.9.0-alpha.1 PG5: a change-set read right after the change set was started from current live data
could take seconds: on rows without statistics yet, PostgreSQL looked up each record's attribute
value through an index that read every value of the attribute. That redundant index
(
ix_snapshot_attribute_values_owner_key) is dropped in revision20261003_01. - The background job worker no longer spins when its database is unreachable. A failed first pass left its next sweep in the past, so it retried without waiting and logged a full traceback on every pass (3,111 attempts in half a second in the regression test; a 28 GB log overnight). It now waits its 2 s between retries, logs the traceback once per outage, then one line a minute, and says when the database is reachable again.
- The volume tests' statement counts no longer include the background job worker's own statements, which made a restructuring budget test fail when a run was slow.
python -m app.cli.model_schema_upgrade --helpnames every supported schema (0.5–0.8); it named 0.5–0.7 while it also accepted 0.8.- Startup refused with an unsupported model schema named the supported schemas as 0.5–0.7; it names every supported schema (0.5–0.8).