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 and catalog are 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 (advisoryLock, advisoryLockWithIsolation, 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).

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.

Code When
ENGINE_PROFILE_REQUIRES_WRITE_FENCE_DECLARATION The profile’s resolved capabilities omit writeFencecreateSqlBackend 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.
WRITE_FENCE_DECLARATION_INVALID The declared writeFence carries an unrecognized mechanism or drain string, or a drain key on a mechanism other than "advisory"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.
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.