symfonic.core.prompt.blocks.sources.database¶
database ¶
DatabaseBlockSource -- a prompt block read from, and appended to, SQL.
This is the adapter an operator's own admin system sits behind. Identity
and rules become per-tenant rows rather than redeployed files: the
operator edits a block in their admin surface, the host application calls
:meth:DatabaseBlockSource.append_revision, and the next turn's prompt
reads the new head.
It implements all three block Protocols --
:class:~symfonic.core.prompt.blocks.protocol.BlockSource,
:class:~symfonic.core.prompt.blocks.protocol.HistoryCapableBlockSource
and :class:~symfonic.core.prompt.blocks.protocol.WritableBlockSource --
over the append-only schema in
:mod:~symfonic.core.prompt.blocks.sources.database_schema. It defines no
SQL of its own; every statement it executes is a constant imported from
that module, which is where the append-only assertion runs.
The head is the highest sequence, never the newest timestamp¶
:meth:load returns the row selected by SELECT_HEAD: highest
sequence for this (scope_path, block_id). Ordering by
created_at would be wrong in a way that only shows up under load --
two rows can share a timestamp (coarse clock granularity, a batch insert
inside one transaction), leaving two candidate heads and no principled
tiebreak, and a clock adjustment can order them backwards outright.
sequence is the leading part of the primary key, so it is unique by
construction and the head is a single unambiguous row.
block_id is in every WHERE clause alongside scope_path. One
table backs every block of every tenant; a read that filtered on scope
alone would hand back whichever block's row sorted first.
Writing is append-only, and operator-facing¶
:meth:append_revision issues one INSERT. There is no update, no
delete, no rewind and no restore-in-place on this class -- the
verbs simply do not exist, so no caller can reach for one. Restoring
revision N is performed by reading it with :meth:load_revision and
appending its content as a new revision, which leaves the intervening
revisions in :meth:list_revisions and makes the restore itself an
auditable entry.
This method is not an agent tool. It is called by the host application from its own admin surface, where the operator's identity and authorization model already live. This stage registers no block-edit tool in the agent's palette, ships no admin endpoint, and ships no authorization surface: core cannot see an adopter's permission model, and inventing one here would be a guess wearing the costume of a security control. Authorizing the caller is the host's job, and it happens before this method is reached.
Concurrency has two lines of defence¶
- :func:
~symfonic.core.prompt.blocks.protocol.ensure_expected_headcomparesexpected_headagainst the head just read, and rejects a caller working from a revision that has already been superseded. - The primary key on
(scope_path, block_id, sequence)catches the race the pre-check cannot: two writers that both read head n both pass the pre-check, both try to claim sequence n+1, and the database rejects one of them. That loser's integrity error is translated into the same :class:~symfonic.core.prompt.blocks.protocol.RevisionConflictErrorthe pre-check raises, so a caller mapping conflicts to a 409 handles one exception type and not two.
The pre-check alone would be a check-then-act race; the constraint alone would surface as a driver-specific integrity error with no indication of what the current head actually is. Both are needed.
Offline and scope posture¶
offline_safe is False: every read crosses a connection to another
process. A block served from here does not survive the datastore being
unreachable, and
:func:~symfonic.core.prompt.blocks.validation.warn_if_offline_unsafe
says so when such a block is placed in an authored tier.
scope_aware is True: the isolation key is
:attr:~symfonic.core.scope.TenantScope.scope_path (via
:func:~symfonic.core.prompt.blocks.protocol.block_isolation_key), the
full root-first path rather than tenant_id, so two brands under one
org keep separate blocks and separate histories.
Schema creation is explicit¶
:meth:ensure_schema exists but is never called from a read or write
path. A library that runs DDL lazily on first use is a library that
creates tables in whichever database a misconfigured DSN happened to
point at. The host calls it once at boot, or runs
:data:~symfonic.core.prompt.blocks.sources.database_schema.MIGRATION_UP
through its own migration tool.
BlockDatabaseUnavailableError ¶
Bases: StorageError
The backing database could not serve the block.
Typed as a :class:~symfonic.core.protocols.StorageError so the
resolver routes it through the block's on_source_failure policy
rather than letting a driver exception escape past that policy and
turn an optional block into a failed turn.
BlockRevisionNotFoundError ¶
Bases: BlockDatabaseUnavailableError, NotFoundError
No such revision for this block in this scope.
Raised by :meth:DatabaseBlockSource.load when a block has never
been written, and by :meth:DatabaseBlockSource.load_revision when
the named revision does not exist for this scope -- a revision id
belonging to another tenant is not found here, which is the same
answer an id that never existed gets.
DatabaseBlockSource ¶
Serves prompt blocks from the append-only revision table.
One instance backs every block of every scope in one database: the
(scope_path, block_id) pair in each statement selects the rows,
so a deployment needs one of these rather than one per block.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pool
|
Any
|
Anything exposing |
required |
Attributes:
| Name | Type | Description |
|---|---|---|
offline_safe |
bool
|
Always |
scope_aware |
bool
|
Always |
Source code in src/symfonic/core/prompt/blocks/sources/database.py
append_revision
async
¶
append_revision(
scope: TenantScope,
block_id: str,
content: str,
*,
author: str | None,
message: str | None,
expected_head: str | None,
) -> BlockRevision
Append a new revision of block_id for scope.
Operator-facing: called by the host application from its own admin surface, never registered as an agent tool. Authorizing the caller happens before this method is reached.
One INSERT, no ON CONFLICT clause. Existing revisions are
left exactly as they were, and the previous head stays reachable
through :meth:load_revision.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Isolation argument; the stored key is
|
required |
block_id
|
str
|
The block spec's name. |
required |
content
|
str
|
The new body. |
required |
author
|
str | None
|
Who asked for the write, or |
required |
message
|
str | None
|
Why, or |
required |
expected_head
|
str | None
|
The revision the caller believes is current,
or |
required |
Returns:
| Type | Description |
|---|---|
BlockRevision
|
The appended :class: |
Raises:
| Type | Description |
|---|---|
RevisionConflictError
|
|
Source code in src/symfonic/core/prompt/blocks/sources/database.py
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 | |
ensure_schema
async
¶
Create the revision table if it is absent. Idempotent.
Never called from :meth:load or :meth:append_revision; see
the module docstring for why DDL stays off the hot paths.
CREATE TABLE IF NOT EXISTS is not race-safe on every engine:
two replicas booting at once can both attempt it, and the loser
may get a duplicate-key error on a system catalog index instead of
a silent no-op. That shape is indistinguishable from any other
unique-violation-shaped exception, so it is recognised with the
same :func:is_unique_violation the write path uses rather than a
second heuristic, and treated as success: the table exists either
way, which is everything this method promises.
Raises:
| Type | Description |
|---|---|
BlockDatabaseUnavailableError
|
The pool or the driver failed for a reason other than losing this race. |
Source code in src/symfonic/core/prompt/blocks/sources/database.py
list_revisions
async
¶
list_revisions(
scope: TenantScope,
block_id: str,
*,
limit: int | None = None,
) -> Sequence[BlockRevision]
Return revisions of block_id for scope, newest first.
Scoped and block-filtered like every other read. History is cumulative, so a history read that dropped either filter would leak strictly more than a current-value read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limit
|
int | None
|
Not part of :class: |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
BlockDatabaseUnavailableError
|
The pool or the driver failed. |
Source code in src/symfonic/core/prompt/blocks/sources/database.py
load
async
¶
Return the head revision of block_id for scope.
The head is the highest sequence for the pair, not the newest
created_at and not whichever row the table returns first.
Raises:
| Type | Description |
|---|---|
BlockRevisionNotFoundError
|
The block has never been written for this scope. |
Source code in src/symfonic/core/prompt/blocks/sources/database.py
load_revision
async
¶
Return one named prior revision of block_id for scope.
scope is part of the lookup, not inferred from revision:
revision ids are unique only within a scope's block, so trusting
the id alone would read across tenants.
Raises:
| Type | Description |
|---|---|
BlockRevisionNotFoundError
|
No such revision in this scope. |
Source code in src/symfonic/core/prompt/blocks/sources/database.py
is_unique_violation ¶
Return True when exc reports a duplicate-key rejection.
Matched without importing any driver: asyncpg, psycopg and friends
carry sqlstate (psycopg2 spells it pgcode), and DB-API drivers
without one (sqlite3) raise a class named IntegrityError.
Importing every driver this adapter might be handed a connection from
would drag optional extras into a code path that only needs to
classify an error.
A driver-reported code is authoritative and decides the answer on its
own: NOT NULL (23502), CHECK and FOREIGN KEY (23503)
violations all subclass IntegrityError too, so matching on the
class name alone -- the pre-fix fallback -- reported every one of them
as a duplicate key. Only a driver that exposes no code at all falls
through to the name-plus-message heuristic, and even then the message
must actually say so; IntegrityError alone is not enough.