@astram/engine
The Astram paved road: one fixed, transactional write pipe (type pre-gate → fetch-record →
permission gate → derive → validate → write → junction sync → audit) over real tables. Feature
code receives only the RecordEngine facade (RECORD_ENGINE / RecordsService), never a pool.
The permission gate and the audit stage are mandatory. The consumer guide is
@astram/guide (CAPABILITIES.md).
Duplicate keys: UniqueConflictError
A record create, update, createMany row or operation-write create that hits a UNIQUE index
(driver errno 1062) throws UniqueConflictError (GAPS #58,
docs/features/unique-conflict-error/SPEC.md). Guard's global filter answers it with
409 { statusCode: 409, code: 'unique_conflict', message: 'This record conflicts with an existing one.' }.
- Fields:
typeKey,constraint(the index name, e.g.uq_site_code, ornullwhen the driver message cannot be read safely),rowIndex(createMany: the 0-based row; the whole batch rolled back), and read-onlyerrno: 1062/code: 'ER_DUP_ENTRY'for older catch code. - Match it with
isUniqueConflict(err, '<constraint>'). The driver'ssqlMessageand message are not exposed: they name the value and carry the SQL parameters. The error never carries a value and has nocause. - Inside
transaction(fn)the failed write makes the transaction rollback-only: catching the error and writing again throwsEngineUnavailableError(503), and nothing commits. For the idempotent answer, re-read in a fresh transaction after the catch. - Walled types. A non-root create on a type with
readScope(notcreate: 'unscoped') whose input lies outside the actor's write scope answerscreate_outside_write_scope(403) even when the value is a duplicate, exactly like a fresh value (no oracle on values the actor cannot reach). The scope check on a duplicate compares the input byte-exactly, so a case or trailing-space variant of a scope value on a case-insensitive string column also answers 403 (fail closed); uuid scope columns are always canonical. A supplied primary key that collides with a row the actor cannot reach also answers 403, never a 409 confirming the id exists. If that scope check cannot run, the create fails withEngineUnavailableError(503). - Tenant types: lead every UNIQUE index with the tenant column (e.g.
UNIQUE (s_tenant, s_code)). An index without it spans tenants, so a 409 tells the caller that another tenant holds the value.
lockForUpdate and locked fetches: the walled lock
lockForUpdate(typeKey, id) (inside transaction(fn)), the update/delete pre-fetch and
tx.authorize(typeKey, id, action) inside a transaction lock a row only when the actor could
write it: the same WHERE as an update (whereById: primary key + the actor's tenant + the
readScope write wall; root: primary key only). Before any FOR UPDATE, the engine checks that
the row exists for this actor with a read of the primary key and wall columns only, on the
transaction and, when needed, on a dedicated lock-probe pool (2 connections per worker, same
account and settings as the engine pool, READ COMMITTED per probe, acquire timeout 2000 ms). A
missing, foreign-tenant or out-of-scope id takes no row lock.
Behaviour change (0.19)
Before this version, lockForUpdate on a missing, foreign or out-of-scope id was silently not
locked (and a foreign id was locked by its primary key). From this version it throws the same deny
an update of that id gets: AuthorizationDeniedError with reason no_record_permit (one answer
for all three, no existence signal). Code that locks first and checks for a missing row after must
either check existence first (get inside the transaction, then lockForUpdate) or catch that
deny. The deny comes before any lock statement, so a caller that catches it may continue and
commit the transaction.
EngineUnavailableErrorfromlockForUpdateor a locked fetch means the lock-probe pool did not answer (acquire timeout, connection or query error). No lock was taken; let it propagate (503). Failures are counted (MariaDbUnitOfWork.lockProbeStats(),@astram/engine/internal) and logged as one warning per minute with the count, never ids.- A row inserted by another transaction but not yet committed is not found (the old primary-key lock waited for it).
- If a row is deleted between the existence check and the lock, the
FOR UPDATEtakes a gap lock on the primary-key range around that id until the transaction ends. It locks no existing row and reveals nothing. - The application's ADR-0018 connection budget counts 2 more connections per worker
(
@astram/guardENGINE_LOCK_PROBE_POOL_CONNECTIONS), in both database layouts.
Spec: docs/features/agent-proposals/SPEC.md §5 J (GAPS #51).