PAM Native Nitro
PAM Native Nitro is the high-performance local data engine for PAM Native. It keeps SQLite work outside rendering, selects only bounded rows needed by the current screen, and moves batches across the native bridge once.
composer require pushinbr/pam-native-nitroNitro 0.3.1 requires PHP 8.4 and PAM Native 0.5.13 or newer. It is a pure Composer package over PAM Native’s database module.
Define models
Section titled “Define models”Models use PHP attributes. Database column names can stay snake case while application properties remain idiomatic PHP.
use Pam\Nitro\Attributes\Children;use Pam\Nitro\Attributes\Field;use Pam\Nitro\Attributes\PrimaryKey;use Pam\Nitro\Model;use Pam\Nitro\Relations\ChildrenRelation;
enum MessageType: int{ case Text = 1; case Image = 2;}
final class Message extends Model{ #[PrimaryKey] #[Field] public string $id;
#[Field(name: 'chat_id', indexed: true)] public string $chatId;
#[Field] public string $body;
#[Field] public MessageType $type = MessageType::Text;
#[Field(name: 'created_at', indexed: true)] public int $createdAt;
public static function table(): string { return 'messages'; }}
final class Chat extends Model{ #[PrimaryKey] #[Field] public string $id;
#[Children(Message::class, foreignKey: 'chat_id')] public ChildrenRelation $messages;
public static function table(): string { return 'chats'; }}Coded domain values use sequential integer-backed enums. Nitro stores the integer and restores the enum during hydration.
Prepare and query
Section titled “Prepare and query”Boot once, then prepare every model before the first query. Preparation creates tables, reconciles additive columns, and creates declared indexes.
use Pam\Nitro\Nitro;
Nitro::boot('zechat.db');Nitro::prepare([Chat::class, Message::class], function () use ($chatId): void { Message::query() ->where('chat_id', $chatId) ->latest() ->limit(20) ->get(function (array $messages): void { $this->messages = array_reverse($messages); });});Queries are immutable builders. Identifiers must belong to the reflected schema, values are bound parameters, and result limits are clamped to 1–1,000 rows. Relations stay lazy:
$chat->messages->get(function (array $messages): void { // Only this chat's rows cross the bridge.});Offline-first synchronization
Section titled “Offline-first synchronization”Prefer one native transaction for a remote snapshot or upload batch:
Nitro::saveMany($messages, function (): void { // One bridge call, one prepared upsert, one transaction.});
Nitro::replaceMany( Message::class, $freshMessages, ['chat_id' => $chatId], function (): void { // Delete and replacement commit atomically inside this chat only. },);saveMany() accepts up to 10,000 homogeneous models. replaceMany() accepts
up to 9,999 replacement models and requires a non-empty scope. The existing
snapshot remains visible until the transaction commits, avoiding an
empty-cache frame.
Delete one model through its primary key or use an explicit scope:
$message->delete();
Nitro::deleteWhere( Message::class, ['chat_id' => $chatId, 'pending' => false],);An empty deleteWhere() or replaceMany() scope is rejected to prevent an
accidental table-wide mutation.
Durable mutation outbox
Section titled “Durable mutation outbox”For offline likes, messages, follows, votes, or uploads, persist the user’s intent before requesting the network. The server idempotency key must identify one specific mutation and remain stable across retries:
enum SyncActionType: int{ case SendMessage = 1; case ToggleLike = 2; case ToggleFollow = 3;}
final class SyncAction extends Model{ #[PrimaryKey] #[Field] public string $id;
#[Field(indexed: true)] public string $userId;
#[Field(indexed: true)] public SyncActionType $type;
#[Field(indexed: true)] public string $dedupeKey;
#[Field] public string $payloadJson = '{}';
#[Field(indexed: true)] public int $nextAttemptAt = 0;
public static function table(): string { return 'sync_actions'; }}
$mutationId = bin2hex(random_bytes(12));$action->dedupeKey = 'post-like:'.$postId.':'.$mutationId;$action->id = hash('sha256', $userId."\0".$action->dedupeKey);$action->payloadJson = json_encode( ['post_id' => $postId, 'liked' => $liked], JSON_THROW_ON_ERROR,);
Nitro::save($action, function (): void { // Dispatch a bounded batch after the local intent is durable.});Update the visible model and cache optimistically, send a bounded batch with
the dedupe key, delete acknowledged rows, and retain failures with exponential
backoff. On process restart, reset any interrupted sending action and replay
it with the same dedupe or idempotency key. This keeps rendering independent
from network latency without duplicating mutations.
Do not reuse one server dedupe key for opposite operations such as like and
unlike: a valid receipt for the first operation may make the second look like
a replay. If rapid local changes should collapse, store a separate semantic
coalesce_key, replace only actions that have not started sending, and issue a
new idempotency key whenever the payload changes.
Schema evolution
Section titled “Schema evolution”Adding a field is non-destructive. Nitro::prepare() compares the model with
PRAGMA table_info, adds missing columns sequentially, and preserves cached
rows. Give new non-nullable properties a safe default:
#[Field]public string $preview = '';Nullable fields migrate to NULL. Integer-backed enums use their first
sequential case when no explicit property default exists. Renames, removals,
and type changes require an explicit application migration.
Execution model
Section titled “Execution model”- SQLite runs on PAM Native’s dedicated database worker, not the UI renderer.
- WAL,
synchronous=NORMAL, foreign keys, a busy timeout, and memory-backed temporary storage are configured by the native database module. - Model schemas are reflected once per process and cached.
- Bulk writes reuse a prepared statement inside one native transaction.
- No JavaScript, JSI, proxy ORM, runtime code generation, or full-database JSON hydration sits in the hot path.
Performance claims
Section titled “Performance claims”Nitro targets at least 10× the performance of full JSON-cache hydration for representative mobile workloads. That target is not a blanket claim against WatermelonDB. A cross-framework multiplier is published only after equivalent indexed schemas, durability, result sizes, physical devices, and release builds have been measured.
Run the serialization baseline with:
composer benchmarkDevice reports should include cold open, last-20 query, 1,000/10,000-row hydration, incremental sync, indexed pagination, optimistic update, memory at 100,000 rows, median, p95, device, OS, build mode, dataset, and exact commit.
See the source repository for the benchmark protocol, architecture details, tests, and release history.