// Copyright © 2019 650 Industries. All rights reserved. #import #import NS_ASSUME_NONNULL_BEGIN @interface EXUpdatesDatabase () @property (nonatomic, assign) sqlite3 *db; @property (nonatomic, readwrite, strong) NSLock *lock; @end static NSString * const EXUpdatesDatabaseErrorDomain = @"EXUpdatesDatabase"; static NSString * const EXUpdatesDatabaseFilename = @"expo-v4.db"; @implementation EXUpdatesDatabase # pragma mark - lifecycle - (instancetype)init { if (self = [super init]) { _databaseQueue = dispatch_queue_create("expo.database.DatabaseQueue", DISPATCH_QUEUE_SERIAL); } return self; } - (BOOL)openDatabaseInDirectory:(NSURL *)directory withError:(NSError ** _Nullable)error { sqlite3 *db; NSURL *dbUrl = [directory URLByAppendingPathComponent:EXUpdatesDatabaseFilename]; BOOL shouldInitializeDatabase = ![[NSFileManager defaultManager] fileExistsAtPath:[dbUrl path]]; int resultCode = sqlite3_open([[dbUrl path] UTF8String], &db); if (resultCode != SQLITE_OK) { NSLog(@"Error opening SQLite db: %@", [self _errorFromSqlite:_db].localizedDescription); sqlite3_close(db); if (resultCode == SQLITE_CORRUPT || resultCode == SQLITE_NOTADB) { NSString *archivedDbFilename = [NSString stringWithFormat:@"%f-%@", [[NSDate date] timeIntervalSince1970], EXUpdatesDatabaseFilename]; NSURL *destinationUrl = [directory URLByAppendingPathComponent:archivedDbFilename]; NSError *err; if ([[NSFileManager defaultManager] moveItemAtURL:dbUrl toURL:destinationUrl error:&err]) { NSLog(@"Moved corrupt SQLite db to %@", archivedDbFilename); if (sqlite3_open([[dbUrl absoluteString] UTF8String], &db) != SQLITE_OK) { if (error != nil) { *error = [self _errorFromSqlite:_db]; } return NO; } shouldInitializeDatabase = YES; } else { NSString *description = [NSString stringWithFormat:@"Could not move existing corrupt database: %@", [err localizedDescription]]; if (error != nil) { *error = [NSError errorWithDomain:EXUpdatesDatabaseErrorDomain code:1004 userInfo:@{ NSLocalizedDescriptionKey: description, NSUnderlyingErrorKey: err }]; } return NO; } } else { if (error != nil) { *error = [self _errorFromSqlite:_db]; } return NO; } } _db = db; if (shouldInitializeDatabase) { return [self _initializeDatabase:error]; } return YES; } - (void)closeDatabase { sqlite3_close(_db); _db = nil; } - (void)dealloc { [self closeDatabase]; } - (BOOL)_initializeDatabase:(NSError **)error { NSAssert(_db, @"Missing database handle"); dispatch_assert_queue(_databaseQueue); NSString * const createTableStmts = @"\ PRAGMA foreign_keys = ON;\ CREATE TABLE \"updates\" (\ \"id\" BLOB UNIQUE,\ \"scope_key\" TEXT NOT NULL,\ \"commit_time\" INTEGER NOT NULL,\ \"runtime_version\" TEXT NOT NULL,\ \"launch_asset_id\" INTEGER,\ \"metadata\" TEXT,\ \"status\" INTEGER NOT NULL,\ \"keep\" INTEGER NOT NULL,\ PRIMARY KEY(\"id\"),\ FOREIGN KEY(\"launch_asset_id\") REFERENCES \"assets\"(\"id\") ON DELETE CASCADE\ );\ CREATE TABLE \"assets\" (\ \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\ \"url\" TEXT,\ \"key\" TEXT NOT NULL UNIQUE,\ \"headers\" TEXT,\ \"type\" TEXT NOT NULL,\ \"metadata\" TEXT,\ \"download_time\" INTEGER NOT NULL,\ \"relative_path\" TEXT NOT NULL,\ \"hash\" BLOB NOT NULL,\ \"hash_type\" INTEGER NOT NULL,\ \"marked_for_deletion\" INTEGER NOT NULL\ );\ CREATE TABLE \"updates_assets\" (\ \"update_id\" BLOB NOT NULL,\ \"asset_id\" INTEGER NOT NULL,\ FOREIGN KEY(\"update_id\") REFERENCES \"updates\"(\"id\") ON DELETE CASCADE,\ FOREIGN KEY(\"asset_id\") REFERENCES \"assets\"(\"id\") ON DELETE CASCADE\ );\ CREATE TABLE \"json_data\" (\ \"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\ \"key\" TEXT NOT NULL,\ \"value\" TEXT NOT NULL,\ \"last_updated\" INTEGER NOT NULL,\ \"scope_key\" TEXT NOT NULL\ );\ CREATE UNIQUE INDEX \"index_updates_scope_key_commit_time\" ON \"updates\" (\"scope_key\", \"commit_time\");\ CREATE INDEX \"index_updates_launch_asset_id\" ON \"updates\" (\"launch_asset_id\");\ CREATE INDEX \"index_json_data_scope_key\" ON \"json_data\" (\"scope_key\")\ "; char *errMsg; if (sqlite3_exec(_db, [createTableStmts UTF8String], NULL, NULL, &errMsg) != SQLITE_OK) { if (error != nil) { *error = [self _errorFromSqlite:_db]; } sqlite3_free(errMsg); return NO; }; return YES; } # pragma mark - insert and update - (void)addUpdate:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error { NSString * const sql = @"INSERT INTO \"updates\" (\"id\", \"scope_key\", \"commit_time\", \"runtime_version\", \"metadata\", \"status\" , \"keep\")\ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1);"; [self _executeSql:sql withArgs:@[ update.updateId, update.scopeKey, @([update.commitTime timeIntervalSince1970] * 1000), update.runtimeVersion, update.metadata ?: [NSNull null], @(EXUpdatesUpdateStatusPending) ] error:error]; } - (void)addNewAssets:(NSArray *)assets toUpdateWithId:(NSUUID *)updateId error:(NSError ** _Nullable)error { sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL); for (EXUpdatesAsset *asset in assets) { NSAssert(asset.downloadTime, @"asset downloadTime should be nonnull"); NSAssert(asset.filename, @"asset filename should be nonnull"); NSAssert(asset.contentHash, @"asset contentHash should be nonnull"); NSString * const assetInsertSql = @"INSERT OR REPLACE INTO \"assets\" (\"key\", \"url\", \"headers\", \"type\", \"metadata\", \"download_time\", \"relative_path\", \"hash\", \"hash_type\", \"marked_for_deletion\")\ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0);"; if ([self _executeSql:assetInsertSql withArgs:@[ asset.key, asset.url ? asset.url.absoluteString : [NSNull null], asset.headers ?: [NSNull null], asset.type, asset.metadata ?: [NSNull null], @(asset.downloadTime.timeIntervalSince1970 * 1000), asset.filename, asset.contentHash, @(EXUpdatesDatabaseHashTypeSha1) ] error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return; } // statements must stay in precisely this order for last_insert_rowid() to work correctly if (asset.isLaunchAsset) { NSString * const updateSql = @"UPDATE updates SET launch_asset_id = last_insert_rowid() WHERE id = ?1;"; if ([self _executeSql:updateSql withArgs:@[updateId] error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return; } } NSString * const updateInsertSql = @"INSERT OR REPLACE INTO updates_assets (\"update_id\", \"asset_id\") VALUES (?1, last_insert_rowid());"; if ([self _executeSql:updateInsertSql withArgs:@[updateId] error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return; } } sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL); } - (BOOL)addExistingAsset:(EXUpdatesAsset *)asset toUpdateWithId:(NSUUID *)updateId error:(NSError ** _Nullable)error { BOOL success; sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL); NSString * const assetSelectSql = @"SELECT id FROM assets WHERE \"key\" = ?1 LIMIT 1;"; NSArray *rows = [self _executeSql:assetSelectSql withArgs:@[asset.key] error:error]; if (!rows || ![rows count]) { success = NO; } else { NSNumber *assetId = rows[0][@"id"]; NSString * const insertSql = @"INSERT OR REPLACE INTO updates_assets (\"update_id\", \"asset_id\") VALUES (?1, ?2);"; if ([self _executeSql:insertSql withArgs:@[updateId, assetId] error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return NO; } if (asset.isLaunchAsset) { NSString * const updateSql = @"UPDATE updates SET launch_asset_id = ?1 WHERE id = ?2;"; if ([self _executeSql:updateSql withArgs:@[assetId, updateId] error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return NO; } } success = YES; } sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL); return success; } - (void)updateAsset:(EXUpdatesAsset *)asset error:(NSError ** _Nullable)error { NSAssert(asset.downloadTime, @"asset downloadTime should be nonnull"); NSAssert(asset.filename, @"asset filename should be nonnull"); NSAssert(asset.contentHash, @"asset contentHash should be nonnull"); NSString * const assetUpdateSql = @"UPDATE \"assets\" SET \"headers\" = ?2, \"type\" = ?3, \"metadata\" = ?4, \"download_time\" = ?5, \"relative_path\" = ?6, \"hash\" = ?7, \"url\" = ?8 WHERE \"key\" = ?1;"; [self _executeSql:assetUpdateSql withArgs:@[ asset.key, asset.headers ?: [NSNull null], asset.type, asset.metadata ?: [NSNull null], @(asset.downloadTime.timeIntervalSince1970 * 1000), asset.filename, asset.contentHash, asset.url ? asset.url.absoluteString : [NSNull null] ] error:error]; } - (void)mergeAsset:(EXUpdatesAsset *)asset withExistingEntry:(EXUpdatesAsset *)existingAsset error:(NSError ** _Nullable)error { // if the existing entry came from an embedded manifest, it may not have a URL in the database if (asset.url && !existingAsset.url) { existingAsset.url = asset.url; [self updateAsset:existingAsset error:error]; } // all other properties should be overridden by database values asset.filename = existingAsset.filename; asset.contentHash = existingAsset.contentHash; asset.downloadTime = existingAsset.downloadTime; } - (void)markUpdateFinished:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error { if (update.status != EXUpdatesUpdateStatusDevelopment) { update.status = EXUpdatesUpdateStatusReady; } NSString * const updateSql = @"UPDATE updates SET status = ?1, keep = 1 WHERE id = ?2;"; [self _executeSql:updateSql withArgs:@[ @(update.status), update.updateId ] error:error]; } - (void)setScopeKey:(NSString *)scopeKey onUpdate:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error { NSString * const updateSql = @"UPDATE updates SET scope_key = ?1 WHERE id = ?2;"; [self _executeSql:updateSql withArgs:@[scopeKey, update.updateId] error:error]; } # pragma mark - delete - (void)deleteUpdates:(NSArray *)updates error:(NSError ** _Nullable)error { sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL); NSString * const sql = @"DELETE FROM updates WHERE id = ?1;"; for (EXUpdatesUpdate *update in updates) { if ([self _executeSql:sql withArgs:@[update.updateId] error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return; } } sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL); } - (nullable NSArray *)deleteUnusedAssetsWithError:(NSError ** _Nullable)error { // the simplest way to mark the assets we want to delete // is to mark all assets for deletion, then go back and unmark // those assets in updates we want to keep // this is safe as long as we do this inside of a transaction sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL); NSString * const update1Sql = @"UPDATE assets SET marked_for_deletion = 1;"; if ([self _executeSql:update1Sql withArgs:nil error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return nil; } NSString * const update2Sql = @"UPDATE assets SET marked_for_deletion = 0 WHERE id IN (\ SELECT asset_id \ FROM updates_assets \ INNER JOIN updates ON updates_assets.update_id = updates.id\ WHERE updates.keep = 1\ );"; if ([self _executeSql:update2Sql withArgs:nil error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return nil; } NSString * const selectSql = @"SELECT * FROM assets WHERE marked_for_deletion = 1;"; NSArray *rows = [self _executeSql:selectSql withArgs:nil error:error]; if (!rows) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return nil; } NSMutableArray *assets = [NSMutableArray new]; for (NSDictionary *row in rows) { [assets addObject:[self _assetWithRow:row]]; } NSString * const deleteSql = @"DELETE FROM assets WHERE marked_for_deletion = 1;"; if ([self _executeSql:deleteSql withArgs:nil error:error] == nil) { sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL); return nil; } sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL); return assets; } # pragma mark - select - (nullable NSArray *)allUpdatesWithConfig:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error { NSString * const sql = @"SELECT * FROM updates WHERE scope_key = ?1;"; NSArray *rows = [self _executeSql:sql withArgs:@[config.scopeKey] error:error]; if (!rows) { return nil; } NSMutableArray *launchableUpdates = [NSMutableArray new]; for (NSDictionary *row in rows) { [launchableUpdates addObject:[self _updateWithRow:row config:config]]; } return launchableUpdates; } - (nullable NSArray *)launchableUpdatesWithConfig:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error { NSString *sql = [NSString stringWithFormat:@"SELECT *\ FROM updates\ WHERE scope_key = ?1\ AND status IN (%li, %li, %li);", (long)EXUpdatesUpdateStatusReady, (long)EXUpdatesUpdateStatusEmbedded, (long)EXUpdatesUpdateStatusDevelopment]; NSArray *rows = [self _executeSql:sql withArgs:@[config.scopeKey] error:error]; if (!rows) { return nil; } NSMutableArray *launchableUpdates = [NSMutableArray new]; for (NSDictionary *row in rows) { [launchableUpdates addObject:[self _updateWithRow:row config:config]]; } return launchableUpdates; } - (nullable EXUpdatesUpdate *)updateWithId:(NSUUID *)updateId config:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error { NSString * const sql = @"SELECT *\ FROM updates\ WHERE updates.id = ?1;"; NSArray *rows = [self _executeSql:sql withArgs:@[updateId] error:error]; if (!rows || ![rows count]) { return nil; } else { return [self _updateWithRow:rows[0] config:config]; } } - (nullable NSArray *)assetsWithUpdateId:(NSUUID *)updateId error:(NSError ** _Nullable)error { NSString * const sql = @"SELECT assets.id, \"key\", url, type, relative_path, assets.metadata, launch_asset_id\ FROM assets\ INNER JOIN updates_assets ON updates_assets.asset_id = assets.id\ INNER JOIN updates ON updates_assets.update_id = updates.id\ WHERE updates.id = ?1;"; NSArray *rows = [self _executeSql:sql withArgs:@[updateId] error:error]; if (!rows) { return nil; } NSMutableArray *assets = [NSMutableArray arrayWithCapacity:rows.count]; for (NSDictionary *row in rows) { [assets addObject:[self _assetWithRow:row]]; } return assets; } - (nullable EXUpdatesAsset *)assetWithKey:(NSString *)key error:(NSError ** _Nullable)error { NSString * const sql = @"SELECT * FROM assets WHERE \"key\" = ?1 LIMIT 1;"; NSArray *rows = [self _executeSql:sql withArgs:@[key] error:error]; if (!rows || ![rows count]) { return nil; } else { return [self _assetWithRow:rows[0]]; } } # pragma mark - helper methods - (nullable NSArray *)_executeSql:(NSString *)sql withArgs:(nullable NSArray *)args error:(NSError ** _Nullable)error { NSAssert(_db, @"Missing database handle"); dispatch_assert_queue(_databaseQueue); sqlite3_stmt *stmt; if (sqlite3_prepare_v2(_db, [sql UTF8String], -1, &stmt, NULL) != SQLITE_OK) { if (error != nil) { *error = [self _errorFromSqlite:_db]; } return nil; } if (args) { if (![self _bindStatement:stmt withArgs:args]) { if (error != nil) { *error = [self _errorFromSqlite:_db]; } return nil; } } NSMutableArray *rows = [NSMutableArray arrayWithCapacity:0]; NSMutableArray *columnNames = [NSMutableArray arrayWithCapacity:0]; int columnCount = 0; BOOL didFetchColumns = NO; int result; BOOL hasMore = YES; BOOL didError = NO; while (hasMore) { result = sqlite3_step(stmt); switch (result) { case SQLITE_ROW: { if (!didFetchColumns) { // get all column names once at the beginning columnCount = sqlite3_column_count(stmt); for (int i = 0; i < columnCount; i++) { [columnNames addObject:[NSString stringWithUTF8String:sqlite3_column_name(stmt, i)]]; } didFetchColumns = YES; } NSMutableDictionary *entry = [NSMutableDictionary dictionary]; for (int i = 0; i < columnCount; i++) { id columnValue = [self _getValueWithStatement:stmt column:i]; entry[columnNames[i]] = columnValue; } [rows addObject:entry]; break; } case SQLITE_DONE: hasMore = NO; break; default: didError = YES; hasMore = NO; break; } } if (didError && error != nil) { *error = [self _errorFromSqlite:_db]; } sqlite3_finalize(stmt); return didError ? nil : rows; } - (id)_getValueWithStatement:(sqlite3_stmt *)stmt column:(int)column { int columnType = sqlite3_column_type(stmt, column); switch (columnType) { case SQLITE_INTEGER: return @(sqlite3_column_int64(stmt, column)); case SQLITE_FLOAT: return @(sqlite3_column_double(stmt, column)); case SQLITE_BLOB: NSAssert(sqlite3_column_bytes(stmt, column) == 16, @"SQLite BLOB value should be a valid UUID"); return [[NSUUID alloc] initWithUUIDBytes:sqlite3_column_blob(stmt, column)]; case SQLITE_TEXT: return [[NSString alloc] initWithBytes:(char *)sqlite3_column_text(stmt, column) length:sqlite3_column_bytes(stmt, column) encoding:NSUTF8StringEncoding]; } return [NSNull null]; } - (BOOL)_bindStatement:(sqlite3_stmt *)stmt withArgs:(NSArray *)args { __block BOOL success = YES; [args enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { if ([obj isKindOfClass:[NSUUID class]]) { uuid_t bytes; [((NSUUID *)obj) getUUIDBytes:bytes]; if (sqlite3_bind_blob(stmt, (int)idx + 1, bytes, 16, SQLITE_TRANSIENT) != SQLITE_OK) { success = NO; *stop = YES; } } else if ([obj isKindOfClass:[NSNumber class]]) { if (sqlite3_bind_int64(stmt, (int)idx + 1, [((NSNumber *)obj) longLongValue]) != SQLITE_OK) { success = NO; *stop = YES; } } else if ([obj isKindOfClass:[NSDictionary class]]) { NSError *error; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:(NSDictionary *)obj options:kNilOptions error:&error]; if (!error && sqlite3_bind_text(stmt, (int)idx + 1, jsonData.bytes, (int)jsonData.length, SQLITE_TRANSIENT) != SQLITE_OK) { success = NO; *stop = YES; } } else if ([obj isKindOfClass:[NSNull class]]) { if (sqlite3_bind_null(stmt, (int)idx + 1) != SQLITE_OK) { success = NO; *stop = YES; } } else { // convert to string NSString *string = [obj isKindOfClass:[NSString class]] ? (NSString *)obj : [obj description]; NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding]; if (sqlite3_bind_text(stmt, (int)idx + 1, data.bytes, (int)data.length, SQLITE_TRANSIENT) != SQLITE_OK) { success = NO; *stop = YES; } } }]; return success; } - (NSError *)_errorFromSqlite:(struct sqlite3 *)db { int code = sqlite3_errcode(db); int extendedCode = sqlite3_extended_errcode(db); NSString *message = [NSString stringWithUTF8String:sqlite3_errmsg(db)]; return [NSError errorWithDomain:EXUpdatesDatabaseErrorDomain code:extendedCode userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Error code %i: %@ (extended error code %i)", code, message, extendedCode]}]; } - (EXUpdatesUpdate *)_updateWithRow:(NSDictionary *)row config:(EXUpdatesConfig *)config { NSError *error; id metadata = nil; id rowMetadata = row[@"metadata"]; if ([rowMetadata isKindOfClass:[NSString class]]) { metadata = [NSJSONSerialization JSONObjectWithData:[(NSString *)rowMetadata dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&error]; NSAssert(!error && metadata && [metadata isKindOfClass:[NSDictionary class]], @"Update metadata should be a valid JSON object"); } EXUpdatesUpdate *update = [EXUpdatesUpdate updateWithId:row[@"id"] scopeKey:row[@"scope_key"] commitTime:[NSDate dateWithTimeIntervalSince1970:[(NSNumber *)row[@"commit_time"] doubleValue] / 1000] runtimeVersion:row[@"runtime_version"] metadata:metadata status:(EXUpdatesUpdateStatus)[(NSNumber *)row[@"status"] integerValue] keep:[(NSNumber *)row[@"keep"] boolValue] config:config database:self]; return update; } - (EXUpdatesAsset *)_assetWithRow:(NSDictionary *)row { NSError *error; id metadata = nil; id rowMetadata = row[@"metadata"]; if ([rowMetadata isKindOfClass:[NSString class]]) { metadata = [NSJSONSerialization JSONObjectWithData:[(NSString *)rowMetadata dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&error]; NSAssert(!error && metadata && [metadata isKindOfClass:[NSDictionary class]], @"Asset metadata should be a valid JSON object"); } id launchAssetId = row[@"launch_asset_id"]; id rowUrl = row[@"url"]; NSURL *url; if (rowUrl && [rowUrl isKindOfClass:[NSString class]]) { url = [NSURL URLWithString:rowUrl]; } EXUpdatesAsset *asset = [[EXUpdatesAsset alloc] initWithKey:row[@"key"] type:row[@"type"]]; asset.url = url; asset.downloadTime = [NSDate dateWithTimeIntervalSince1970:([(NSNumber *)row[@"download_time"] doubleValue] / 1000)]; asset.filename = row[@"relative_path"]; asset.contentHash = row[@"hash"]; asset.metadata = metadata; asset.isLaunchAsset = (launchAssetId && [launchAssetId isKindOfClass:[NSNumber class]]) ? [(NSNumber *)launchAssetId isEqualToNumber:(NSNumber *)row[@"id"]] : NO; return asset; } @end NS_ASSUME_NONNULL_END