mirror of
https://github.com/zapstore/zapstore.git
synced 2026-09-14 03:05:06 +00:00
Script to create seed database for default stacks and apps
This commit is contained in:
Binary file not shown.
+25
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user