GraphQL API
Atomo services expose a generated GraphQL API. When running atomo dev, visit /graphql for the IDE. The server merges service queries with platform queries (users, sessions, audit).
The service API is model-generic: operations take a model argument and JSON where/orderBy/data payloads.
Where operators
Mutation data distinguishes omitted fields from explicit null: omission preserves the database default on create or leaves a field unchanged on update; explicit null writes SQL NULL when the column permits it. This applies to single and bulk writes, including numeric, boolean, timestamp and JSON fields. Non-null constraints remain enforced, and a failing bulk insert rolls back the batch. A JSON object or array containing nested nulls remains a JSON value.
in and notIn require arrays. Set predicates are parameterized within one SQL statement and grouped with surrounding scope conditions, including for bulk soft deletion, restoration and permanent deletion. An empty in matches nothing; an empty notIn excludes nothing. Non-array inputs are rejected. Arrays written to JSON fields remain JSON values and are independent of set-filter binding.
Per field, the where JSON accepts: equals/eq, not/neq, contains/like, startsWith, endsWith, gt, gte, lt, lte, in, notIn, and isNull. isNull: true matches IS NULL; isNull: false matches IS NOT NULL. Multiple fields — and multiple operators on one field ({ value: { gte: 1, lte: 9 } }) — combine with AND; there is no OR combinator.
Example Operations
# List records with Hasura-style filtering and ordering
query {
records(
model: "Contact"
where: { email: { contains: "@example.com" } }
orderBy: { createdAt: "DESC" }
limit: 20
offset: 0
)
}# Paginated list with page metadata
query {
paginatedRecords(model: "Contact", limit: 20, offset: 0) {
data
pageInfo { totalCount hasNextPage hasPreviousPage }
}
}# Fetch one by id
query { record(model: "Contact", id: "<id>") }# Create / update / delete (update and delete honor the where filter)
mutation { create(model: "Contact", data: { firstName: "John", email: "john@example.com" }) }
mutation { update(model: "Contact", where: { id: { equals: "<id>" } }, data: { phone: "555" }) }
mutation { delete(model: "Contact", where: { id: { equals: "<id>" } }) }
# id shorthand — equivalent to where: { id: { equals: "<id>" } }
mutation { update(model: "Contact", id: "<id>", data: { phone: "555" }) }
mutation { delete(model: "Contact", id: "<id>") }
mutation { restore(model: "Contact", id: "<id>") }
mutation { hardDelete(model: "Contact", id: "<id>") }# Bulk update — update many records in one call; returns all updated records.
mutation {
updateMany(model: "Contact", items: [
{ id: "<id1>", data: { phone: "555" } },
{ id: "<id2>", data: { phone: "666" } }
])
}# Soft-delete lifecycle: delete soft-deletes; restore brings back; hardDelete purges.
mutation { restore(model: "Contact", where: { id: { equals: "<id>" } }) }
mutation { hardDelete(model: "Contact", where: { id: { equals: "<id>" } }) }
# List soft-deleted records (the trash view), with pagination metadata.
query {
deletedRecords(model: "Contact", limit: 20, offset: 0) {
data
pageInfo { totalCount hasNextPage hasPreviousPage }
}
}# Paginated list with filtering, sorting, and total count.
query {
paginatedRecords(
model: "Contact"
where: { email: { contains: "@example.com" } }
orderBy: { createdAt: "DESC" }
limit: 20
offset: 0
) {
data
pageInfo { totalCount hasNextPage hasPreviousPage }
}
}# Subscribe to model changes (over WebSocket at /graphql/ws)
subscription { modelChanges(model: "Contact") { eventType modelName eventId } }Notes
- Casing: query results return camelCase keys (
firstName,createdAt). Mutation inputs accept both camelCase and snake_case — the server normalizes to the schema's field names. whereoperators:equals,not,contains,startsWith,endsWith,gt,gte,lt,lte,in,notIn,isNull.idshorthand:update,delete,restore, andhardDeleteaccept anidargument as sugar forwhere: { id: { equals: "..." } }. An error is returned if bothidandwhereare provided, or if neither is.deleteis a soft delete (setsdeleted_at); userestoreto undo orhardDeleteto purge.records/paginatedRecordsexclude soft-deleted rows;deletedRecordsshows only them.updateMany: accepts anitemsarray of{ id, data }pairs and returns all updated records. Each item updates one record by id.- Access is enforced per model from the schema
accessrules (RBAC). SendAuthorization: Bearer <jwt>. - Multi-tenant scoping: send
X-Tenant-ID: <id>to scope all operations to a tenant. - Mutations are audit-logged with the acting user (from the JWT) as
user_id. - Errors carry codes in extensions:
NOT_FOUND,UNAUTHORIZED,FORBIDDEN,VALIDATION_ERROR,INTERNAL_ERROR.
Local Endpoints
- Dev server default:
http://localhost:3000/graphql - Subscriptions (WebSocket):
ws://localhost:3000/graphql/ws - Override the port with
atomo dev --port <n>oratomo-server --port <n>
See also: the schema.ts in each service (e.g., services/crm-service/schema.ts).