refactor(learn): own rows name shared content by its ID alone
ADR 0044, D26. The sync engine links no row of one space to a row of another: a reference across spaces is blanked, on the device and on the server alike (found by experiment, 11 Nov 2026). Reviews, card, note and path tags, PDF readings and path answers are the person's own (D2, D25) but will name cards, notes, paths, steps and PDFs of spaces they share, so those six tables keep their columns without the relation. Every gateway already deletes them as it deletes what they name. The migration drops the nine foreign keys of those tables and adds the three to `tag` again, which Serverpod numbers anew; no column or row changes. The synced tables' hash covers foreign keys, so released apps stop syncing with a server that has it until they update: deploy it together with a release of Learn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6b1e9a0226
commit
358d983011
2 changed files with 32974 additions and 1147 deletions
|
|
@ -0,0 +1,199 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/shared_space/sync_space_grants.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../support/sync_database.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// A person's own rows name what is in a space they share by its ID alone
|
||||
/// (Halkyon's ADR 0044, D26): the sync engine links no row of one space to
|
||||
/// a row of another, so a relation would lose the card a review names.
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Own rows naming shared content',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
late UuidValue space;
|
||||
final grants = SyncSpaceGrants();
|
||||
|
||||
setUp(() async {
|
||||
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
alice = const Uuid().v7obj();
|
||||
bob = const Uuid().v7obj();
|
||||
space = await session.db.transaction(
|
||||
(t) => grants.create(session, owner: alice, transaction: t),
|
||||
);
|
||||
await session.db.transaction(
|
||||
(t) =>
|
||||
grants.grant(session, space, bob, writes: true, transaction: t),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final each in [alice, bob, space]) {
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||||
parameters: QueryParameters.named({'space': each.uuid}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a member's review, tag, reading and answer keep naming the "
|
||||
"shared space's card, note, path, step and PDF", () async {
|
||||
final payload = ByteData(1);
|
||||
final shared = await asPerson(session, alice, space: space, (t) async {
|
||||
final studyset = await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(payload: payload),
|
||||
transaction: t,
|
||||
);
|
||||
final card = await Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: studyset.id!, position: 'a0'),
|
||||
transaction: t,
|
||||
);
|
||||
final note = await Note.db.insertRow(
|
||||
session,
|
||||
Note(studysetId: studyset.id!, position: 'a0'),
|
||||
transaction: t,
|
||||
);
|
||||
final path = await LearningPath.db.insertRow(
|
||||
session,
|
||||
LearningPath(
|
||||
studysetId: studyset.id!,
|
||||
position: 'a0',
|
||||
payload: payload,
|
||||
),
|
||||
transaction: t,
|
||||
);
|
||||
final step = await LearningPathStep.db.insertRow(
|
||||
session,
|
||||
LearningPathStep(pathId: path.id!, position: 'a0', kind: 'text'),
|
||||
transaction: t,
|
||||
);
|
||||
final pdf = await PdfSource.db.insertRow(
|
||||
session,
|
||||
PdfSource(studysetId: studyset.id!, payload: payload),
|
||||
transaction: t,
|
||||
);
|
||||
return (
|
||||
card: card.id!,
|
||||
note: note.id!,
|
||||
path: path.id!,
|
||||
step: step.id!,
|
||||
pdf: pdf.id!,
|
||||
);
|
||||
});
|
||||
|
||||
final own = await asPerson(session, bob, (t) async {
|
||||
final tag = await Tag.db.insertRow(
|
||||
session,
|
||||
Tag(payload: payload),
|
||||
transaction: t,
|
||||
);
|
||||
final now = DateTime.now().toUtc();
|
||||
return (
|
||||
review: (await ReviewLog.db.insertRow(
|
||||
session,
|
||||
ReviewLog(
|
||||
cardId: shared.card,
|
||||
rating: 3,
|
||||
reviewedAt: now,
|
||||
durationMs: 1000,
|
||||
),
|
||||
transaction: t,
|
||||
)).id!,
|
||||
cardTag: (await CardTag.db.insertRow(
|
||||
session,
|
||||
CardTag(cardId: shared.card, tagId: tag.id!),
|
||||
transaction: t,
|
||||
)).id!,
|
||||
noteTag: (await NoteTag.db.insertRow(
|
||||
session,
|
||||
NoteTag(noteId: shared.note, tagId: tag.id!),
|
||||
transaction: t,
|
||||
)).id!,
|
||||
pathTag: (await LearningPathTag.db.insertRow(
|
||||
session,
|
||||
LearningPathTag(pathId: shared.path, tagId: tag.id!),
|
||||
transaction: t,
|
||||
)).id!,
|
||||
reading: (await PdfReading.db.insertRow(
|
||||
session,
|
||||
PdfReading(
|
||||
pdfSourceId: shared.pdf,
|
||||
readAt: now,
|
||||
kind: 'reached',
|
||||
page: 2,
|
||||
),
|
||||
transaction: t,
|
||||
)).id!,
|
||||
answer: (await LearningPathAnswer.db.insertRow(
|
||||
session,
|
||||
LearningPathAnswer(
|
||||
stepId: shared.step,
|
||||
answeredAt: now,
|
||||
outcome: 'right',
|
||||
),
|
||||
transaction: t,
|
||||
)).id!,
|
||||
);
|
||||
});
|
||||
|
||||
final read = await asPerson(
|
||||
session,
|
||||
bob,
|
||||
(t) async => (
|
||||
review: await ReviewLog.db.findById(
|
||||
session,
|
||||
own.review,
|
||||
transaction: t,
|
||||
),
|
||||
cardTag: await CardTag.db.findById(
|
||||
session,
|
||||
own.cardTag,
|
||||
transaction: t,
|
||||
),
|
||||
noteTag: await NoteTag.db.findById(
|
||||
session,
|
||||
own.noteTag,
|
||||
transaction: t,
|
||||
),
|
||||
pathTag: await LearningPathTag.db.findById(
|
||||
session,
|
||||
own.pathTag,
|
||||
transaction: t,
|
||||
),
|
||||
reading: await PdfReading.db.findById(
|
||||
session,
|
||||
own.reading,
|
||||
transaction: t,
|
||||
),
|
||||
answer: await LearningPathAnswer.db.findById(
|
||||
session,
|
||||
own.answer,
|
||||
transaction: t,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(read.review?.cardId, shared.card);
|
||||
expect(read.cardTag?.cardId, shared.card);
|
||||
expect(read.noteTag?.noteId, shared.note);
|
||||
expect(read.pathTag?.pathId, shared.path);
|
||||
expect(read.reading?.pdfSourceId, shared.pdf);
|
||||
expect(read.answer?.stepId, shared.step);
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
|
|
@ -18,10 +18,16 @@ Database syncDatabaseInterceptor(Session session, Database inner) {
|
|||
/// database wraps it.
|
||||
final _syncDatabases = Expando<OfflineSyncDatabase>();
|
||||
|
||||
/// Writes through [write] as [person] in their personal space, as their
|
||||
/// device would, on [session]'s sync engine database.
|
||||
/// Writes through [write] as [person] in their personal space, or in the
|
||||
/// shared [space] they are a member of, as their device would, on
|
||||
/// [session]'s sync engine database.
|
||||
Future<T> asPerson<T>(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
Future<T> Function(Transaction transaction) write,
|
||||
) => _syncDatabases[session]!.transactionForUser(person, write);
|
||||
Future<T> Function(Transaction transaction) write, {
|
||||
UuidValue? space,
|
||||
}) => _syncDatabases[session]!.transactionForUser(
|
||||
person,
|
||||
write,
|
||||
spaceId: space,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue