PAM Native Nitro
Start here
Section titled “Start here”Every PAM Native product runs on the PAM runtime. Install and verify PAM first, create a Native application, and add this package through PAM Composer:
curl -fsSL https://github.com/push-in/pam/releases/latest/download/install.sh | shpam doctorpam init my-app --template nativecd my-apppam composer require pushinbr/pam-native-nitropam doctorpam native devPAM 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.
Nitro 0.3.3 requires PHP 8.4 and PAM Native 0.6.2 or newer. The package is installed through pam composer. Remove it with pam composer remove.
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.
Built-in sync queue
Section titled “Built-in sync queue”Nitro 0.3.3 includes a durable outbox instead of requiring applications to rebuild that state machine:
use Pam\Nitro\Sync\MutationOperation;use Pam\Nitro\Sync\RetryPolicy;use Pam\Nitro\Sync\SyncQueue;
enum SyncEntityKind: int{ case Message = 1;}
SyncQueue::prepare(function (): void { SyncQueue::enqueue( entityKind: SyncEntityKind::Message, entityId: $message->id, operation: MutationOperation::Upsert, payload: ['body' => $message->body], idempotencyKey: $mutationId, );});
SyncQueue::due(function (array $mutations): void { // Send a bounded page, then acknowledge() or retry().}, limit: 100);markInFlight() records an attempt, acknowledge() removes completed intent,
and retry() schedules exponential backoff through RetryPolicy. Queue states
are Pending=1, InFlight=2, RetryScheduled=3, Acknowledged=4, and
Failed=5; mutation operations are Upsert=1 and Delete=2.
Remote deltas and conflicts
Section titled “Remote deltas and conflicts”DeltaApplier::prepare() creates cursor/outbox models. apply() commits a
bounded set of remote upserts and tombstones atomically with its opaque scoped
cursor; cursor() reads the last committed cursor. Conflict policies are
ServerWins=1, ClientWins=2, LastWriteWins=3, and Manual=4.
ConflictResolver::resolve() applies the selected deterministic policy.
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:
pam 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.
Complete PHP API
Section titled “Complete PHP API”| Type | Public contract |
|---|---|
Nitro |
boot(), query(), createTable(), prepare(), save(), delete(), deleteWhere(), saveMany(), replaceMany(), connection() |
Model |
query(), find(), save(), delete(), attributes(), hydrate() and application-defined table() |
Query |
Immutable where(), orderBy(), latest(), limit(), get(), first() |
Connection |
execute(), query(), executeMany(), transaction() over one named database |
ModelSchema |
Cached reflection through for(model) |
Column / ColumnType |
Reflected column contract; Integer=1, Real=2, Text=3, Blob=4 |
Field, PrimaryKey, Children |
Model mapping attributes |
ChildrenRelation |
Lazy scoped child query through get() |
SyncQueue / OutboxMutation |
Durable mutation enqueue, pagination, in-flight, acknowledgement and retry |
RetryPolicy |
Bounded exponential delayForAttempt() |
DeltaApplier / SyncCursor |
Atomic server deltas and scoped opaque cursor persistence |
ConflictResolver / ConflictPolicy |
Deterministic server/client/LWW/manual resolution |
MutationOperation / MutationState |
Sequential integer-backed outbox protocol enums |
All callbacks are asynchronous completion boundaries for Native database work. Do not read a result immediately after starting an operation; update component state inside the callback.