Script to create seed database for default stacks and apps

This commit is contained in:
franzap
2026-04-04 20:40:11 -03:00
parent 5ebb217a28
commit fdf9df4bd1
5 changed files with 241 additions and 3 deletions
BIN
View File
Binary file not shown.
+25 -1
View File
@@ -1,8 +1,9 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:io' show File, Platform;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
@@ -235,6 +236,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
// Clear storage if requested from a clear all operation
await maybeClearStorage(dbPath);
// Seed database on first launch so new users see content immediately
await _maybeCopySeedDatabase(dbPath);
// Load local relay config BEFORE storage init
// This ensures custom relays work even when signed out
final secureStorage = ref.read(secureStorageServiceProvider);
@@ -291,6 +295,26 @@ final amberSignerProvider = Provider<AmberSigner>(
(ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()),
);
/// Copy the bundled seed database on first launch so the UI has content
/// before relay data arrives. No-op if the database already exists.
Future<void> _maybeCopySeedDatabase(String dbPath) async {
final dbFile = File(dbPath);
if (dbFile.existsSync()) return;
try {
final seedData = await rootBundle.load('assets/seed.db');
await dbFile.create(recursive: true);
await dbFile.writeAsBytes(
seedData.buffer.asUint8List(
seedData.offsetInBytes,
seedData.lengthInBytes,
),
flush: true,
);
} catch (_) {
// Non-fatal: the app works fine without the seed — just a cold start
}
}
Future<void> _attemptAutoSignIn(Ref ref) async {
try {
await ref.read(amberSignerProvider).attemptAutoSignIn();
+2 -2
View File
@@ -1076,7 +1076,7 @@ packages:
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
dependency: "direct dev"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
@@ -1292,7 +1292,7 @@ packages:
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
dependency: "direct dev"
description:
name: web_socket
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
+4
View File
@@ -86,6 +86,9 @@ dev_dependencies:
flutter_lints: ^5.0.0
flutter_launcher_icons: ^0.14.4
sqlite3: any
web_socket: any
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -99,6 +102,7 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- assets/images/
- assets/seed.db
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
+210
View File
@@ -0,0 +1,210 @@
/// Pre-release script that generates assets/seed.db for new-user cold start.
///
/// Fetches curated stacks (kind 30267) and their referenced apps (kind 32267)
/// from the AppCatalog relay, plus author profiles (kind 0) from social relays.
/// Writes them into a SQLite database using the same schema/codec as purplebase.
///
/// Usage:
/// dart run tool/seed_database.dart
///
/// The output file is assets/seed.db — commit it before building the release APK.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:sqlite3/sqlite3.dart';
import 'package:web_socket/web_socket.dart';
// Internal purplebase imports for codec + schema
import 'package:purplebase/src/db/codec.dart';
import 'package:purplebase/src/db/schema.dart';
const _kZapstoreCommunityPubkey =
'acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84';
const _kAppCatalogRelay = 'wss://relay.zapstore.dev';
const _kProfileRelays = [
'wss://relay.vertexlab.io',
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
];
void main() async {
final outputPath = 'assets/seed.db';
stdout.writeln('=== Zapstore seed database generator ===\n');
// 1. Fetch stacks
stdout.writeln('Fetching stacks from $_kAppCatalogRelay ...');
final stacks = await _fetchEvents(
_kAppCatalogRelay,
{'kinds': [30267], 'authors': [_kZapstoreCommunityPubkey], 'limit': 100},
);
stdout.writeln(' ${stacks.length} stacks');
// 2. Extract referenced app coordinates and author pubkeys
final appRefs = <String>{};
final pubkeys = <String>{_kZapstoreCommunityPubkey};
for (final stack in stacks) {
pubkeys.add(stack['pubkey'] as String);
for (final tag in (stack['tags'] as List)) {
if (tag[0] == 'a' && (tag[1] as String).startsWith('32267:')) {
appRefs.add(tag[1] as String);
pubkeys.add((tag[1] as String).split(':')[1]);
}
}
}
// 3. Fetch apps by d-tag
final dTags = appRefs.map((r) => r.split(':')[2]).toList();
stdout.writeln('Fetching ${dTags.length} apps from $_kAppCatalogRelay ...');
final apps = await _fetchEvents(
_kAppCatalogRelay,
{'kinds': [32267], '#d': dTags, 'limit': 500},
);
stdout.writeln(' ${apps.length} apps');
// Collect any additional pubkeys from fetched apps
for (final app in apps) {
pubkeys.add(app['pubkey'] as String);
}
// 4. Fetch profiles from multiple relays, dedup by pubkey (keep newest)
stdout.writeln('Fetching profiles for ${pubkeys.length} pubkeys ...');
final profilesByPubkey = <String, Map<String, dynamic>>{};
for (final relay in [_kAppCatalogRelay, ..._kProfileRelays]) {
final remaining =
pubkeys.where((pk) => !profilesByPubkey.containsKey(pk)).toList();
if (remaining.isEmpty) break;
stdout.write(' $relay (${remaining.length} remaining) ... ');
try {
final profiles = await _fetchEvents(
relay,
{'kinds': [0], 'authors': remaining, 'limit': remaining.length},
);
for (final p in profiles) {
final pk = p['pubkey'] as String;
final existing = profilesByPubkey[pk];
if (existing == null ||
(p['created_at'] as int) > (existing['created_at'] as int)) {
profilesByPubkey[pk] = p;
}
}
stdout.writeln('${profiles.length} found');
} catch (e) {
stdout.writeln('failed ($e)');
}
}
final profiles = profilesByPubkey.values.toList();
final missingCount = pubkeys.length - profiles.length;
stdout.writeln(' ${profiles.length} profiles total'
'${missingCount > 0 ? ' ($missingCount missing — will be fetched at runtime)' : ''}');
// 5. Build the SQLite database
final allEvents = [...stacks, ...apps, ...profiles];
stdout.writeln('\nWriting ${allEvents.length} events to $outputPath ...');
final dbFile = File(outputPath);
if (dbFile.existsSync()) dbFile.deleteSync();
dbFile.parent.createSync(recursive: true);
final db = sqlite3.open(outputPath);
try {
db.execute(setUpSql);
_insertEvents(db, allEvents);
db.execute('VACUUM');
} finally {
db.dispose();
}
final fileSize = File(outputPath).lengthSync();
stdout.writeln(' Done: $fileSize bytes (${(fileSize / 1024).toStringAsFixed(1)} KB)');
stdout.writeln('\nSeed database ready. Commit $outputPath before building the release.');
}
/// Fetch events from a single relay using a one-shot REQ/EOSE pattern.
Future<List<Map<String, dynamic>>> _fetchEvents(
String relayUrl,
Map<String, dynamic> filter,
) async {
final uri = Uri.parse(relayUrl);
final ws = await WebSocket.connect(uri).timeout(const Duration(seconds: 10));
final events = <Map<String, dynamic>>[];
final completer = Completer<List<Map<String, dynamic>>>();
const subId = 'seed';
final sub = ws.events.listen((event) {
if (event case TextDataReceived(:final text)) {
final msg = jsonDecode(text) as List;
if (msg[0] == 'EVENT' && msg[1] == subId) {
events.add(msg[2] as Map<String, dynamic>);
} else if (msg[0] == 'EOSE' && msg[1] == subId) {
if (!completer.isCompleted) completer.complete(events);
}
}
});
ws.sendText(jsonEncode(['REQ', subId, filter]));
try {
return await completer.future.timeout(const Duration(seconds: 15));
} finally {
ws.sendText(jsonEncode(['CLOSE', subId]));
sub.cancel();
unawaited(ws.close().catchError((_) {}));
}
}
/// Insert events into the database using the same codec as purplebase.
void _insertEvents(Database db, List<Map<String, dynamic>> events) {
final (encodedEvents, tagsForId) = EventCodec.encode(events);
final sql = '''
INSERT INTO events (id, pubkey, kind, created_at, blob)
VALUES (:id, :pubkey, :kind, :created_at, :blob)
ON CONFLICT(id) DO UPDATE SET
pubkey = EXCLUDED.pubkey,
kind = EXCLUDED.kind,
created_at = EXCLUDED.created_at,
blob = EXCLUDED.blob
WHERE EXCLUDED.created_at > events.created_at;
INSERT OR REPLACE INTO event_tags (event_id, value, is_relay)
VALUES (:event_id, :value, :is_relay);
''';
final [eventPs, tagsPs] = db.prepareMultiple(sql);
try {
db.execute('BEGIN');
for (final event in encodedEvents) {
eventPs.executeWith(StatementParameters.named(event));
for (final List tag in tagsForId[event[':id']]!) {
if (tag.length < 2 || tag[0].toString().length > 1) continue;
tagsPs.executeWith(
StatementParameters.named({
':event_id': event[':id'],
':value': '${tag[0]}:${tag[1]}',
':is_relay': false,
}),
);
}
}
db.execute('COMMIT');
} catch (e) {
db.execute('ROLLBACK');
rethrow;
} finally {
eventPs.dispose();
tagsPs.dispose();
}
}