Almost every collection in AppEngine — built-in or custom — is reachable through one controller: RepositoryController. Once you know its shape, every datatype (products, contacts, leads, posts, your custom widgets) follows the same conventions.
The BaseModel\<T\> envelope
Every record on the wire is wrapped:
// confirm shape against AppEngine: src/repositories/repository.controller.ts
interface BaseModel\<T\> {
pk: string; // partition key (usually orgId-scoped)
sk: string; // sort key — the record's stable id
name?: string; // unique-within-datatype name
datatype: string; // collection name, e.g. "product", "contact"
data: T; // YOUR fields live here
version: number; // monotonic; increments on update
state?: string; // draft | published | archived | deleted | ...
createdate?: string;
modifydate?: string;
author?: string;
owner?: { id: string; email?: string; name?: string; datatype?: string };
}
Two rules:
- Your fields go inside
data.record.data.email, neverrecord.email. - For new records, leave
pkandskempty and setisNew: true— the server fills them in.
Endpoints at a glance
/repository/get/{datatype}/{id}JWT+APIKEY/repository/find-any-id/{datatype}/{id}?en=trueJWT+APIKEY/repository/find-page-data/{datatype}JWT+APIKEY/repository/find/{datatype}JWT+APIKEY/repository/search/{datatype}JWT+APIKEY/repository/createJWT+APIKEY/repository/createJWT+APIKEY/repository/update/{id}JWT+APIKEY/repository/update-partial/{datatype}/{id}JWT+APIKEY/repository/delete/{datatype}/{id}JWT+APIKEY/repository/delete/{datatype}JWT+APIKEY/repository/aggregate/{datatype}JWT+APIKEYPUT /create and POST /create are equivalent — the controller registers both verbs. POST /delete/{datatype} accepts a JSON array of ids for bulk delete.
Read
Get one by id
// from base-app/src/lib/repository-api.ts
const path = `repository/get/${datatype}/${id}`;
const response = await client.processRequest('get', path);
const record = response?.data; // BaseModel\<T\>
find-any-id is a tolerant variant: it tries sk, name, slug, and a few other keys before giving up. Pass ?en=true to inflate referenced records (e.g. category, owner).
List with filters
find-page-data is GET, takes attribute filters in query params, and returns a paged envelope:
// from base-app/src/lib/repository-api.ts
const params = new URLSearchParams();
params.append('p', '1');
params.append('ps', '20');
params.append('s', 'modifydate');
params.append('st', 'desc');
params.append('keyword', 'mug');
const res = await client.processRequest('get',
`repository/find-page-data/product?${params.toString()}`);
// res = { data: BaseModel\<T\>[], total, hasNext }
Query with a body (Mongo-style)
find is POST and takes a { query, options } body. Use this when filters won't fit in a URL or when you want operator syntax.
const res = await client.processRequest('post', `repository/find/product`, {
query: { 'data.price': { $lte: 50 }, 'data.stock': { $gt: 0 } },
options: { page: 1, pageSize: 20, sort: 'data.price', sortType: 'asc' },
});
See MongoDB operators for the full operator list.
Create
POST /repository/create accepts a BaseModel\<T\> shaped body. Set isNew: true and leave pk/sk empty.
const newRecord = {
pk: '',
sk: '',
name: '',
datatype: 'product',
data: { title: 'Wireless Mouse', price: 29.99, stock: 150 },
isNew: true,
version: 0,
};
const created = await client.processRequest('post', 'repository/create', newRecord);
There is also PUT /repository/create-extended for richer creation flows that need to seed comments, schedules, or activities in one round-trip. Most integrators don't need it.
Update
There are two flavors:
POST /repository/update/{id}— full-record update. Send back the wholeBaseModel\<T\>you fetched, withdatamodified. The server bumpsversion.POST /repository/update-partial/{datatype}/{id}— patch a subset ofdatafields. Only the keys you send are merged.
| Field | Type | Description |
|---|---|---|
| data* | object | The full |
| version | number | Optimistic concurrency. If supplied and stale, the server returns 409. |
// partial update (recommended)
await client.processRequest(
'post',
`repository/update-partial/product/${id}`,
{ data: { price: 24.99 } }
);
Delete
// soft delete — moves the record to trash and bumps state to "deleted"
await client.processRequest('delete', `repository/delete/product/${id}`);
// bulk
await client.processRequest('post', `repository/delete/product`, [id1, id2, id3]);
Records are soft-deleted by default — they go to trash and can be restored via POST /repository/trash-restore. To hard-delete a whole collection, ConfigAdmin can call DELETE /repository/truncate/{datatype}.
Workflow verbs
The controller also exposes lifecycle actions on top of CRUD:
/repository/publish/{datatype}/{id}JWT/repository/unpublish/{datatype}/{id}JWT/repository/request-approval/{datatype}/{id}JWT/repository/approve/{datatype}/{id}JWT/repository/reject/{id}JWT/repository/trash-restoreJWTThese move the state field on the record and emit corresponding events. They're used by the CMS approval workflow but available on any datatype.
Domain controllers vs the generic surface
When a domain has its own controller (storefront, CRM, banking, etc.), prefer it. Domain controllers run business logic — pricing, inventory holds, KYC checks — that the generic CRUD bypasses. Use /repository/* for collections that don't have a domain, and for admin tooling that needs raw access.
Aliases under /data/*
Some clients call the same surface under /repository/get/{collection}/{id} shorthand. Internally these route to the same RepositoryController handlers — pick whichever your codebase uses and stay consistent.