See NOTICE for important disclaimers.
extenddb uses two PostgreSQL databases per deployment:
- Catalog database (e.g.,
extenddb_catalog): All metadata — table definitions, indexes, accounts, IAM entities, settings, stream metadata, and schema history. - Data database (e.g.,
extenddb_catalog_data): User item data. Each DynamoDB table maps to a PostgreSQL table. GSI and LSI data are stored in separate PostgreSQL tables.
The data database connection string is stored in the catalog's settings table under the key data_database_url. This allows the catalog and data databases to live on different PostgreSQL instances.
| Table | Purpose |
|---|---|
accounts |
Multi-account support. account_id (12-digit string) is the primary key. |
tables |
DynamoDB table metadata. Composite PK: (account_id, table_name). Stores key schema, attribute definitions, billing mode, stream spec, status, ARN, and TTL config. |
indexes |
GSI/LSI metadata. FK on table_id (UUID) with CASCADE delete. |
tags |
Resource tags. PK: (resource_arn, tag_key). |
settings |
Key-value store for catalog version, data DB URL, and runtime settings. |
schema_history |
Migration tracking. Records which SQL files have been applied. |
admin_users |
Admin credentials (bcrypt-hashed passwords). |
iam_users |
IAM users scoped to accounts. Optional console password. |
iam_groups |
IAM groups scoped to accounts. |
iam_group_members |
User-to-group membership. |
iam_roles |
IAM roles with trust policies. |
iam_user_policies |
Inline policies attached to users. |
iam_group_policies |
Inline policies attached to groups. |
iam_role_policies |
Inline policies attached to roles. |
access_keys |
Access key ID + AES-256-GCM encrypted secret key. |
iam_user_tags |
Tags on IAM users. |
iam_role_tags |
Tags on IAM roles. |
permissions_boundaries |
Permissions boundaries for users and roles. |
encryption_keys |
AES-256-GCM key used to encrypt access key secrets. |
stream_shards |
Stream shard metadata (parent shard, sequence range). |
stream_records |
Stream change records with 24-hour retention. |
Each DynamoDB table T in account A maps to a PostgreSQL table named t_{table_id} in the data database. The table has:
pkcolumn: Partition key value (stored as JSONB)skcolumn: Sort key value (JSONB, nullable for hash-only tables)itemcolumn: Full item as JSONB- Primary key:
(pk)or(pk, sk)
GSI tables are named gsi_{table_id}_{index_name} with the GSI key columns and a copy of projected attributes. LSI tables are named lsi_{table_id}_{index_name}.
- Cross-table foreign keys use
table_id(UUID), nottable_name, to support future table rename operations. - All IAM entities are scoped to
account_idvia foreign keys to theaccountstable. - CASCADE deletes ensure that deleting an account removes all its IAM entities, and deleting a table removes its indexes.
The expression engine lives in core and operates on in-memory AttributeValue types. It handles five expression types:
Evaluated before writes to enforce preconditions. Supports:
- Comparisons:
=,<>,<,<=,>,>= - Functions:
attribute_exists,attribute_not_exists,attribute_type,begins_with,contains,size - Logical:
AND,OR,NOT BETWEENandINoperators- Nested attribute paths with dot notation and array indexing
Condition evaluation happens inside the storage transaction (after SELECT FOR UPDATE) to prevent TOCTOU races.
Applied after reads (Query/Scan) to exclude non-matching items. Same syntax as ConditionExpression. Filter expressions do not reduce consumed capacity — all scanned items count toward RCU.
Applied during UpdateItem to modify attributes. Four clauses:
SET: Assign values, withif_not_exists()andlist_append()functionsREMOVE: Delete attributes or list elementsADD: Numeric addition or set unionDELETE: Set subtraction
Update expressions are applied inside the storage transaction after condition evaluation.
Applied after reads to return only requested attributes. Supports nested paths. If omitted, all attributes are returned.
Parsed by the engine and translated to SQL WHERE clauses by the storage backend. Supports partition key equality and sort key conditions (equality, range, begins_with, between).
extenddb uses SigV4 signature verification with a local IAM credential store. This is the only supported authentication mode.
Full SigV4 signature verification:
- Extract
Authorizationheader components (credential, signed headers, signature) - Look up access key in the credential store (database-backed, credential lookup per request; encryption key cached at startup)
- Reconstruct the canonical request and string-to-sign
- Derive the signing key:
HMAC-SHA256(HMAC-SHA256(HMAC-SHA256(HMAC-SHA256("AWS4" + secret, date), region), service), "aws4_request") - Compare computed signature with the provided signature (constant-time comparison)
- Return
AuthIdentity::UserorAuthIdentity::RoleSessionwith account context
After authentication, the authorization layer evaluates IAM policies using a 5-phase algorithm:
- Explicit Deny — scan all policies (identity, permissions boundary, session). Any matching Deny → access denied.
- Permissions Boundary — if set, must contain a matching Allow → else denied.
- Session Policy — if set (AssumeRole), must contain a matching Allow → else denied.
- Identity Allow — scan identity policies (user, group, role). Any matching Allow → access granted.
- Implicit Deny — no matching Allow → access denied.
Policy conditions support all IAM condition operators: StringEquals, StringNotEquals, StringEqualsIgnoreCase, StringLike, StringNotLike, NumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEquals, DateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEquals, Bool, Null, ArnEquals, ArnNotEquals, ArnLike, ArnNotLike, plus ForAllValues, ForAnyValue, and IfExists modifiers. Supported condition keys include aws:PrincipalTag/*, dynamodb:ResourceTag/*, dynamodb:LeadingKeys, dynamodb:Attributes, dynamodb:Select, dynamodb:ReturnValues, dynamodb:ReturnConsumedCapacity, dynamodb:FullTableScan, and dynamodb:EnclosingOperation.
Access key secrets are encrypted at rest using AES-256-GCM. The encryption key is generated during extenddb init and stored in the encryption_keys table. Each access key record stores the encrypted secret and a unique nonce.
Credential lookups (access key → encrypted secret) read directly from the database on every request — there is no in-process cache for credentials. The encryption key used to decrypt secrets is cached at startup because it is immutable after extenddb init (see Caching Design below).
Stream records are captured atomically with data writes. The engine constructs a StreamCapture struct with metadata (stream ARN, view type, shard ID, sequence number, keys). The storage backend persists the stream record in the same PostgreSQL transaction as the data write.
For UpdateItem, the new_image is not known until after apply_update runs inside the transaction, so the storage backend constructs the full StreamRecord after the update.
Each stream has a fixed set of shards (currently 4 shards per stream). Shard IDs are deterministic (shardId-<table>-000000000000 through shardId-<table>-000000000003). Sequence numbers are monotonically increasing integers.
TRIM_HORIZON: Start from the oldest available recordLATEST: Start from the most recent recordAT_SEQUENCE_NUMBER: Start at a specific sequence numberAFTER_SEQUENCE_NUMBER: Start after a specific sequence number
Iterators expire after 15 minutes of inactivity.
Stream records are retained for 24 hours. A background task runs hourly to delete expired records.
All user-supplied strings are validated at the engine layer before reaching storage. The storage layer uses parameterized queries exclusively — no dynamic SQL construction with user input. See docs/adr/sql-injection-defense.md.
Storage traits use BoxFuture for object safety, allowing dynamic dispatch of storage backends. Auth traits use #[async_trait] for the same reason. The per-request allocation cost is negligible compared to I/O and crypto operations.
Condition expressions are evaluated inside the storage transaction (after SELECT FOR UPDATE) rather than in the engine layer. This prevents TOCTOU races where another request could modify the item between condition check and write.
extenddb calculates consumed capacity matching real DynamoDB:
- Read capacity: Item size rounded up to 4 KB. Eventually consistent reads cost 0.5 RCU per 4 KB. Strongly consistent reads cost 1.0 RCU per 4 KB. Transactional reads cost 2.0 RCU per 4 KB.
- Write capacity: Item size rounded up to 1 KB. Standard writes cost 1.0 WCU per 1 KB. Transactional writes cost 2.0 WCU per 1 KB.
- Table-level and index-level: When
ReturnConsumedCapacityisINDEXES, capacity is broken down per table and per index.
Item size includes attribute names and values, matching DynamoDB's size calculation rules.
extenddb caches a small set of operational settings in memory to avoid per-request database queries on hot paths. Catalog state (table metadata, auth policies, tags, GSI definitions) is never cached.
| Setting | Mechanism | Refresh | Justification |
|---|---|---|---|
gsi_propagation_delay_ms |
AtomicU64 |
Background poller every 30s | Write-path hot path; briefly-stale value only affects GSI propagation timing |
encryption_key |
Arc<str> loaded at startup |
Never (immutable after extenddb init) |
Decryption key for access key secrets; generated once, never changes |
log_level / log_destination |
Tracing filter reload | Background poller every 30s | Observability tuning; stale value only delays log level changes |
throttling_enabled |
AtomicBool |
Background poller every 30s | Capacity management toggle; briefly-stale is safe |
All cached values are operational tuning knobs where a briefly-stale value does not affect correctness.
Catalog state is never cached because correctness requires every request to see the current state:
- Table metadata (key schema, attribute definitions, status, billing mode): A stale cache could serve the wrong key schema after a table is deleted and recreated with the same name but different schema. The new table has a different
table_id, different key schema, and different indexes — stale cache serves wrong schema, writes corrupt data, reads return garbage. - IAM policies and credentials: A revoked Deny policy still cached as absent creates a security gap. A deleted access key still cached as valid allows unauthorized access.
- Tags: Tag-based authorization (
dynamodb:ResourceTag/*) requires current tag values. - GSI definitions: Stale GSI metadata could route writes to wrong index tables.
The fundamental reason catalog state cannot be cached safely:
- Client calls
DeleteTable("Orders") - Client immediately calls
CreateTable("Orders")with a different key schema - New table gets a new
table_id, new key schema, new indexes - A stale cache still maps "Orders" → old
table_idwith old key schema - Writes use wrong column layout → data corruption
- Reads return items with wrong attribute interpretation → garbage
No safe TTL exists because delete-recreate can happen within milliseconds. Cross-instance invalidation (e.g., PostgreSQL LISTEN/NOTIFY) would be a prerequisite for any future catalog caching.
extenddb does not enforce single-instance-per-catalog. Multiple extenddb instances may share the same PostgreSQL catalog. Any in-process cache of catalog state would be invisible to other instances. PostgreSQL's own buffer pool provides memory-resident access to hot rows, making application-level caching unnecessary for most workloads.
Caching of operational settings is currently unconditional. If issues arise (e.g., a setting change must take effect immediately for safety reasons), a runtime toggle (extenddb settings set caching_enabled false) should be added. Catalog caching remains prohibited without a cross-instance invalidation design and explicit human approval.
Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. See LICENSE for the full text.
This software is provided "as is" without warranty of any kind. ExtendDB is not affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is a trademark of Amazon.com, Inc.