feat: Add an example app and misc fixes (#81)
This commit is contained in:
parent
5a8f9c91a0
commit
de7e22d91a
5 changed files with 9138 additions and 61 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue