roble

Cliente de Flutter para Roble, la plataforma de Uninorte OpenLab.

Con este paquete tu app puede tener cuentas de usuario, guardar datos y enterarse de los cambios al momento, sin que escribas backend.


Instalación

flutter pub add roble

En Android, abre android/app/src/main/AndroidManifest.xml y añade permiso de internet dentro de <manifest>:

<uses-permission android:name="android.permission.INTERNET" />

Tu primer minuto

Necesitas dos datos de la consola de Roble: la URL y el id de tu proyecto (el «contrato»).

import 'package:roble/roble.dart';

final db = RobleApiDataBase(
  config: RobleApiConfig.fromContract(
    baseUrl: 'https://roble-api.test-openlab.uninorte.edu.co',
    contractId: 'miproyecto_ab12cd34',
  ),
);

// Crear una cuenta
await db.register(
  email: 'ana@correo.com',
  password: 'MiClave!1',
  name: 'Ana García',
);

// Entrar. Devuelve el perfil.
final usuario = await db.login(
  email: 'ana@correo.com',
  password: 'MiClave!1',
);
print('Hola ${usuario['name']}');

Crea db una sola vez en tu app y reutilízalo. Si lo creas de nuevo en cada pantalla, cada copia tendrá su propia sesión.


Cuentas de usuario

Entrar y salir

await db.login(email: correo, password: clave);
await db.logout();

if (db.isLoggedIn) print('Hay alguien dentro');

final perfil = await db.currentUser();

Qué devuelve el login

login(), signInWithGoogle() y cualquier otra forma de entrar devuelven el mismo mapa: el perfil de la persona.

{
  'id': 'us-3f2a…',            // fila del perfil
  'userId': '9c1e…',           // el usuario. Este es el que referencian tus tablas
  'email': 'ana@correo.com',
  'name': 'Ana García',
  'role': 'admin',             // null si no tiene rol asignado
  'extra': {'programa': 'Sistemas'},
  'createdAt': '2026-08-27T12:00:00.000Z',
  'updatedAt': null,
}

Dos avisos:

  • id y userId no son lo mismo. userId es el del usuario, el que guardas en tus tablas para saber de quién es cada fila. id es el de la fila del perfil.
  • role puede ser null, si nadie le asignó rol. No es un error.

currentUser() devuelve exactamente esto mismo, y es lo que usas después de restoreSession() para saber quién entró.

Que la sesión sobreviva a cerrar la app

Por omisión la sesión se guarda en el almacenamiento seguro del teléfono. Al arrancar, pregúntale a Roble si sigue siendo válida:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (await db.restoreSession()) {
    // Entra directo a la pantalla principal
  } else {
    // Muestra el login
  }
  runApp(MiApp());
}

Registro con código por correo

Si prefieres confirmar que el correo existe antes de crear la cuenta:

await db.registerWithVerification(
  email: 'ana@correo.com',
  password: 'MiClave!1',
  name: 'Ana García',
);

// Ana recibe un código y lo escribe en tu pantalla
await db.verifyEmail(email: 'ana@correo.com', code: '123456');

resendCode(email: ...) lo manda otra vez si no llegó.

Contraseña olvidada

await db.forgotPassword(email: 'ana@correo.com');
// Le llega un código por correo
await db.resetPassword(token: '123456', newPassword: 'OtraClave!2');

Entrar sin cuenta

Dos formas, y la diferencia importa: una deja dueño, la otra no.

Invitado: escribe ahora, se registra después

if (!db.isLoggedIn) await db.signInAnonymously();
await db.create('carrito', {'producto': id});   // suyo, y de nadie más

Un invitado es un usuario de verdad. Tiene userId, cada fila que inserta queda a su nombre, y isMine() y el alcance own funcionan igual que con una cuenta normal. Lo único que no tiene es correo y contraseña.

db.isAnonymous responde desde el token, sin ir al servidor, que es lo que necesitas para decidir si la pantalla enseña «guarda tu cuenta».

Su email es una dirección inventada anon_…@anonymous.invalid. No existe y no puede recibir correo: no la muestres en pantalla.

Cuando quiera conservarlo:

try {
  await db.upgradeAccount(email: email, password: password);
} on RobleAnonUpgradeEmailTakenException {
  // ya hay cuenta con ese correo: ofrécele iniciar sesión, avisando
  // de que lo escrito como invitado se queda en la sesión de invitado
}

Esto no crea un usuario nuevo: muta el que ya hay. El userId no cambia, así que todo lo que escribió sigue siendo suyo sin mover un solo dato. Ese es el punto entero.

Con Google o Microsoft es lo mismo, con linkIdentity(provider: 'google'): devuelve la url a la que mandar a la persona, y al volver la identidad queda unida a esta cuenta en vez de crear otra.

El proyecto tiene que tener habilitado el acceso anónimo y aplicar propiedad por fila en alguna tabla. Si falta algo sale RobleAnonymousAuthException y su code dice cuál de las dos cosas. La segunda condición no es un capricho: un invitado sin propiedad por fila escribe filas que puede borrar cualquier otro invitado.

Clave publicable: un buzón, sin sesión ninguna

Para un formulario de contacto, una encuesta, un buzón de sugerencias: gente que no se va a registrar y que no necesita volver a ver lo que mandó.

final buzon = RobleApiDataBase(
  config: RobleApiConfig.fromContract(
    baseUrl: baseUrl,
    contractId: contractId,
    anonKey: 'roble_anon_…',        // se emite en la consola del proyecto
  ),
);

await buzon.create('sugerencias', {'texto': texto});

La clave va dentro de la app y es pública por diseño: quien desensamble el binario la va a ver, y eso no es una filtración. Lo que la hace segura es que no puede leer nada.

Un cliente así sólo inserta, y sólo en las tablas que el proyecto haya marcado. Cualquier otra cosa falla en tu código, sin salir a la red:

await buzon.read('sugerencias');
// RobleAnonKeyScopeException: una clave publicable sólo puede insertar

buzon.isAnonKeyMode es true, para no ofrecer en pantalla un botón que siempre va a fallar.

Las filas que escribe no tienen dueño. Nadie con alcance own va a poder editarlas ni borrarlas después. Si la persona tiene que poder volver a tocar lo suyo, lo que quieres es la sesión de invitado de arriba, no esto.


Entrar con Google

Una línea:

final usuario = await db.signInWithGoogle();

En móvil sale el selector de cuentas del teléfono. En web se abre una ventana. El paquete elige solo.

Antes tienes que configurar Google en la consola de Roble y registrar allí el «destino de retorno» de tu app. En iOS, además, pasa el Client ID de iOS al crear el cliente:

RobleApiDataBase(
  config: config,
  googleIosClientId: 'xxxx.apps.googleusercontent.com',
  ssoRedirect: 'mi-app-web', // el nombre que registraste en la consola
);

Un destino de retorno por entorno

ssoRedirect no es una URL: es el nombre de un destino registrado en la consola. La URL vive allí, así que puedes tener varios y elegir cuál usa cada build.

Registra uno por entorno en vez de irle cambiando la URL al mismo:

Nombre en la consola URL
mi-app-web-dev http://localhost:5001
mi-app-web https://mi-dominio
mi-app-movil com.miempresa.miapp://sso-done

Así desarrollo y producción no se pelean por el mismo sitio, y puedes probar el flujo real en local sin tocar lo que usan los demás. Publicar deja de ser un cambio en la consola: es otro valor en la configuración de ese build.

En local, el puerto forma parte del destino. flutter run -d chrome elige un puerto distinto en cada arranque si no se lo fijas, así que Google autentica bien y te devuelve a un puerto donde ya no hay nadie: ERR_CONNECTION_REFUSED, que parece un fallo del login y no lo es.

flutter run -d chrome --web-port=5001

Y localhost no es 127.0.0.1 para el proveedor, aunque sean la misma máquina: abre la app por el mismo origen que registraste.

Fuera de web el destino es el esquema propio de la app, declarado en AndroidManifest.xml y en Info.plist. No lleva dominio, así que no cambia al publicar.

¿Qué proveedores tienes activos? Para pintar solo los botones que funcionan:

for (final p in await db.listProviders()) {
  print(p.displayName); // "Google", "Microsoft"…
}

Guardar datos: tablas

Una tabla es como una hoja de cálculo: la creas en la consola con sus columnas, y desde la app la llenas.

// Crear
final producto = await db.create('Product', {
  'name': 'Café',
  'quantity': 12,
});

// Leer todo
final todos = await db.read('Product');

// Leer con filtro (igualdad)
final agotados = await db.read('Product', filters: {'quantity': 0});

// Uno solo, por su id
final uno = await db.getById('Product', producto['_id']);

// Cambiar
await db.update('Product', producto['_id'], {'quantity': 11});

// Borrar
await db.delete('Product', producto['_id']);

Cada registro trae un _id que pone Roble. Es lo que usas para cambiarlo o borrarlo.

Varios de golpe

final res = await db.createMany('Product', [
  {'name': 'Té', 'quantity': 5},
  {'name': 'Pan', 'quantity': 0},
]);
print('Guardados: ${res.inserted.length}, rechazados: ${res.skipped.length}');

Consultas más complicadas

read solo filtra por igualdad. Para juntar tablas, sumar o paginar, guarda la consulta SQL en la consola y llámala por su nombre:

final res = await db.executeQueryByName('productosSinInventario');
for (final fila in res.rows) print(fila);

Usa el nombre, no el UUID: el nombre sobrevive si recreas la consulta.

Una tabla que todos pueden leer

Si marcas una tabla como pública en la consola, se puede leer sin haber iniciado sesión:

final catalogo = await db.publicRead('Product');

Ojo: público es público. Cualquiera con el id del proyecto puede leerla.


Quién puede tocar qué

Los permisos de Roble son por tabla y por acción. Un rol puede leer Product y no poder borrarla; darle permiso de borrado en una tabla no se lo da en las demás. Cuando no lo tiene, la llamada responde 403:

try {
  await db.delete('Product', id);
} on RobleApiForbiddenException {
  mostrar('Tu cuenta no puede borrar productos');
}

Los roles que trae un proyecto nuevo:

Rol Qué puede
admin Todo, sobre cualquier fila
user Crear y leer (es el rol de quien se registra)
editor Crear, leer, cambiar y borrar cualquier fila
editor_own Lo mismo, pero sólo sobre sus propias filas

editor_own es el que suele hacer falta: «que cada quien edite lo suyo» sin que promover a alguien le deje vaciar la tabla. El rol de la sesión viene en RobleUser.role, y están escritos en RobleRole para no teclearlos:

final user = await db.currentUser();
if (user.role == RobleRole.editorOwn) mostrarBotonEditar();

De quién es cada fila

Las tablas creadas desde que existe la propiedad por fila traen una columna _owner con el usuario que insertó el registro. La pone el servidor: lo que mandes tú en ella no se respeta ni al crear ni al actualizar, y este paquete la quita de los datos que envías, así que leer una fila, cambiarle algo y volver a escribirla sigue funcionando.

Para pintar «esto es mío» no hace falta ir al servidor:

final filas = await db.read('Product');
final mias = filas.where(db.isMine).toList();

// El id de la sesión, si lo prefieres a mano
db.currentUserId;

isMine mira _owner contra la sesión: es para la pantalla, no una comprobación de seguridad — quien decide qué puedes tocar es el servidor.

Una tabla puede ocultar _owner desde la consola (expose_owner), que es lo que quieres en una tabla anónima: ahí no viene en las lecturas, filtrar por ella da error, y isMine responde false porque no hay forma de saberlo.

Cuando la tabla sólo te deja lo tuyo

Con la propiedad activada en una tabla, tocar la fila de otra persona responde 404, el mismo que si no existiera. Es a propósito: si respondiera distinto, probar identificadores diría cuáles existen y de quién son.

try {
  await db.delete('Product', id);
} on RobleApiNotFoundException {
  mostrar('Ese producto ya no está, o no es tuyo');
}

Un cambio a tener en cuenta: borrar dos veces el mismo _id ya no responde 200, responde 404. Si tu app reintenta borrados, trata ese 404 como éxito.


Guardar datos: árbol JSON

A veces no vale la pena declarar una tabla: un chat, un tablero, una partida. Para eso está el árbol JSON. No declaras nada: la estructura nace cuando escribes el primer dato.

// Añadir, con clave que genera el servidor
final id = await db.json.push('mensajes', {
  'texto': 'hola',
  'de': 'ana@correo.com',
});

// Leer la colección entera
final todos = await db.json.read('mensajes');

// Cambiar solo una clave
await db.json.update('mensajes/$id', {'leido': true});

// Borrar
await db.json.remove('mensajes/$id');

Borra ramas, no colecciones: json.remove('mensajes') —un solo trozo— vacía la colección entera y es cosa de administradores, así que a un usuario normal le responde RobleApiForbiddenException. Con json.remove('mensajes/$id') no hace falta ningún rol.

Una colección puede además exigir que la ruta sea tuya: si la consola marca uno de sus segmentos como el del dueño (por ejemplo mensajes/<tuId>/...), escribir o borrar por debajo del segmento de otra persona es un 403, y suscribirse a lo que no puedes leer lanza un error de permisos en el stream en vez de emitir.

Una ruta es coleccion/hijo/nieto. El primer trozo es la colección.

Las claves de push salen ordenadas por tiempo, así que ordenarlas ordena los mensajes — sin depender del reloj de cada teléfono.

¿Tabla o árbol JSON?

Usa una tabla cuando Usa el árbol JSON cuando
Los datos tienen forma fija La forma cambia o no importa
Quieres consultas SQL Solo lees y escribes por ruta
Son datos del negocio Son datos que van y vienen

Enterarse de los cambios al momento

Escuchar te avisa cuando otro usuario cambia algo, sin que tengas que recargar.

// Una tabla
final sub = db.watchTable('Product').listen((cambio) {
  print('${cambio.type}: ${cambio.record}');
});

// Un solo registro
db.watchRecord('Product', id).listen((cambio) { ... });

// El árbol JSON
db.json.watch('mensajes').listen((cambio) {
  // En un push, `record` trae {claveNueva: dato}
  cambio.record?.forEach((id, dato) => print(dato));
});

// Al salir de la pantalla
await sub.cancel();

Tres cosas que conviene saber:

  • No trae lo que ya existe, solo lo que cambie de ahora en adelante. Para pintar la lista, léela primero y aplica encima lo que llegue.
  • Cancela al salir de la pantalla. Si no, el socket sigue abierto.
  • Hace falta sesión iniciada.

Puedes pedir solo algunos cambios, o filtrar en el servidor:

db.watchTable(
  'Product',
  events: [RobleChangeType.insert],
  filters: [RobleFilter('quantity', 'eq', 0)],
).listen(...);

Filtrar aquí ahorra el viaje de todo lo que no te interesa.


Notificaciones

Avisos que se guardan y llegan al momento a quien tenga la app abierta. Es otra cosa que el árbol JSON: no hay colección que crear, no hay ruta que elegir, y el destinatario es un usuario del proyecto.

// Escuchar lo que vaya llegando.
db.notifications.watch().listen((evento) {
  if (evento.type == RobleNotificationEventType.created) {
    mostrarAviso(evento.notification.title);
  }
});

// Enviar a alguien.
await db.notifications.send(
  to: otroUsuarioId,
  title: 'Te toca',
  body: 'Ana movió ficha',
  topic: 'partida',
  data: {'partidaId': '42'},
);

El data es tuyo: lo que la pantalla necesite para abrir lo correcto cuando alguien toque el aviso.

A todo el proyecto

await db.notifications.send(
  to: robleNotificationEveryone,
  title: 'Mañana no hay clase',
);

Llega a todos. Cada persona la marca leída por su cuenta: que tú la leas no la marca para los demás. Una de proyecto no se puede borrar desde la app —la notificación es una sola y es de todos—, se marca leída y ya.

Canales: avisar a un grupo

Cuando el aviso no es para una persona ni para todo el proyecto —los de un curso, los que siguen un tema— eso es un canal. Se envía una vez y lo reciben quienes estén dentro.

await db.notifications.subscribe('curso-101');
final mios = await db.notifications.channels();

// Enviar: `channel` en vez de `to`.
await db.notifications.send(channel: 'curso-101', title: 'Examen el viernes');

await db.notifications.unsubscribe('curso-101');

Un canal existe con solo usarlo; no hay que crearlo. Lo que sí se configura desde la consola es quién puede entrar:

open Cualquiera se suscribe solo. Para «me interesa el fútbol».
roles Solo ciertos roles.
managed La membresía la pone el servidor. Para una matrícula: quién está en Cálculo II no lo decide el estudiante.

Dos cosas que conviene saber: al entrar no recibes lo anterior —te enteras de lo que pasa desde que entras— y salirte siempre puedes, también de un canal managed.

Lo que ya estaba ahí

El stream no trae lo anterior, solo lo que llegue a partir de ahora. Para pintar la lista al abrir:

final lista = await db.notifications.list();              // las 50 últimas
final sinLeer = await db.notifications.list(unread: true);
final cuantas = await db.notifications.unreadCount();     // el número del globito

list() acepta topic, limit (1-100) y before para ir hacia atrás.

Marcarlas

await db.notifications.markRead(notificacion.id);
await db.notifications.markAllRead();
await db.notifications.remove(notificacion.id); // solo las dirigidas a ti

Marcar una vuelve por el stream como un evento read, y solo a tus dispositivos: el teléfono se entera de lo que marcaste en el navegador.

El globito, sin pedirlo

Al conectar, el servidor ya manda cuántas hay sin leer:

db.notifications.unreadCountChanges.listen((n) => setState(() => badge = n));

Enviar más tarde, o todos los días

Una notificación se puede dejar programada: la manda el servidor cuando llegue la hora, aunque nadie tenga la app abierta.

// Un recordatorio, una sola vez.
await db.notifications.schedule(
  to: usuarioId,
  title: 'Tu cita es en una hora',
  at: DateTime.now().add(const Duration(hours: 1)),
);

// Un "buenos días" todos los días, sin montar un cron.
await db.notifications.schedule(
  to: robleNotificationEveryone,
  title: '¡Buenos días!',
  at: manana7am,
  repeat: RobleRepeat.daily,
);
final pendientes = await db.notifications.scheduled();
await db.notifications.cancelScheduled(pendientes.first.id);

La fecha se manda siempre en UTC, así que puedes pasar una hora local sin pensarlo. Solo ves las tuyas. Quien puede enviar también decide quién puede programar, y se comprueba dos veces: al programar —para que te enteres ahora— y al enviar, porque para entonces la política del proyecto puede haber cambiado. Si ya no puedes, queda en failed con el motivo en lastError, y una repetida que falla se para en vez de reintentar cada día.

Que lleguen con la app cerrada (push)

Lo de arriba llega mientras la app está abierta. Para que suene con la app cerrada hace falta Firebase, y hacen falta tus credenciales, no las de Roble: un token de FCM está atado al proyecto de Firebase con el que registraste tu app, así que nadie puede enviarle push en tu nombre.

Una vez, en la consola de Roble: sección Notificaciones → Push, sube el JSON de la cuenta de servicio de tu proyecto de Firebase (Configuración del proyecto → Cuentas de servicio → Generar nueva clave privada).

Después, en tu app. El token lo da firebase_messaging, que va en tu app y no en este paquete —así una app que no quiera push no carga Firebase—:

final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
  await db.notifications.registerDevice(token, RobleDevicePlatform.android);
}

Llámalo en cada arranque: repetirlo con el mismo token no duplica nada, solo refresca la fecha. Y al cerrar sesión, antes de logout():

await db.notifications.unregisterDevice(token);

Si no lo sueltas, ese aparato sigue recibiendo los avisos de esa cuenta.

El push es un segundo camino, no el principal: la notificación se guarda y llega por el stream igual aunque Firebase no esté configurado o falle.

Ojo

Cualquiera con sesión en el proyecto puede enviar a cualquiera, igual que cualquiera puede escribir en cualquier rama del árbol JSON. Si el aviso lo tiene que mandar solo el profesor, esa comprobación va en tu código.


Cuando algo falla

Todo lanza alguna subclase de RobleApiException:

try {
  await db.login(email: correo, password: clave);
} on RobleApiHttpException catch (e) {
  if (e.statusCode == 401) mostrar('Correo o contraseña incorrectos');
} on RobleApiNetworkException {
  mostrar('Sin conexión');
} on RobleApiException catch (e) {
  mostrar(e.message);
}
Excepción Qué pasó
RobleApiHttpException El servidor respondió con error. Mira statusCode
RobleApiForbiddenException 403: tu rol no puede hacer eso (subclase de la anterior)
RobleApiNotFoundException 404: no existe, o no es tuyo (subclase de la anterior)
RobleApiNetworkException No se pudo llegar al servidor
RobleApiTimeoutException Tardó demasiado
RobleApiAuthException Problema de sesión o de login social
RobleApiFormatException La respuesta no tenía la forma esperada
RobleApiConflictException Ya existe una cuenta con ese correo

Los números que más vas a ver:

  • 401 — no hay sesión, o las credenciales están mal.
  • 403 — tu rol no puede hacer eso sobre esa tabla, la tabla no es pública (en publicRead), o intentaste borrar una colección entera del árbol JSON, que es cosa de administradores.
  • 404 — la fila no existe o no es tuya, o no existe esa consulta guardada.

Las dos últimas tienen su propia clase, así que no hace falta comparar números: RobleApiForbiddenException y RobleApiNotFoundException. Ambas siguen siendo RobleApiHttpException, así que el código que ya miraba statusCode no cambia.


Referencia rápida

Todos los métodos son asíncronos salvo isLoggedIn, isSocialCallback, currentUserId e isMine.

Sesión

Método Devuelve
isLoggedIn bool — si hay sesión en memoria
restoreSession() booltrue si la sesión guardada sigue viva
logout() nada

Cuentas

Método Devuelve
register() el usuario creado, tal como lo mandó el servidor
registerWithVerification() confirmación de que el correo salió
verifyEmail() confirmación
resendCode() confirmación
login() el perfil (arriba)
currentUser() el perfil
forgotPassword() confirmación de que el correo salió
resetPassword() confirmación
deleteAccount() nada

Login social

Método Devuelve
signInWithGoogle() el perfil
signInWithProvider() el perfil
signInWithIdToken() el perfil
exchangeSocialCode() el perfil
listProviders() List<RobleProviderInfo>name, displayName, clientId, autoLinkSupported
providerClientId() String?null si ese proveedor no está configurado
startSocialLogin() Uri — a dónde mandar a la persona
isSocialCallback() bool
RobleApiDataBase.newNonce() String

Tablas

Método Devuelve
create() el registro creado, con su _id
createMany() RobleInsertResultinserted y skipped
read() List<Map> — vacía si no hay nada, nunca null
getById() Map?null si no existe
update() el registro ya cambiado
delete() confirmación del borrado — 404 si ya no estaba o no es tuyo
isMine() bool — si la fila lleva tu _owner
currentUserId String? — el id de la sesión, sin ir al servidor
publicRead() List<Map> — sin necesidad de sesión
executeQuery() RobleQueryResultrows, rowCount, fields
executeQueryByName() RobleQueryResult

Árbol JSON

Método Devuelve
json.collections() List<String> — los nombres
json.read() lo que haya en esa ruta, o null si no existe
json.write() nada
json.update() nada
json.push() Stringla clave que generó el servidor
json.remove() nada — con un solo segmento, sólo para admin
json.watch() Stream<RobleChange>

Tiempo real

Método Devuelve
watchTable() Stream<RobleChange>
watchRecord() Stream<RobleChange>
realtimePolicies() List<RobleTablePolicy>
realtimePolicy() RobleTablePolicy?null si esa tabla no tiene política
setRealtimePolicy() la política ya guardada
disableRealtime() nada

Un RobleChange trae type (insert, update, delete), table, record (la fila después del cambio, null al borrar), previous, primaryKey, id, commitTimestamp y path (solo en el árbol JSON).


Notificaciones

Método Devuelve
notifications.send() las notificaciones creadas, una por destinatario
notifications.list() List<RobleNotification>, de la más reciente a la más antigua
notifications.unreadCount() int
notifications.markRead() la notificación ya marcada
notifications.markAllRead() int — cuántas cambiaron
notifications.remove() nada
notifications.watch() Stream<RobleNotificationEvent>
notifications.unreadCountChanges Stream<int>
notifications.subscribe() nada
notifications.unsubscribe() nada
notifications.channels() List<RobleChannel> — en los que estás
notifications.schedule() el envío programado
notifications.scheduled() List<RobleScheduledNotification> — los tuyos
notifications.cancelScheduled() nada
notifications.registerDevice() el aparato apuntado
notifications.unregisterDevice() nada
notifications.devices() List<RobleDevice> — los aparatos de esta cuenta

Una RobleNotification trae id, title, body, topic, data, recipientId (* si es de proyecto, o isForEveryone), senderId, readAt (o isUnread), createdAt y expiresAt.


Licencia

MIT

Libraries

roble