Every record AppEngine returns is wrapped in a BaseModel\<T\>. The wrapper exists for one reason: to make a multi-tenant store with versioning, soft-delete, and cross-collection indexing work uniformly. Once you internalize the shape, every collection in AppMint reads the same.
The shape
type BaseModel\<T\> = {
pk: string; // primary key (typically `${datatype}-${nanoid}`)
sk: string; // sort key (typically `${orgid}/${datatype}/${id}`)
datatype: string; // collection name — 'contact', 'product', 'order', ...
version: number; // optimistic concurrency, incremented on each write
state: string; // workflow state — 'active', 'draft', 'archived', ...
createdate: string; // ISO 8601
modifydate: string; // ISO 8601
author?: string; // sk or email of creating principal
data: T; // the typed payload
};
Your application code reads record.data.email, not record.email. System fields stay on the wrapper; payload stays in data.
A typical contact looks like:
{
"pk": "contact-abc123",
"sk": "my-org/contact/abc123",
"datatype": "contact",
"version": 3,
"state": "active",
"createdate": "2025-09-01T12:34:56.000Z",
"modifydate": "2026-04-10T08:20:11.000Z",
"author": "[email protected]",
"data": {
"email": "[email protected]",
"firstName": "Lead",
"lastName": "Person",
"tags": ["newsletter"]
}
}
Why the wrapper
Three forces shaped it:
Multi-tenant indexing. The sk encodes the org + datatype + id, so every range scan is naturally tenant-scoped. The MongoDB indexes are built on sk prefixes; you can't accidentally read data from another org with a typed query because the index can't reach there.
Cross-collection consistency. Every collection has version, state, createdate, modifydate in the same place. A generic UI (the admin's record viewer, audit log, automation triggers) works across every collection without per-collection code.
Versioning without a migration. Adding fields to data doesn't require a schema migration. The wrapper guarantees the system metadata is always there even when the payload evolves.
Optimistic concurrency: version
Every write bumps version by 1. Updates that include the expected version are conflict-checked: if the stored version is higher, the write is rejected with a 409. Updates that omit version overwrite blindly — useful for first-write-wins flows but dangerous for concurrent edits.
A safe update flow:
const current = await client.get('contact', 'abc123');
// current.version === 3
const updated = {
...current,
version: 3, // include the version we read
data: { ...current.data, tags: [...current.data.tags, 'vip'] },
};
await client.update(updated);
// returns the new record with version === 4
If another writer beat us to it, the stored version is now 4 and the server returns 409. The client refetches, re-applies the change, and retries. This is the standard pattern for any list-edit flow in the admin.
Every read carries the version you should write back. Stripping it (or hardcoding version: 0) defeats the concurrency check and lets the last write silently clobber concurrent ones.
State transitions
state is a string. Common values across collections:
| State | Meaning |
|---|---|
draft | Created but not visible to non-authors. |
active | Visible and operational. |
archived | Hidden but retained — recoverable. |
deleted | Soft-deleted. Reaped by PurgeScheduler after a TTL. |
pending | Awaiting an approval/processing step. |
Some domains add their own: orders move through placed → paid → fulfilled → completed; broadcasts move through draft → scheduled → sending → sent. The exact state machine is documented per-collection.
State changes go through specific endpoints rather than free-form PATCH so transitions can fire side-effects (events, automations, broadcasts):
/repository/state/{datatype}/{id}JWTcurl https://appengine.appmint.io/repository/state/contact/abc123 \
-H "orgid: my-org" \
-H "Authorization: Bearer ..." \
-H "Content-Type: application/json" \
-d '{ "state": "archived" }'
Generic CRUD
The same controller pattern works for every collection. Documented in detail under each domain page; the core endpoints are:
/repository/get/{datatype}/{id}JWT+APIKEY/repository/find/{datatype}JWT+APIKEY/repository/search/{datatype}JWT+APIKEY/repository/create/{datatype}JWT/repository/update/{id}JWT/repository/delete/{datatype}/{id}JWTFind takes a query + options:
| Field | Type | Description |
|---|---|---|
| query | object | MongoDB-style filter — { "data.tags": "vip" }, { "state": "active" }. Empty {} returns everything. |
| options | object | pageSize, page, sort, select. Defaults to pageSize: 50. |
The find query goes through DynamicQueryModule, which translates a domain-friendly filter into a tenant-scoped MongoDB query. Audience segmentation, automation conditions, and rule-engine evaluations all run through the same query builder — what works in find works in those contexts.
Custom collections
The built-in collections cover most needs (contact, product, order, lead, campaign, event, ticket, etc. — about 150 of them). When they don't, you define your own at runtime:
/collection/createJWTThe custom collection inherits the same wrapper, the same CRUD endpoints, the same RBAC, the same query builder. Add fields to data; everything else is wired automatically.
Reads return arrays of wrappers
When you fetch a list, you get an array of BaseModel\<T\>, not raw payloads:
{
"data": [
{ "pk": "...", "sk": "...", "version": 1, "data": { "email": "[email protected]" } },
{ "pk": "...", "sk": "...", "version": 2, "data": { "email": "[email protected]" } }
],
"total": 2,
"page": 1,
"pageSize": 50
}
UI code that lists records typically maps record.data for display and remembers record.sk and record.version for subsequent updates. Lose either and you can't write the record back safely.
Where to go next
- Multi-tenancy — how the
orgidandskenforce isolation. - Security — what's encrypted at rest, how JWTs are signed.
- Performance — pagination conventions, caching.