Documentation

Data model

BaseModel\<T\>, optimistic concurrency, state, and why the wrapper exists.

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.

Don't strip version

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:

StateMeaning
draftCreated but not visible to non-authors.
activeVisible and operational.
archivedHidden but retained — recoverable.
deletedSoft-deleted. Reaped by PurgeScheduler after a TTL.
pendingAwaiting 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):

POST/repository/state/{datatype}/{id}JWT
curl 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:

GET/repository/get/{datatype}/{id}JWT+APIKEY
POST/repository/find/{datatype}JWT+APIKEY
POST/repository/search/{datatype}JWT+APIKEY
POST/repository/create/{datatype}JWT
POST/repository/update/{id}JWT
DELETE/repository/delete/{datatype}/{id}JWT

Find takes a query + options:

FieldTypeDescription
queryobjectMongoDB-style filter — { "data.tags": "vip" }, { "state": "active" }. Empty {} returns everything.
optionsobjectpageSize, 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:

POST/collection/createJWT

The 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 orgid and sk enforce isolation.
  • Security — what's encrypted at rest, how JWTs are signed.
  • Performance — pagination conventions, caching.