improved tab parent handling

This commit is contained in:
Fabian Freund
2026-01-02 16:53:26 +01:00
parent feb8393a42
commit 3f11325bff
10 changed files with 1951 additions and 32 deletions
@@ -118,14 +118,35 @@ class TabRepository extends _$TabRepository {
selectTabId: selectTabId,
);
// Build sets for validation
final creatingTabIds = createdTabIds.toSet();
final parentIdsToValidate = tabs
.map((tab) => tab.parentId)
.whereType<String>()
.where((id) => !creatingTabIds.contains(id))
.toSet();
// Batch validate parent IDs that aren't in the current creation batch
final existingParentIds =
await tabDao.getExistingTabIds(parentIdsToValidate).get().then((ids) => ids.toSet());
// Upsert all tabs in the database
for (var i = 0; i < createdTabIds.length; i++) {
final tabId = createdTabIds[i];
final tab = tabs[i];
// Validate parent exists in either the batch being created or database
String? validatedParentId;
if (tab.parentId != null) {
if (creatingTabIds.contains(tab.parentId) ||
existingParentIds.contains(tab.parentId)) {
validatedParentId = tab.parentId;
}
}
await tabDao.insertTab(
tabId,
parentId: Value(tab.parentId),
parentId: Value(validatedParentId),
source: TabSource.manual,
containerId: Value(container?.value?.id),
isPrivate: Value(tab.private),
@@ -539,7 +560,7 @@ class TabRepository extends _$TabRepository {
},
);
final tabStateDebouncer = Debouncer(const Duration(seconds: 3));
final tabStateDebouncer = Debouncer(const Duration(seconds: 1));
Map<String, TabState>? debounceStartValue;
ref.listen(
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'ea1079570ed2c3424fef6168a7633a10cfb9ac6a';
String _$tabRepositoryHash() => r'6a66c2f1f8d00767d19e6125d4eafc6d93381c8a';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -61,6 +61,15 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
return query.map((row) => row.read(db.tab.id)!);
}
/// Validates multiple tab IDs and returns only those that exist in the database.
Selectable<String> getExistingTabIds(Iterable<String> tabIds) {
final query = selectOnly(db.tab)
..addColumns([db.tab.id])
..where(db.tab.id.isIn(tabIds));
return query.map((row) => row.read(db.tab.id)!);
}
Selectable<TabData> getTabsFifo({int limit = 25}) {
return select(db.tab)
..limit(limit)
@@ -242,33 +251,72 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
Future<void> updateTabs(
Map<String, TabState>? previous,
Map<String, TabState> next,
) async {
await batch((batch) {
) {
return db.transaction(() async {
// Collect parent IDs that need database validation
final parentIdsToValidate = <String>{};
final validatedParentIds = <String, String?>{};
for (final state in next.values) {
final previousState = previous?[state.id];
if (previousState == null ||
previousState.url != state.url ||
previousState.title != state.title) {
batch.update(
db.tab,
TabCompanion(
parentId: (previousState?.parentId != state.parentId)
? Value(
next.containsKey(state.parentId) ? state.parentId : null,
)
: const Value.absent(),
url: (previousState?.url != state.url)
? Value(state.url)
: const Value.absent(),
title: (previousState?.title != state.title)
? Value(state.title)
: const Value.absent(),
),
where: (t) => t.id.equals(state.id),
);
if (previousState?.parentId != state.parentId && state.parentId != null) {
if (next.containsKey(state.parentId)) {
// Parent exists in current state
validatedParentIds[state.id] = state.parentId;
} else {
// Need to validate against database
parentIdsToValidate.add(state.parentId!);
}
}
}
// Batch validate parent IDs that aren't in the current state
final existingParentIds =
await getExistingTabIds(parentIdsToValidate).get().then((ids) => ids.toSet());
// Complete validation map
for (final state in next.values) {
final previousState = previous?[state.id];
if (previousState?.parentId != state.parentId) {
if (!validatedParentIds.containsKey(state.id)) {
// This parent ID needed database validation
if (state.parentId != null && existingParentIds.contains(state.parentId)) {
validatedParentIds[state.id] = state.parentId;
} else {
validatedParentIds[state.id] = null;
}
}
}
}
await batch((batch) {
for (final state in next.values) {
final previousState = previous?[state.id];
if (previousState == null ||
previousState.url != state.url ||
previousState.title != state.title ||
previousState.parentId != state.parentId) {
batch.update(
db.tab,
TabCompanion(
parentId: validatedParentIds.containsKey(state.id)
? Value(validatedParentIds[state.id])
: const Value.absent(),
url: (previousState?.url != state.url)
? Value(state.url)
: const Value.absent(),
title: (previousState?.title != state.title)
? Value(state.title)
: const Value.absent(),
),
where: (t) => t.id.equals(state.id),
);
}
}
});
});
}
@@ -31,7 +31,7 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
@DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao])
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
@override
final int schemaVersion = 4;
final int schemaVersion = 5;
@override
final int ftsTokenLimit = 10;
@@ -95,5 +95,9 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
),
);
},
from4To5: (m, schema) async {
await m.drop(schema.tabMaintainParentChainOnDelete);
await m.create(schema.tabMaintainParentChainOnDelete);
},
);
}
@@ -383,9 +383,88 @@ i1.GeneratedColumn<int> _column_16(String aliasedName) =>
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
final class Schema5 extends i0.VersionedSchema {
Schema5({required super.database}) : super(version: 5);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
container,
tab,
tabFts,
tabMaintainParentChainOnDelete,
tabAfterInsert,
tabAfterDelete,
tabAfterUpdate,
];
late final Shape0 container = Shape0(
source: i0.VersionedTable(
entityName: 'container',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_0, _column_1, _column_2, _column_3],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 tab = Shape3(
source: i0.VersionedTable(
entityName: 'tab',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_16,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 tabFts = Shape2(
source: i0.VersionedVirtualTable(
entityName: 'tab_fts',
moduleAndArgs:
'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")',
columns: [_column_8, _column_7, _column_12, _column_14],
attachedDatabase: database,
),
alias: null,
);
final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger(
'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END',
'tab_maintain_parent_chain_on_delete',
);
final i1.Trigger tabAfterInsert = i1.Trigger(
'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END',
'tab_after_insert',
);
final i1.Trigger tabAfterDelete = i1.Trigger(
'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END',
'tab_after_delete',
);
final i1.Trigger tabAfterUpdate = i1.Trigger(
'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END',
'tab_after_update',
);
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -399,6 +478,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
case 4:
final schema = Schema5(database: database);
final migrator = i1.Migrator(database, schema);
await from4To5(migrator, schema);
return 5;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -408,6 +492,11 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(from2To3: from2To3, from3To4: from3To4),
step: migrationSteps(
from2To3: from2To3,
from3To4: from3To4,
from4To5: from4To5,
),
);
@@ -42,9 +42,15 @@ CREATE VIRTUAL TABLE tab_fts
-- Create trigger to handle parent deletion
CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN
-- Update all children of the deleted row to point to its parent
UPDATE tab
SET parent_id = OLD.parent_id
-- Update all children of the deleted row to point to its parent (grandparent)
-- Only if the grandparent exists, otherwise set to NULL
UPDATE tab
SET parent_id = CASE
WHEN OLD.parent_id IS NOT NULL AND
EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)
THEN OLD.parent_id
ELSE NULL
END
WHERE parent_id = OLD.id;
END;
@@ -2195,7 +2195,7 @@ class TabFtsCompanion extends i0.UpdateCompanion<i3.TabFt> {
}
i0.Trigger get tabMaintainParentChainOnDelete => i0.Trigger(
'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = OLD.parent_id WHERE parent_id = OLD.id;END',
'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END',
'tab_maintain_parent_chain_on_delete',
);
i0.Trigger get tabAfterInsert => i0.Trigger(