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.

Dart
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

ConceptTypeMeaning
userIdStringOwner of the record. Ignored in favour of the session when the caller holds a publishable key.
dataCategoryStringA namespace such as 'notes', 'orders' or 'settings'. Lower-cased by the server; set on create, filtered on read.
dataMap<String, dynamic>Your JSON payload. Any shape, as long as it serialises. Indexed for search.
dataIdStringServer-assigned record ID, returned on create and used to address a single record.
versionintBumped on every write. Pass it back on update to reject a competing change instead of overwriting it.
parentIdString?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)
ParamTypeRequiredDescription
userIdStringYesOwner of the new record.
dataCategoryStringYesCategory namespace.
dataMap<String, dynamic>YesThe record body.
parentIdString?NoParent record, for nested shapes.

Returns: DataItemResponse

Dart
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 safely

Throws: 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})
ParamTypeRequiredDescription
userIdStringYesWhose records to list.
categoryString?NoRestrict to one category.
parentIdString?NoRestrict to the children of one record.
limitint?NoPage size. Defaults to 20, capped at 100 — a larger value is clamped, not rejected.
cursorString?NonextCursor from the previous page.
itemParserItemParser<T>?NoMaps each record into your own model.

Returns: Page<T>

Dart
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})
ParamTypeRequiredDescription
paramsGetUserDataParamsYesSame filters as getUserData. Any cursor you set is ignored — the traversal manages its own.
itemParserItemParser<T>?NoMaps each record into your own model.
maxPagesint?NoStop after this many pages. A safety valve when the dataset size is unknown.

Returns: Stream<T>

Dart
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)
ParamTypeRequiredDescription
userIdStringYesOwner of the record.
dataIdStringYesRecord ID.

Returns: DataItemResponse

Dart
1final record = await authix.getSingleUserData(
2  GetSingleUserDataParams(userId: userId, dataId: 'data_123'),
3);
4
5record.data!['title'];
6record.version; // needed for a safe update

Throws: 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})
ParamTypeRequiredDescription
userIdStringYesWhose records to search.
qString?NoSubstring match across the whole record.
categoryString?NoRestrict to one category.
keysMap<String, dynamic>?NoExact field match, e.g. {'status': 'paid'}.
limitint?NoPage size. Defaults to 20, capped at 100.
cursorString?NonextCursor from the previous page.

Returns: Page<T>

Dart
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)
ParamTypeRequiredDescription
userIdStringYesOwner of the record.
dataIdStringYesRecord to update.
dataMap<String, dynamic>YesFields to write. Fields you omit are left alone.
versionint?NoThe version you last read. Omit it and the write always wins.

Returns: DataItemResponse

Dart
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)
ParamTypeRequiredDescription
userIdStringYesOwner of the record.
dataIdStringYesRecord to delete.

Returns: Map<String, dynamic>

Dart
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)
ParamTypeRequiredDescription
userIdStringYesOwner of the records.
dataIdsList<String>YesUp to 100 record IDs.

Returns: Map<String, dynamic>

Dart
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)
ParamTypeRequiredDescription
userIdStringYesOwner of the records.
operationsList<BatchOperation>YesUp to 50 create / update / delete operations.

Returns: Map<String, dynamic>

Dart
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.

Dart
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.

Dart
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 fields

Two 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.

Dart
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.

Dart
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