Skip to content

PAM Native Nitro

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:

Terminal window
curl -fsSL https://github.com/push-in/pam/releases/latest/download/install.sh | sh
pam doctor
pam init my-app --template native
cd my-app
pam composer require pushinbr/pam-native-nitro
pam doctor
pam native dev

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.

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.

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.

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.
});

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.

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.

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.

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.

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.

  • 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.

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:

Terminal window
pam composer benchmark

Device 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.

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.