User Data Management
Store arbitrary JSON records against a user, grouped into categories. Nine methods cover create, read, search, update, delete, bulk delete and atomic batches. Every read is cursor-paginated, and every write can be guarded against a competing writer.
1// Each record belongs to one user and one category.
2await authix.addUserData(
3 AddUserDataParams(
4 userId: userId,
5 dataCategory: 'notes',
6 data: {'title': 'Groceries', 'body': 'Milk, eggs'},
7 ),
8);Ownership is enforced by the server
With a publishable key the target is pinned to the signed-in session — the userId in the URL is ignored, so an app cannot be repointed at somebody else's records by editing an id. Call loginUser() first; without a session these routes return NoUserSessionError.
With a secret key any user may be targeted directly — which is why a secret key belongs on a server.
The data model
| Concept | Type | Meaning |
|---|---|---|
| userId | String | Owner of the record. Ignored in favour of the session when the caller holds a publishable key. |
| dataCategory | String | A namespace such as 'notes', 'orders' or 'settings'. Lower-cased by the server; set on create, filtered on read. |
| data | Map<String, dynamic> | Your JSON payload. Any shape, as long as it serialises. Indexed for search. |
| dataId | String | Server-assigned record ID, returned on create and used to address a single record. |
| version | int | Bumped on every write. Pass it back on update to reject a competing change instead of overwriting it. |
| parentId | String? | Optional parent record, for order → line-item shapes. Deleting a parent deletes its children. |
Methods
addUserData
POST /users/:userId/data
Future<DataItemResponse> addUserData(AddUserDataParams params)
| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Owner of the new record. |
| dataCategory | String | Yes | Category namespace. |
| data | Map<String, dynamic> | Yes | The record body. |
| parentId | String? | No | Parent record, for nested shapes. |
Returns: DataItemResponse
1final created = await authix.addUserData(
2 AddUserDataParams(
3 userId: userId,
4 dataCategory: 'notes',
5 data: {
6 'title': 'Groceries',
7 'body': 'Milk, eggs, bread',
8 'pinned': true,
9 },
10 ),
11);
12
13created.id; // 'ck…' — server-assigned
14created.version; // 0 — keep this to update the record safelyThrows: ConfigurationError when userId or dataCategory is empty. PermissionDeniedError when the account is not verified, or the plan's storage limit is reached.
Your data map is flattened into the request body alongside dataCategory, so avoid 'dataCategory', 'parentId' or 'version' as field names inside data — they would collide.
getUserData
GET /users/:userId/data
Future<Page<T>> getUserData<T>(GetUserDataParams params, {ItemParser<T>? itemParser})| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Whose records to list. |
| category | String? | No | Restrict to one category. |
| parentId | String? | No | Restrict to the children of one record. |
| limit | int? | No | Page size. Defaults to 20, capped at 100 — a larger value is clamped, not rejected. |
| cursor | String? | No | nextCursor from the previous page. |
| itemParser | ItemParser<T>? | No | Maps each record into your own model. |
Returns: Page<T>
1final page = await authix.getUserData(
2 GetUserDataParams(userId: userId, category: 'notes', limit: 20),
3);
4
5page.length; // records in this page
6page.hasMore; // true when the server holds more
7page.nextCursor; // pass back as cursor for the next page
8
9for (final record in page) {
10 print(record['title']);
11}Newest first. Without itemParser the records come back as raw Map<String, dynamic>.
iterateUserData
GET /users/:userId/data (repeated)
Stream<T> iterateUserData<T>(GetUserDataParams params, {ItemParser<T>? itemParser, int? maxPages})| Param | Type | Required | Description |
|---|---|---|---|
| params | GetUserDataParams | Yes | Same filters as getUserData. Any cursor you set is ignored — the traversal manages its own. |
| itemParser | ItemParser<T>? | No | Maps each record into your own model. |
| maxPages | int? | No | Stop after this many pages. A safety valve when the dataset size is unknown. |
Returns: Stream<T>
1await for (final record in authix.iterateUserData(
2 GetUserDataParams(userId: userId, category: 'notes'),
3)) {
4 print(record['title']);
5}Follows the cursor for you and yields records as they arrive, so a ListView can render the first page while the rest loads.
getSingleUserData
GET /users/:userId/data/:dataId
Future<DataItemResponse> getSingleUserData(GetSingleUserDataParams params)
| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Owner of the record. |
| dataId | String | Yes | Record ID. |
Returns: DataItemResponse
1final record = await authix.getSingleUserData(
2 GetSingleUserDataParams(userId: userId, dataId: 'data_123'),
3);
4
5record.data!['title'];
6record.version; // needed for a safe updateThrows: ConfigurationError when either field is empty. NotFoundError when the record does not exist.
searchInUserData
POST /users/:userId/data/search
Future<Page<T>> searchInUserData<T>(SearchUserDataParams params, {ItemParser<T>? itemParser})| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Whose records to search. |
| q | String? | No | Substring match across the whole record. |
| category | String? | No | Restrict to one category. |
| keys | Map<String, dynamic>? | No | Exact field match, e.g. {'status': 'paid'}. |
| limit | int? | No | Page size. Defaults to 20, capped at 100. |
| cursor | String? | No | nextCursor from the previous page. |
Returns: Page<T>
1// Substring match
2final byText = await authix.searchInUserData(
3 const SearchUserDataParams(
4 userId: userId,
5 category: 'notes',
6 q: 'groceries',
7 ),
8);
9
10// Exact field match
11final pinned = await authix.searchInUserData(
12 const SearchUserDataParams(
13 userId: userId,
14 category: 'notes',
15 keys: {'pinned': true},
16 ),
17);Both run in Postgres against an index — keys as a jsonb containment query, q as a trigram substring match. Nothing is filtered on the device.
updateUserData
PUT /users/:userId/data/:dataId
Future<DataItemResponse> updateUserData(UpdateUserDataParams params)
| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Owner of the record. |
| dataId | String | Yes | Record to update. |
| data | Map<String, dynamic> | Yes | Fields to write. Fields you omit are left alone. |
| version | int? | No | The version you last read. Omit it and the write always wins. |
Returns: DataItemResponse
1await authix.updateUserData(
2 UpdateUserDataParams(
3 userId: userId,
4 dataId: 'data_123',
5 data: {'title': 'Groceries (updated)', 'pinned': false},
6 version: 2,
7 ),
8);Throws: ConfigurationError when userId or dataId is empty. VersionConflictError when version no longer matches.
deleteUserData
DELETE /users/:userId/data/:dataId
Future<Map<String, dynamic>> deleteUserData(DeleteUserDataParams params)
| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Owner of the record. |
| dataId | String | Yes | Record to delete. |
Returns: Map<String, dynamic>
1await authix.deleteUserData(
2 DeleteUserDataParams(userId: userId, dataId: 'data_123'),
3);Permanent. Records parented to this one go with it.
deleteManyUserData
POST /users/:userId/data-bulk-delete
Future<Map<String, dynamic>> deleteManyUserData(DeleteManyUserDataParams params)
| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Owner of the records. |
| dataIds | List<String> | Yes | Up to 100 record IDs. |
Returns: Map<String, dynamic>
1await authix.deleteManyUserData(
2 DeleteManyUserDataParams(
3 userId: userId,
4 dataIds: selectedIds,
5 ),
6);Throws: ConfigurationError when the list is empty.
One transaction: either every id is removed or none are. Far cheaper than a loop of deleteUserData calls.
batch
POST /users/:userId/data-batch
Future<Map<String, dynamic>> batch(BatchParams params)
| Param | Type | Required | Description |
|---|---|---|---|
| userId | String | Yes | Owner of the records. |
| operations | List<BatchOperation> | Yes | Up to 50 create / update / delete operations. |
Returns: Map<String, dynamic>
1await authix.batch(
2 BatchParams(
3 userId: userId,
4 operations: [
5 const BatchOperation.create(
6 dataCategory: 'orders',
7 data: {'total': 40},
8 ),
9 BatchOperation.update(id: stockId, version: 3, data: {'remaining': 9}),
10 BatchOperation.delete(id: draftId),
11 ],
12 ),
13);Throws: ConfigurationError when the list is empty. NotFoundError or VersionConflictError aborts the whole batch — nothing is written.
Build operations with BatchOperation.create / .update / .delete so a malformed one is a compile error rather than a 400.
Reading more than one page
Reads are bounded — 20 records by default, 100 maximum. There is no “fetch everything” mode, because one such request against a large app would have to hold the whole dataset in memory, which on a phone is fatal. Follow nextCursor yourself for a “Load more” button, or let iterateUserData do it.
1// One page at a time — for a "Load more" button.
2var page = await authix.getUserData(
3 GetUserDataParams(userId: userId, category: 'notes', limit: 50),
4);
5
6if (page.hasMore) {
7 page = await authix.getUserData(
8 GetUserDataParams(
9 userId: userId,
10 category: 'notes',
11 limit: 50,
12 cursor: page.nextCursor,
13 ),
14 );
15}
16
17// Or let the SDK follow the cursor and stream every record as it arrives.
18await for (final record in authix.iterateUserData(
19 GetUserDataParams(userId: userId, category: 'notes'),
20)) {
21 print(record['title']);
22}
23
24// Bound the traversal when you do not know how much there is.
25final firstFewHundred = await authix
26 .iterateUserData(
27 GetUserDataParams(userId: userId),
28 maxPages: 10,
29 )
30 .toList();Typed records with itemParser
Rather than reaching into maps everywhere, hand the SDK a factory and get your own model back.
1class Note {
2 final String id;
3 final String title;
4 final int version;
5
6 const Note({required this.id, required this.title, required this.version});
7
8 factory Note.fromJson(Map<String, dynamic> json) => Note(
9 id: json['id']?.toString() ?? '',
10 title: json['title']?.toString() ?? '',
11 version: json['version'] as int? ?? 0,
12 );
13}
14
15// Pass the factory as itemParser to get Note objects instead of raw maps.
16final page = await authix.getUserData<Note>(
17 GetUserDataParams(userId: userId, category: 'notes'),
18 itemParser: Note.fromJson,
19);
20
21for (final note in page) { // a Page is an Iterable of its records
22 print(note.title);
23}
24
25// Or use the built-in record shape, which types version for you.
26final typed = await authix.getUserData<DataItem>(
27 GetUserDataParams(userId: userId),
28 itemParser: DataItem.fromJson,
29);
30
31typed.first.version; // int
32typed.first['title']; // payload field
33typed.first.payload; // payload without the identity fieldsTwo devices, one record
Without a version, the last write wins and the other change is gone with no error. That is fine for a record only one device touches, and wrong for anything else.
1// Read, then write back with the version you read.
2final current = await authix.getSingleUserData(
3 GetSingleUserDataParams(userId: userId, dataId: dataId),
4);
5
6try {
7 await authix.updateUserData(
8 UpdateUserDataParams(
9 userId: userId,
10 dataId: dataId,
11 data: {'status': 'shipped'},
12 version: current.version,
13 ),
14 );
15} on VersionConflictError catch (e) {
16 // Another device wrote first. Nothing was overwritten — re-read and retry.
17 print('now at version ${e.currentVersion}');
18}When records must change together
A sequence of separate calls can fail halfway and leave your data inconsistent — an order recorded but the stock never deducted. batch() commits every operation or none of them.
1// Deduct stock and record the order together, or do neither.
2await authix.batch(
3 BatchParams(
4 userId: userId,
5 operations: [
6 const BatchOperation.create(
7 dataCategory: 'orders',
8 data: {'total': 40, 'status': 'placed'},
9 ),
10 BatchOperation.update(
11 id: stockId,
12 version: stock.version,
13 data: {'remaining': 9},
14 ),
15 BatchOperation.delete(id: draftId),
16 ],
17 ),
18);Verify the account first
An unverified account cannot store records — both addUserData and batch reject the write with PermissionDeniedError. Run the email OTP flow before your first write.
Related