Skip to content

Authoring an engine profile

Backend Setup covers using the two bundled backends. This page is for adapting one: changing a lock spelling, loosening a declared capability, or swapping the resource-audit verdict without hand-copying every other field a profile carries.

A SqlEngineProfile is the data and dialect closures one SQL engine contributes before any backend object exists: dialect tokens, the execution adapter, transaction framing, DDL provisioning, strategies, capability declarations, and an opaque assembly wrapping the operation-backend builder. createSqlBackend is the one factory that turns a profile into a GraphBackend, and it owns everything that is the same for every engine:

  • Capability derivation — running the shared capability tail (atomic-batch detection, vector/fulltext capability shape, contribution-rebuild support) over the profile’s own declaredCapabilities.
  • Fence resolution — building the one write-fence target for the whole backend and resolving its plan once, so every lock site and every transaction-scoped handle agrees on the same decision.
  • Member assembly — resolving the profile’s assembly into its operation-backend builder and late-member factory, then assembling the mirrored member groups (contribution, identity, graph-template, base-schema, index-materialization, kind-removal, schema-version).
  • Marks — auditing the backend’s resource shape and applying the trust marks (root-autocommit eligibility, schema-fenced-insert eligibility, first-party standing) that gate optimizations elsewhere.

createPostgresBackend and createSqliteBackend are each createSqlBackend applied to a profile the bundled builders produce.

import {
buildPostgresEngineProfile,
createSqlBackend,
deriveEngineProfile,
} from "@nicia-ai/typegraph/adapters/drizzle/engine";
const baseProfile = buildPostgresEngineProfile(db, options);
const derivedProfile = deriveEngineProfile(baseProfile, {
// one or more of the derivable fields below
});
const backend = createSqlBackend(derivedProfile);

buildPostgresEngineProfile and buildSqliteEngineProfile are the derivation base: they build a real profile against a real connection, exactly the way createPostgresBackend / createSqliteBackend do internally. deriveEngineProfile(base, overrides) returns a fresh profile — {...base, ...overrides} — with overrides restricted to the fields listed below. createSqlBackend then assembles a backend from the result through the exact same path a bundled profile takes.

If your own module re-exports a derived profile as an inferred-typed const, give it an explicit SqlEngineProfile<TTx> annotation — the opaque assembly field’s internal brand is not itself exported, so tsc cannot name it in a declaration file it has to infer.

Each field below is read directly off the profile object (or off the assembly-derived context) by exactly one place in createSqlBackend, with the one carve-out below — so overriding it changes the whole backend consistently.

Field What overriding it changes
declaredCapabilities The capabilities finalizeEngineCapabilities derives the rest of the backend’s advertised capabilities from — for example, declaring writeFence differently changes which write-fence plan resolves.
fenceSql The lock-statement spelling the resolved fence plan carries; pass undefined to remove it entirely (see Removing fenceSql below).
resourceAudit The serialized-resource verdict createSqlBackend records before the backend escapes.
autocommit Whether a single statement outside an explicit transaction is durable — gates the root-autocommit mark.
contributionRuntime Deps for the contribution-marker member group.
identityRuntime Deps for the identity / recorded-relation member group.
graphTemplateRuntime Deps for the graph-template member group.
baseSchemaRuntime Deps for the base-schema lifecycle member group.
indexMaterializationRuntime Deps for the index-materializations member group.
kindRemovalRuntime Deps for the kind-removals member group.
close The backend’s close member.

DERIVABLE_ENGINE_PROFILE_KEYS (exported alongside DerivableEngineProfileKey and DerivableEngineProfileOverrides<TTx>) is the exact set above, as an as const array.

declaredCapabilities and resourceAudit are otherwise freely derivable, but deriveEngineProfile refuses an override that would change three of their sub-fields — declaredCapabilities.maxBindParameters, declaredCapabilities.execution.interactiveTransactions, and resourceAudit.kind — away from the base profile’s own value, naming the sub-field (ENGINE_PROFILE_OVERRIDE_UNSUPPORTED). This check runs against any base profile, PostgreSQL or SQLite, but it exists for the bundled PostgreSQL builder: buildPostgresEngineProfile reads those exact three sub-values to compute its execution adapter’s own options before the profile object exists, baking a copy of each into profile.execution, which is not itself derivable. Deriving from a SQLite base refuses the same override even though buildSqliteEngineProfile’s operation backend reads maxBindParameters off the resolved capabilities directly and would honor a changed value — the check does not distinguish the two dialects. Every other sub-field on both objects — writeFence, windowFunctions, clearValidTo, returning, claims, graphAnalytics, resourceAudit’s resource / identityLeaseResource, and so on — stays freely derivable.

Every other field is refused for one of these reasons: most are captured by more than the profile’s head alone, so overriding only the head would leave buildOperations, lateMembers, or a member group they build reading the value the base builder closed over; dialect and assembly are refused for different reasons of their own (see the table).

Field Why it’s refused
dialect The operation backend literal hardcodes it.
tableNames Captured by buildOperations and every transaction handle.
execution Captured by buildOperations and every transaction handle.
strategy Captured by buildOperations and every transaction handle.
fulltext Captured by buildOperations and every transaction handle.
vector Captured by buildOperations and every transaction handle.
provisioning ensureTable, catalog, and lineage are all captured by migrations and transaction handles.
assembly Opaque and bundled-only; a derived profile carries the base’s assembly forward by reference, so it resolves to the identical buildOperations / lateMembers pair the base builder closed over.

An override naming any of these throws ConfigurationError with code ENGINE_PROFILE_OVERRIDE_UNSUPPORTED, naming the key, whether or not the type would have allowed it — the check runs against the overrides object’s own keys at runtime, not only its declared type.

These refusals are deriveEngineProfile’s contract, not createSqlBackend’s. A profile spread by hand ({ ...base, execution: mine }) carries the base’s assembly by reference, so createSqlBackend accepts it and applies the override to some members while others keep the builder’s value — exactly the split the refusal exists to prevent. Derive through deriveEngineProfile.

Worked example: a custom advisory-lock spelling

Section titled “Worked example: a custom advisory-lock spelling”

An engine that spells its advisory lock differently from the bundled pg_advisory_xact_lock(hashtext($namespace), hashtext($key)) form — hashing one concatenated string instead of two separate arguments — derives a FenceSql and passes it as an override:

import {
buildPostgresEngineProfile,
createSqlBackend,
deriveEngineProfile,
} from "@nicia-ai/typegraph/adapters/drizzle/engine";
import { postgresFenceSql } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
import type { FenceSql } from "@nicia-ai/typegraph/backend";
import { sql, type SqlFragment } from "@nicia-ai/typegraph";
function customAdvisoryLockExpression(
namespace: string,
key: string | number,
): SqlFragment {
const keyText = typeof key === "number" ? String(key) : key;
return sql`pg_advisory_xact_lock(hashtext(${namespace} || ':' || ${keyText}))`;
}
const customFenceSql: FenceSql = {
advisoryLockExpression: customAdvisoryLockExpression,
lockTables: postgresFenceSql.lockTables,
isolationFactExpression: postgresFenceSql.isolationFactExpression,
};
const baseProfile = buildPostgresEngineProfile(db, options);
const derivedProfile = deriveEngineProfile(baseProfile, {
fenceSql: customFenceSql,
});
const backend = createSqlBackend(derivedProfile);

advisoryLockExpression is the custom spelling here; lockTables and isolationFactExpression are the bundled PostgreSQL builders, reused because this example leaves them unchanged — a custom FenceSql need not replace every member. TypeGraph derives the standalone-statement forms every lock site actually calls (acquireKeyed, acquireKeyedWithIsolation, isolationFact) from these two expressions, so customFenceSql never spells a statement and its expression separately — the two cannot disagree about what they lock or read. This is the same customAdvisoryLockExpression pinned by tests/engine-profile-derivation.test.ts against a real PostgreSQL connection, trimmed of the customLockTables / customIsolationFactExpression coverage this example doesn’t need.

Every write-fence lock site now spells its lock through customFenceSql instead of the bundled one — including the recorded graph-write fence, which fuses its lock into its own CTE (buildLockSchemaVersionAndGraphWrite) but reads advisoryLockExpression / isolationFactExpression off the resolved fence target rather than a hardcoded bundled spelling, so this derivation reaches it too. The ONE exception, not reachable through fenceSql, is the schema-commit fence (acquireSchemaWriteFence in postgres.ts): it emits a standalone, single-argument pg_advisory_xact_lock call through advisoryLockSingleExpression, baked directly into buildPostgresEngineProfile’s closure. It deliberately occupies a different lock space from every two-argument lock fenceSql spells, so it is not an oversight fenceSql could close even if it were derivable — reaching it needs a from-scratch profile (see What is not derivable yet). The graph-template instantiation statement is a different, already-reachable case: it is the instantiateStatement member of graphTemplateRuntime, one of the fields this same derivation can override (see the table above).

Worked example: a portable row-mechanism fence

Section titled “Worked example: a portable row-mechanism fence”

An engine with no advisory-lock primitive at all — a PostgreSQL-wire engine with no working pg_advisory_xact_lock — declares mechanism: "row" instead. TypeGraph spells the keyed acquisition itself against the never-dropped fences relation, so this derivation needs no advisoryLockExpression at all — only the declared mechanism and its two facts, drain and conflict:

const derivedProfile = deriveEngineProfile(baseProfile, {
declaredCapabilities: {
...baseProfile.declaredCapabilities,
writeFence: {
mechanism: "row",
drain: "quiescent",
conflict: "commit-time",
},
},
});
const backend = createSqlBackend(derivedProfile);

conflict states which of the two ways this engine resolves two writers of one fence row: "wait" for a lock-based engine (the second acquirer’s statement blocks, exactly like "advisory"); "commit-time" for an optimistic-concurrency engine, where both acquirers proceed and the loser’s COMMIT fails. Declaring "commit-time" on an interactive backend (as here) derives capabilities.execution.unitOfWork: "optimistic-retry" — every store-owned write this backend opens now replays a real commit-time conflict as a whole unit, up to OPTIMISTIC_RETRY_ATTEMPTS (3) attempts, rather than surfacing the raw driver error on the first one. drain: "quiescent" is the simplest legal drain when nothing else needs a real table lock; pass fenceSql.lockTables and declare drain: "table-lock" instead when this engine has one. A fenceSql.isolationFactExpression, if this engine’s wire protocol supports reading it, rides the SAME acquisition statement — pass postgresFenceSql.isolationFactExpression (or a custom one) as fenceSql to keep recorded capture and match-key convergence trusting a real fact instead of failing closed on an unknown one.

Declaring a serializationFailure classifier

Section titled “Declaring a serializationFailure classifier”

isSerializationFailure (the one predicate every retry owner consults) recognizes PostgreSQL’s own 40001 / 40P01 SQLSTATEs and their fixed driver-message fallback. An engine whose commit-conflict shape is something else entirely — a custom error class, a different code — declares execution.serializationFailure so the SAME predicate recognizes it instead of falling through to a raw, unretried failure:

const profile = buildPostgresEngineProfile(db, options);
const backend = createSqlBackend({
...profile,
execution: {
...profile.execution,
serializationFailure: (error) =>
error instanceof Error && error.message.includes("CONFLICT_ON_COMMIT"),
},
});

This is deliberately NOT a deriveEngineProfile override: execution is captured by buildOperations and every transaction handle (see What you cannot override below), so deriveEngineProfile refuses it like every other field in that table. Hand-spreading execution this way is safe for reading serializationFailure itself, because createSqlBackend is the only reader of profile.execution.serializationFailure — it registers the classifier against the exact backend object it is about to return, once, at construction — while every other execution member (compile, execute, runExclusive, and so on) rides forward as the SAME function reference the base builder closed over, spread unchanged. createSqlBackend consults the registered classifier for isSerializationFailure calls that pass this backend (or one of its transactions) as target; every store-owned unit routed through runRetriedUnit, and store.transaction’s own retry, already does.

That safety is narrow, and it does not extend to the profile object itself. {...profile, execution: {...}} is a plain object literal — a different object from the one buildPostgresEngineProfile returned — so isFirstPartyProfile no longer recognizes it. createSqlBackend gates every markFirstPartyFactory call on that check, so a hand-spread profile loses standing to two optimizations, silently and with no functional difference to catch in testing: the dialect-derivation write-fence fallback (moot here, since the spread profile still carries writeFence declared) and the lazy schema-fence lease (withTransactionSchemaFenceLease, src/store/operations/write-transaction.ts), which falls back to the conservative per-call fence instead. Accept that trade for a one-off serializationFailure override; a backend meant to keep first-party standing declares serializationFailure inside the builder function that constructs profile in the first place, rather than spreading the finished object afterward.

fenceSql is the one field a derived profile can clear: pass fenceSql: undefined to drop the bundled spelling entirely. That alone is not enough to reach a working profile — createSqlBackend still resolves a write-fence plan eagerly, and a profile whose resolved writeFence.mechanism is still "advisory" with no fenceSql refuses with WRITE_FENCE_SQL_UNAVAILABLE. Pair it with a declaredCapabilities override that stops claiming "advisory" (for example, declaring writeFence: { mechanism: "engine-serialized" } instead — no drain key: that field applies only to mechanism: "advisory") to actually resolve an engine-serialized plan that needs no lock spelling at all.

EngineProvisioning.lineage forwards onto the assembled backend’s optional lineage member unchanged, exactly like provisioning.catalog forwards onto catalog. Neither bundled profile sets it: buildPostgresEngineProfile and buildSqliteEngineProfile both leave it undefined, so a store built on a bundled backend derives its lineage from its own recorded relations when history: true is on, and has none otherwise (see Lineage and pruned diffs). An engine whose storage layer already tracks a whole-database revision and can answer “what changed in this graph since revision R” more cheaply than a full scan supplies lineage directly.

Both revision and changesSince take a session as their first argument. Run each read on that session (session.execute or session.executeRaw); a connection captured by the strategy may see a different snapshot. A caller planning outside a transaction passes the root backend. A backend that also needs lineage inside transactions must thread it through EngineProvisioning.lineage so transaction handles expose the same capability.

revision() returns an opaque token comparable only with revisions from the same lineage source. changesSince() must report every changed node and edge key, including inserts, updates, deletes, and resurrection. Return { kind: "unbounded" } when the delta cannot be bounded. TypeGraph uses lineage to prune branch diffs when it has a TypeGraph-owned revision anchor; for stores without revision tracking, base@V uses a complete content fingerprint regardless of engine lineage. That fingerprint covers current identity assertions and is recomputed inside the target commit transaction. Previously minted engine: base tokens are retired.

Test a new lineage against tests/backends/integration/lineage-conformance.ts’s registerLineageConformanceIntegrationTests (registered per-dialect through createIntegrationTestSuite, or called directly against your own backend, via { getStore: () => ({ backend }) }). It registers two describes: only “lineage: recorded-relations conformance” is portable — it drives every case through resolveLineage, the same path a real caller takes, and is the case the bundled recorded-relations derivation passes: after N writes, changesSince(r0) is exactly the touched keys, changesSince(rN) is empty, a hard delete after a revision reports the deleted key once, and an unrecognized revision is unbounded. “lineage: capture-completeness evidence” is TypeGraph-specific — it exercises recordedRelationsLineage directly (the per-revision evidence a bare engine revision has no equivalent gap for); an engine profile’s own suite should run against the conformance describe only and skip the other.

EngineProvisioning.recordedTime declares an engine that tracks recorded (system) time itself, rather than through TypeGraph’s own capture relations and clock — a backend that declares it must also declare lineage (engine-native history keeps no recorded relations for TypeGraph to derive a change delta from; createSqlBackend refuses recordedTime without a co-declared lineage with ENGINE_PROFILE_RECORDED_TIME_REQUIRES_LINEAGE). EngineRecordedTimeMembers has two members, source and revisionNow, both this: void.

source(table, revision) names the table expression table ("nodes" | "edges" | "identityAssertions") reads its recorded rows from, AS OF revision — the engine’s own temporal-table syntax, with the interval already folded in (a system-time AS OF clause, or equivalent). It replaces what a TypeGraph-relation-backed source spells as two members: the recorded relation itself (recordedNodesTable/recordedEdgesTable) and a separate recorded_from <= r AND r < recorded_to interval predicate. Because source’s own expression already scopes every row to exactly one revision, there is nothing left for a predicate to narrow — every recorded read this member backs compiles with no interval clause at all. revision is an opaque { revision, recordedAt } pair minted by your own revisionNow below; never parse revision.revision as a number; embed it in the AS OF expression as an opaque token. table is never called with "identityAssertions" today — a recorded identity read (Store. identityAtCoordinate at a past instant, and the query compiler’s historical identity traversal) is refused outright under engine-native ownership before any read compiles, so your implementation must still accept the shared union without that arm ever running.

revisionNow(session) is called on two different kinds of session, and must answer differently for each:

  • On a root backend (store.recordedNow(), store.revisionNow()): the engine’s current COMMITTED revision.
  • On an open transaction() handle (both places TransactionReceipt.recorded is stamped, called before that transaction’s own COMMIT): the revision at which THIS transaction’s writes will become visible once it commits — the engine’s pending/next revision for that session, not the last one committed before it opened. TypeGraph stamps this still-uncommitted value straight into the receipt it hands back to the caller once the transaction succeeds.

An engine that can only name its last-COMMITTED revision, never its own pending one from inside an open transaction, cannot implement recordedTime: stamping the last-committed value into a receipt would describe the state before the write the receipt is reporting on, and there is no correct point after COMMIT to read the right value from without reopening the race recordedTime exists to close.

Declaring recordedTime changes what history: true means on your profile. TypeGraph’s own recorded relations, clock, and write-fence-gated clock allocation are never engaged; revisionTracking: true is refused whether or not history: true is also requested (ENGINE_NATIVE_REVISION_TRACKING_UNSUPPORTED — there is no TypeGraph clock for it to advance, and the engine’s own revision is only ever available under history: true); a recordedRead external binding is refused (ENGINE_NATIVE_RECORDED_READ_UNSUPPORTED — there is no TypeGraph recorded relation for one to populate); and migrateLegacyRecordedTime refuses outright (ENGINE_NATIVE_MIGRATE_RECORDED_TIME_UNSUPPORTED — it rewrites TypeGraph’s own recorded relations, which your backend does not have). RecordedInstant anchors from a recordedTime-declaring store use the e1:<opaque engine revision>:<ISO instant> form rather than TypeGraph’s r1:<16-digit revision>:<ISO instant> form; asOfRecorded refuses an anchor minted under the other ownership form with RECORDED_INSTANT_OWNERSHIP_MISMATCH. See Engine-native recorded time for the full reader contract.

Code When
ENGINE_PROFILE_REQUIRES_WRITE_FENCE_DECLARATION The profile’s resolved capabilities omit writeFence — createSqlBackend has no write-fence decision to resolve and refuses outright, naming the one capabilities line to add.
WRITE_FENCE_SQL_UNAVAILABLE The resolved capabilities declare mechanism: "advisory" but the profile’s fenceSql is missing the member that mechanism/drain combination needs; or mechanism: "row" with drain: "table-lock" but no fenceSql.lockTables. A "row" target missing tableNames.fences is NOT refused here — it refuses the first time a keyed site actually acquires the fence row.
WRITE_FENCE_DECLARATION_INVALID The declared writeFence carries an unrecognized mechanism, drain, or conflict string; a drain key on a mechanism other than "advisory" / "row"; a conflict key on anything but "row"; or conflict: "commit-time" on a target whose own capabilities.execution.interactiveTransactions is false — that value is honored only by the "optimistic-retry" execution tier, which never derives without an interactive transaction to replay inside, so accepting it there would silently drop it rather than apply it. resolveWriteFencePlan validates the raw value (a plain-JavaScript author is not held to the discriminated-union type) before shaping a plan from it.
CALLER_SERIALIZED_REFUSES_ADOPTION adoptTransaction was called on a backend whose resolved write-fence plan is caller-serialized — an externally owned transaction’s lifetime cannot be held by the backend’s in-process write-unit queue.
CATALOG_UNAVAILABLE A store path that needs the backend’s catalog probes (index materialization, the recorded-time schema check, the recorded-time migration’s column read) finds catalog absent — a profile whose provisioning.catalog is unset builds a backend with no catalog member at all.
LINEAGE_UNAVAILABLE A caller reached requireLineage and found lineage absent on the backend it asked. Every OUT-OF-TRANSACTION graph-merge caller consults lineage through resolveLineage, which already falls back to the recorded-relations lineage or to a full comparison rather than hitting this refusal. assertTargetUnchanged’s in-transaction re-validation reads the transaction handle’s lineage ONLY — no fallback to the root — so this fires whenever a lineage that anchored the plan (found on the root at plan time) is not ALSO threaded onto the transaction handle that commits it; see “Supplying lineage” above for how to thread it correctly.
ENGINE_PROFILE_RECORDED_TIME_REQUIRES_LINEAGE The profile declares recordedTime without also declaring lineage — engine-native history keeps no recorded relations of its own for TypeGraph to derive a graph-merge change delta from, so the engine’s own lineage is the only source for one. Raised at createSqlBackend construction, naming both members.
RECORDED_TIME_UNAVAILABLE A caller reached requireRecordedTime and found recordedTime absent on the backend it asked. Store construction under history: true and the shared recordedNow()/revisionNow()/receipt-stamping read are the only callers today, both reached only once recordedTimeOwnership has already resolved to "engine-native", so this is defense-in-depth rather than a reachable misconfiguration on a bundled backend.
ENGINE_NATIVE_REVISION_TRACKING_UNSUPPORTED A store was constructed with revisionTracking: true against a backend that declares recordedTime, whether or not history: true was also requested — engine-native has no TypeGraph clock for revisionTracking to advance on its own.
ENGINE_NATIVE_RECORDED_READ_UNSUPPORTED A store was constructed with an external recordedRead binding against a backend that declares recordedTime — there is no TypeGraph recorded relation for one to populate; engine-native’s own recorded reads are sourced from recordedTime.source instead.
ENGINE_NATIVE_RECORDED_IDENTITY_UNSUPPORTED Store.identityAtCoordinate at a past recorded instant, or the query compiler’s historical identity traversal, was reached under engine-native recorded time — identity history reads TypeGraph’s own recorded relations directly, which an engine-native backend does not populate.
ENGINE_NATIVE_MIGRATE_RECORDED_TIME_UNSUPPORTED migrateLegacyRecordedTime was called against a backend that declares recordedTime — the migration rewrites TypeGraph’s own recorded relations, which an engine-native backend does not have.
RECORDED_INSTANT_OWNERSHIP_MISMATCH asOfRecorded(instant) was called with an instant minted under the OTHER recorded-time ownership form — an r1: instant against an engine-native store, or an e1: instant against a TypeGraph-owned one.
ENGINE_PROFILE_OVERRIDE_UNSUPPORTED deriveEngineProfile’s overrides names a key outside the derivable set, or one of the three adapter-backed sub-fields with a changed value (see the carve-out).
ENGINE_ASSEMBLY_UNRECOGNIZED The profile’s assembly is not a value assembleEngine produced — a profile built by hand rather than obtained from a bundled builder (optionally adapted with deriveEngineProfile).

First-party standing is bound to the exact profile object one of the two bundled builders returned, not to a field — a derived profile is a new object neither builder ever saw, so it never carries that standing forward, even when every field is copied from a first-party profile unchanged. That costs a derived profile’s backend two things:

  • No dialect-derivation fallback. resolveWriteFencePlan’s fallback for a profile with no writeFence declared is sound only for the two bundled dialects, so it never applies to a derived profile regardless — irrelevant in practice as long as declaredCapabilities is kept, since both bundled declarations already carry a write-fence declaration explicitly.
  • No lazy per-transaction schema-fence lease. The lease store/operations/write-transaction.ts takes out under isFirstPartyFactory is closed to a derived profile’s backend; each managed write takes its own fence instead.

Every gate createSqlBackend runs — the write-fence-declaration refusal, the mechanism: "advisory" without fenceSql refusal, the schema-fenced-insert and autocommit marks — still applies to a derived profile exactly as it does to a bundled one.

A bundled profile object is frozen once its builder returns it: mutating a field on that exact object throws, rather than silently drifting the profile away from what first-party standing was granted to. deriveEngineProfile is unaffected — it spreads base’s fields into a new object literal, which does not freeze.

Building a profile from scratch — rather than deriving a variant of a bundled one — needs an execution adapter, an operation strategy, and an operation-backend assembly, none of which is exported today. SqlEngineProfile.assembly is opaque, and its only constructor, assembleEngine, is exported from no entrypoint: it is authoring a new engine, not deriving a variant of an existing profile, and waits on a future exported assembly constructor. Until then, derivation from a bundled builder — changing a lock spelling, a capability declaration, a resource-audit verdict, or a runtime dependency bag — is the supported way to adapt a profile.