feat: Add an example app and misc fixes (#81)

This commit is contained in:
Marcelo Mendonça Soares 2026-06-23 22:53:47 -03:00 • committed by GitHub
commit de7e22d91a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 9138 additions and 61 deletions

View file

@ -1,12 +1,27 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
hide Endpoints, Protocol;
import 'src/demo_auth.dart';
import 'src/demo_sync_tables.dart';
import 'src/generated/endpoints.dart';
import 'src/generated/protocol.dart';
/// The starting point of the Serverpod server.
Future<void> run(List<String> args) async {
// Initialize Serverpod and connect it with your generated code.
final pod = Serverpod(args, Protocol(), Endpoints());
final pod = Serverpod(
args,
Protocol(),
Endpoints(),
authenticationHandler: demoAuthenticationHandler,
// TODO: Remove the duplicate [syncTables] argument from here and the
// [initializeCrdtSync] call below.
databaseInterceptor: (_, inner) => CrdtDatabase(
inner,
syncTables: demoSyncTables,
),
)..initializeCrdtSync(syncTables: demoSyncTables);
// Start the server.
await pod.start();

View file

@ -0,0 +1,70 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart';
const _demoScopeName = 'offline-sync-demo';
const _demoTokenPrefix = 'offline-sync-demo:';
/// Normalizes a demo username for deterministic auth user creation.
String normalizeDemoUsername(String username) {
final normalized = username.trim().toLowerCase();
if (normalized.isEmpty) {
throw ArgumentError.value(username, 'username', 'Must not be empty.');
}
return normalized;
}
/// Returns the deterministic auth user id used by the demo username.
UuidValue demoAuthUserIdForUsername(String username) {
final normalized = normalizeDemoUsername(username);
final id = const Uuid().v5(
Namespace.url.value,
'serverpod-offline-sync/demo-user/$normalized',
);
return UuidValue.withValidation(id);
}
/// Creates the bearer token accepted by [demoAuthenticationHandler].
String demoTokenForAuthUserId(UuidValue authUserId) {
return '$_demoTokenPrefix${authUserId.uuid}';
}
/// Extracts the deterministic auth user id from a demo bearer token.
UuidValue? demoAuthUserIdFromToken(String token) {
if (!token.startsWith(_demoTokenPrefix)) return null;
final value = token.substring(_demoTokenPrefix.length);
try {
return UuidValue.withValidation(value);
} on FormatException {
return null;
}
}
/// Authentication handler for the passwordless offline-sync demo users.
Future<AuthenticationInfo?> demoAuthenticationHandler(
Session session,
String token,
) async {
final authUserId = demoAuthUserIdFromToken(token);
if (authUserId == null) return null;
final authUser = await AuthUser.db.findById(session, authUserId);
if (authUser == null || authUser.blocked) return null;
return AuthenticationInfo(
authUserId.uuid,
authUser.scopeNames.map(Scope.new).toSet(),
authId: authUserId.uuid,
);
}
/// Creates a new auth user row for the passwordless offline-sync demo.
AuthUser newDemoAuthUser(String username) {
final normalized = normalizeDemoUsername(username);
return AuthUser(
id: demoAuthUserIdForUsername(normalized),
scopeNames: {
_demoScopeName,
'$_demoScopeName:$normalized',
},
);
}

View file

@ -0,0 +1,29 @@
import 'package:serverpod/serverpod.dart';
import 'generated/protocol.dart';
/// Tables exposed through the offline-sync demo server.
final demoSyncTables = <Table>[
Address.t,
City.t,
Company.t,
FkChainCascadeMiddle.t,
FkChainMiddleCascadeChild.t,
FkChainMiddleSetNullChild.t,
FkChainRestrictBlocker.t,
FkChainRoot.t,
FkChainSetNullCascadeChild.t,
FkChainSetNullMiddle.t,
FkChainSetNullRestrictChild.t,
FkChainSetNullSetNullChild.t,
Organization.t,
Person.t,
RequiredSetNullChild.t,
RestrictChild.t,
Town.t,
Types.t,
Unique.t,
UniqueComposite.t,
UniqueSetNullChild.t,
UniqueUuid.t,
];

View file

@ -0,0 +1,30 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart';
import '../demo_auth.dart';
/// Passwordless auth endpoint used by the offline-sync demo app.
class DemoAuthEndpoint extends Endpoint {
@override
bool get requireLogin => false;
/// Creates or reuses a demo auth user and returns its bearer token.
Future<String> loginOrCreateUser(
Session session,
String username,
) async {
final authUser = newDemoAuthUser(username);
final authUserId = authUser.id!;
final existing = await AuthUser.db.findById(session, authUserId);
if (existing == null) {
try {
await AuthUser.db.insertRow(session, authUser);
} on DatabaseQueryException {
// A concurrent demo login may have created the deterministic row.
}
}
return demoTokenForAuthUserId(authUserId);
}
}

View file

@ -0,0 +1,334 @@
import 'dart:typed_data';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
show CrdtDatabase, CrdtScope, IncludeTombstonedRows;
import '../generated/protocol.dart';
/// Read-only inspection endpoint used by the offline-sync demo app to show the
/// server's merged truth for the authenticated user's scope.
class DemoDebugEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Returns every synced domain row in the caller's scope on the server as a
/// flat list of models. When [includeHidden] is true, the list also includes
/// CRDT-hidden rows (conflict losers, soft-deleted rows) via the
/// `t.includeHiddenRows` expression; otherwise only visible rows are returned.
///
/// The rows cross the wire as `dynamic`: Serverpod tags each model with its
/// class name — the same mechanism the CRDT sync layer uses — so the client
/// deserializes them straight back into typed models with no per-table
/// plumbing on either side. The client flags hidden rows by diffing a
/// visible-only fetch against an include-hidden one.
Future<List<dynamic>> fetchScopeSnapshot(
Session session, {
bool includeHidden = false,
}) async {
final userId = UuidValue.withValidation(
session.authenticated!.userIdentifier,
);
final db = session.db;
if (db is! CrdtDatabase) {
return _loadRows(session, null, includeHidden: false, scopeId: null);
}
// Run the reads in the caller's scope so the snapshot reflects what this
// user sees, not an unscoped admin view across every scope.
return db.transactionForUser(userId, (transaction) async {
final scope = await CrdtScope.db.findFirstRow(
session,
where: (t) => t.uuidScopeId.equals(userId),
transaction: transaction,
);
return _loadRows(
session,
transaction,
includeHidden: includeHidden,
scopeId: scope?.id,
);
});
}
/// Clears the caller's scope by deleting its `crdt_scopes` row. Every synced
/// table cascades on `scopeId` → `crdt_scopes`, so all domain rows and CRDT
/// metadata are removed with it — no manual per-table cleanup needed.
///
/// The delete runs with `defer_foreign_keys` on: the scopeId cascade fans out
/// across the CRDT metadata diamond (`crdt_data_rows`/`crdt_data_fields`/
/// `crdt_data_tombstone` reference `crdt_nodes` with NO ACTION while both sides
/// cascade off `crdt_scopes`), and SQLite's cascade order can transiently
/// violate those immediate checks. Deferring them to commit lets the whole
/// cascade complete first.
Future<void> resetScope(Session session) async {
final userId = UuidValue.withValidation(
session.authenticated!.userIdentifier,
);
final db = session.db;
if (db is CrdtDatabase) {
await db.initialize();
}
final scope = await CrdtScope.db.findFirstRow(
session,
where: (t) => t.uuidScopeId.equals(userId),
);
final scopeId = scope?.id;
if (scopeId != null) {
await db.transaction((transaction) async {
// TODO: Remove this explicit call to defer foreign keys once foreign
// keys are deferrable by default on the Serverpod package.
await db.unsafeExecute(
'PRAGMA defer_foreign_keys = ON',
transaction: transaction,
);
await CrdtScope.db.deleteWhere(
session,
where: (t) => t.id.equals(scopeId),
transaction: transaction,
);
});
}
if (db is CrdtDatabase) {
await db.initialize();
}
}
/// Inserts demo rows of [kind] directly into the caller's scope on the server,
/// without going through a replica. Lets the "Server" seed target exercise the
/// fetch-from-scratch flow: seed here, reset a replica, then sync to pull it
/// down. [text] carries an optional name/value for the single-row kinds.
Future<void> seedScope(Session session, String kind, String? text) async {
final userId = UuidValue.withValidation(
session.authenticated!.userIdentifier,
);
final db = session.db;
if (db is! CrdtDatabase) {
throw StateError('This endpoint only works with a CRDT database.');
}
final tag = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
// TODO: Expose the [transactionForUser] through an extension method on the
// base [Database] class so the cast is unnecessary.
await db.transactionForUser(userId, (transaction) async {
switch (kind) {
case 'basicGraph':
final city = await City.db.insertRow(
session,
City(id: const Uuid().v7obj(), name: 'Server City $tag'),
transaction: transaction,
);
await Town.db.insertRow(
session,
Town(
id: const Uuid().v7obj(),
name: 'Server Town $tag',
cityId: city.id,
),
transaction: transaction,
);
final person = await Person.db.insertRow(
session,
Person(
id: const Uuid().v7obj(),
name: 'Server Person $tag',
surname: 'Remote',
),
transaction: transaction,
);
await Address.db.insertRow(
session,
Address(
id: const Uuid().v7obj(),
street: 'Server Street $tag',
inhabitantId: person.id,
),
transaction: transaction,
);
case 'typedRow':
await Types.db.insertRow(
session,
Types(
id: const Uuid().v7obj(),
aBool: true,
aDateTime: DateTime.now().toUtc(),
aText: 'server-typed-$tag',
anInt: 7,
anInt64: BigInt.parse('9007199254740993'),
aReal: 1.5,
aBlob: ByteData.sublistView(Uint8List.fromList([1, 2, 3])),
anEnum: TypesEnum.alpha,
optionalText: 'server-$tag',
optionalUuid: const Uuid().v7obj(),
),
transaction: transaction,
);
case 'person':
final name = (text == null || text.isEmpty)
? 'Server Person $tag'
: '$text $tag';
await Person.db.insertRow(
session,
Person(id: const Uuid().v7obj(), name: name),
transaction: transaction,
);
case 'restrictChild':
final people = await Person.db.find(session, transaction: transaction);
Person? parent;
for (final person in people) {
if (person.id != null) {
parent = person;
break;
}
}
if (parent == null) {
throw StateError(
'Seed or sync a visible Person before adding RestrictChild.',
);
}
await RestrictChild.db.insertRow(
session,
RestrictChild(
id: const Uuid().v7obj(),
name: 'Server restrict child $tag',
parentId: parent.id,
),
transaction: transaction,
);
case 'unique':
await Unique.db.insertRow(
session,
Unique(id: const Uuid().v7obj(), name: text ?? 'server-unique-$tag'),
transaction: transaction,
);
case 'uniqueUuid':
final value = (text == null || text.isEmpty)
? const Uuid().v7obj()
: UuidValue.withValidation(text);
await UniqueUuid.db.insertRow(
session,
UniqueUuid(id: const Uuid().v7obj(), value: value),
transaction: transaction,
);
case 'fkChain':
final root = await FkChainRoot.db.insertRow(
session,
FkChainRoot(id: const Uuid().v7obj(), name: 'Server Root $tag'),
transaction: transaction,
);
final middle = await FkChainCascadeMiddle.db.insertRow(
session,
FkChainCascadeMiddle(
id: const Uuid().v7obj(),
name: 'Server Cascade middle $tag',
rootId: root.id,
),
transaction: transaction,
);
final blocker = await FkChainRestrictBlocker.db.insertRow(
session,
FkChainRestrictBlocker(
id: const Uuid().v7obj(),
name: 'Server Blocker $tag',
cascadeMiddleId: middle.id,
),
transaction: transaction,
);
await FkChainMiddleSetNullChild.db.insertRow(
session,
FkChainMiddleSetNullChild(
id: const Uuid().v7obj(),
name: 'Server set-null child $tag',
restrictBlockerId: blocker.id,
),
transaction: transaction,
);
await FkChainMiddleCascadeChild.db.insertRow(
session,
FkChainMiddleCascadeChild(
id: const Uuid().v7obj(),
name: 'Server cascade child $tag',
restrictBlockerId: blocker.id,
),
transaction: transaction,
);
}
});
}
/// Loads every synced domain row in the caller's scope as a flat list of
/// models. When [includeHidden] is true, CRDT-hidden rows are returned too
/// via the `t.includeHiddenRows` expression.
///
/// `includeHiddenRows` is an extension on any [Table], so the read is uniform
/// across every synced type — each table is listed once because Dart cannot
/// turn a runtime [Table] back into the compile-time `T` that
/// `session.db.find<T>` requires.
Future<List<dynamic>> _loadRows(
Session session,
Transaction? transaction, {
required bool includeHidden,
required int? scopeId,
}) async {
final rows = <dynamic>[];
Future<void> add<T extends TableRow>() async {
rows.addAll(
await session.db.find<T>(
where: includeHidden ? _includeHiddenRowsInScope<T>(session, scopeId) : null,
transaction: transaction,
),
);
}
await add<Person>();
await add<Address>();
await add<City>();
await add<Town>();
await add<Organization>();
await add<Company>();
await add<RestrictChild>();
await add<RequiredSetNullChild>();
await add<Unique>();
await add<UniqueUuid>();
await add<UniqueComposite>();
await add<UniqueSetNullChild>();
await add<Types>();
await add<FkChainRoot>();
await add<FkChainCascadeMiddle>();
await add<FkChainRestrictBlocker>();
await add<FkChainMiddleSetNullChild>();
await add<FkChainMiddleCascadeChild>();
await add<FkChainSetNullMiddle>();
await add<FkChainSetNullCascadeChild>();
await add<FkChainSetNullRestrictChild>();
await add<FkChainSetNullSetNullChild>();
return rows;
}
Expression _includeHiddenRowsInScope<T extends TableRow>(
Session session,
int? scopeId,
) {
if (scopeId == null) {
throw StateError('Cannot include hidden rows without a CRDT scope.');
}
final table =
session.db.serializationManager.getTableForType(T) ??
(throw StateError('No table is registered for type $T.'));
return _scopeIdColumn(table).equals(scopeId) & table.includeHiddenRows;
}
ColumnInt _scopeIdColumn(Table table) {
for (final column in table.columns) {
if (column.columnName == 'scopeId' && column is ColumnInt) {
return column;
}
}
throw StateError('Synced table "${table.tableName}" has no scopeId column.');
}
}