mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-08-29 05:21:43 +00:00
* perf: stop shell rebuilds cascading into rows and cut per-flush markdown cost
Scrolling:
- Give the timeline slivers a delegate with a real shouldRebuild keyed on
rowBuilder/entries identity and centerIndex, so ChatPage setState (drag
start, keyboard insets, composer resize, pin transitions) rebuilds only
the shell, not every mounted row.
- Memoize the transcript window, ChatTimelineRenderModel, and rowBuilder by
identity in ChatPage; the fresh window list per build also defeated the
stable-layout cache's identity fast path.
- Replace full MediaQuery.of dependencies with scoped paddingOf/sizeOf.
- Raise the streaming cacheExtent from 120px to 600px; the small extent
evicted rows that then remounted with a synchronous markdown compile.
Markdown:
- Memoize buildMarkdownDisplayParts by compiled-document identity; it
re-derived one sub-document per block with deep compares on every build.
- Add identical() fast paths to CompiledMarkdownDocument and
PreparedMarkdownText equality; compare rope segments instead of
materializing both sides.
- Cache per-message structure-signature fragments by ChatMessage identity
instead of rebuilding O(messages x versions) strings per emission.
- Gate incremental preparation and the reference-definition strip on one
shared line-anchored predicate; a bare "]:" substring no longer forces
full re-preparation of the whole message every flush.
- Make the streaming split's unsafe-line detection fence-aware and cap
freezing at the first raw HTML block instead of keeping the entire
document mutable; reuse the fence-close helper instead of compiling a
RegExp per fenced block.
- Skip the four LaTeX extraction regex passes when content has no $ or
backslash; memoize the error-heuristic content scans in the assistant
footer; gate profiler map allocations to profiling builds; memoize the
code-block line split and render >50k-char code plain.
* fix: detect reference definitions past raw HTML blocks in streaming split
The single-pass unsafe-line scan returned at the first raw HTML line, so a
reference definition appearing after that block was never seen and blocks
containing its links could be frozen with the link unresolved. Scan the whole
region for definitions and only record the first raw HTML offset as the
freeze cap.
* fix: distrust fence state after a raw HTML block starts in unsafe-line scan
Backtick lines inside raw HTML are content, not fences; an odd count left the
fence tracker 'inside' a fence and skipped a later real reference definition,
freezing earlier blocks with unresolved links. After the first raw HTML start,
check every line for a definition regardless of fence state — at worst more
conservative than the whole-document check this replaced.
* fix: long-response truncation at completion and follow-ups never arriving
Truncation: the streamed buffer is never periodically folded into message
state, so /api/chat/completed was built from a stale prefix of the response
and the server's echo of that payload truncated the full content when merged
back (worst on the HTTP/SSE transport, which reached completion without any
terminal flush). Flush the buffer before building the completed payload, and
guard the three unguarded overwrite paths (completed echo, replay-gap
authoritative recovery, cumulative chat:completion content snapshots) so a
strict prefix of already-streamed content is never adopted.
Follow-ups: the server emits chat:message:follow_ups only after
chat:completion {done:true}, and the per-stream socket subscription is
disposed synchronously by that done event, so the streaming handler for
follow-ups was unreachable. The passive conversation subscription is the
surviving delivery path; apply the pushed payload directly to the target
message there instead of relying on a debounced refetch that races the
server's own persistence of the suggestions.
* fix: address review feedback on follow-ups delivery and splitter
- Fall through to the debounced refetch when a pushed follow-ups payload
targets a message id not present in local state.
- Split the follow-ups envelope parser into a private implementation with a
visibleForTesting wrapper, matching file convention.
- Detect reference-definition labels containing escaped brackets in the
streaming splitter's unsafe-line scan.
- Use package:checks in the new follow-ups parser test.
* fix: deferred structured-output projections dropping response content
The structured-output projector defers full re-projections geometrically
(next re-render only at 2x the last projected length) and permanently
disables its plain-append fast path once the text contains a backtick, so
the visible content can trail the logical content by up to half the
response. Two consequences fixed here:
- A plain content delta arriving after a deferred projection appended onto
the short stale render and flipped structuredOutputIsLatest, which also
made the terminal projector finalize bail — permanently dropping the
deferred middle of the response on screen and in the persisted echo.
appendVisibleAssistantChunk now materializes the full projection (new
StructuredOutputStreamingProjector.syncProjectionToLatest) before
switching the content basis to plain appends.
- handleCompletionDone flushed the buffer before building the completed
payload but did not finalize the projector first, so the payload (and the
outlet-filter echo derived from it) could carry the stale short render.
Also fold the un-flushed streaming buffer into state in _cancelMessageStream
(conversation switch / message deletion mid-stream discarded the entire
un-synced tail), skipped during provider dispose where state is untouchable.
* fix: harden remaining content-adoption paths against divergent server bodies
Local and server renders of the same turn wrap reasoning/tool sections in
semantic <details> blocks with different attributes (locally injected
duration=\"0\" vs the server's real duration), so every raw startsWith/length
guard was dead on reasoning turns. Content comparisons now strip rendered
semantic details and compare answer bodies:
- applyServerContent adopts only when the server's answer body is at least
as long as the local one; a snapshot whose raw length grew (long reasoning
block) while the answer shrank no longer replaces a complete local answer.
- _shouldPreserveLocalAssistantContent (all snapshot adoptions including the
reopened-stream reconcile and its buffer rebase) compares stripped bodies.
- The completed-echo, replay-gap recovery, and cumulative content-snapshot
guards compare stripped bodies, and an echo differing only by details
wrappers is a no-op instead of an adoption.
Also:
- Hermes: a terminal/recovered output that is a strict prefix of the
streamed text no longer replaces it (lagging aggregate or incomplete
recovery would truncate delivered content).
- The local turn echo payload now carries output, files, embeds, usage,
sources, statusHistory, followUps, and error: the sync outbox rebuilds the
chat blob from these rows and the server merge replaces message objects
wholesale, so omitted fields were wiped from the server copy on push.
- A stale settled markdown refresh no longer leaves the preparation flag set
when nothing newer is queued (indefinite loading skeleton).
* fix: address review findings on the unsafe-line scan and echo payload
- Remove the backslash overlap in the reference-definition label pattern;
the overlapping alternation could backtrack exponentially on long
malformed labels, on the UI isolate.
- Track <details> bodies opaquely (open/close depth) in the unsafe-line
scan, mirroring the block scanner: an unmatched backtick line inside a
details body no longer opens a phantom outer fence that hid later
reference definitions.
- Persist codeExecutions in the local turn-echo payload alongside the other
durable server-shape fields.
* fix: live-tail freeze/duplication, follow-up persistence, scroll-down jank
Live tail (regression from the projection-sync fix): syncProjectionToLatest
re-armed the projector's geometric backoff to 2x the full content length
while the plain-append transition disabled the append path, so subsequent
output snapshots all deferred and the visible tail froze for the rest of the
turn. The sync now preserves the backoff threshold. Same-frame handling now
also matches the upstream client contract (Chat.svelte): a frame carrying an
output snapshot supersedes its own choices delta / content field — Conduit
applied the delta first and the snapshot second, duplicating text the
snapshot already contained.
Follow-ups: pushed suggestions were applied to in-memory state only; the
turn echo had been persisted at completion before the event fired, so a
conversation switch reloaded the message without them. The passive handler
now re-persists the message row after applying the payload.
Scroll-down jank: three down-only per-frame costs while returning toward the
bottom — the bottom-anchor recompute re-armed a full layout-maintenance pass
(row-rect snapshot + pin geometry) on every metrics tick once anchored (now
only on anchored-state transitions); pin geometry re-measured three global
rects per frame mid-scroll (now skipped until motion settles once reported);
and UserScrollNotification(idle) was treated as drag end even though Flutter
publishes it at ballistic START, running mode flips and jump-to-latest
arming mid-fling (drag end now comes from ScrollEndNotification, which fires
at actual rest).
* fix: streamed word drops and quote/entity rendering defects
Quotes/entities:
- Answer text no longer escapes double quotes (element-mode escaping; tags
are still neutralized). " escaped into a context the markdown decoder
skips — immediately after a backquote, or inside code via the streaming
fragment path — surfaced literally on screen. Attribute-mode escaping
stays for <details> attribute values.
- The plain streaming accumulator was seeded/refilled from the RENDERED
(already-escaped) body on reopen/reasoning sync; the next full render
escaped it a second time (&quot; decoding once back to a visible
"). Plain-content derivation now strips semantic details AND
unescapes entities.
- Clipboard copy and TTS decode presentation entities back to literal text.
API replay deliberately does not (it cannot distinguish model-typed
entities from presentation escaping, and the direct bridge has trusted
raw replay for fidelity).
Missing words:
- Whitespace-only deltas were discarded on one transport (trim() guard),
gluing words together and losing paragraph breaks.
- Whitespace-only semantic text blocks were dropped from full renders and
the streaming append delta never re-emits the swallowed prefix — the
blank line between a reasoning section and the answer vanished.
- Once a backtick/tilde disabled the projector's append path for the turn,
geometric backoff left the visible tail up to 50% behind until
completion; renders now use a bounded additive step when appends are
unavailable.
- The SSE parser now mutes same-frame deltas only when the output snapshot
parses into renderable blocks, matching the socket path — an output whose
items all parse away no longer mutes the delta while rendering nothing.
* fix: address review feedback on plain-content whitespace and coverage
- The semantic-details strip in plain-content derivation now consumes only
the wrapper's own trailing newline instead of \s* plus trim, preserving
answer whitespace such as a leading indented code block's indentation.
- Regression tests: the additive re-projection schedule for code-bearing
streams, and a non-renderable output snapshot not muting the same-frame
delta.
* fix: match the details parser's exact close token in streaming scans
The streaming details trackers accepted '</details >' as a close while the
details parser recognizes only the literal '</details>'. A close lookalike
inside a streamed details body exited details tracking early, let a body
backtick open a phantom outer fence, and hid a valid reference definition
after the real close — freezing an earlier reference-style link unresolved.
The preparation engine's checkpoint scanner had the same loose pattern and
could split prepared content mid-block. Both now match the parser exactly,
with a regression test verified to fail against the loose pattern.
* fix: reconcile deferred snapshots at terminal finalize and audit lengths
finalizeStructuredOutputProjection bailed whenever a plain chunk was the
last content-affecting operation; if output snapshots after that chunk had
deferred under the re-projection threshold, the deferred tail was dropped
from the final content. The finalize now adopts the terminal render unless
the accumulated visible text is longer (matching upstream's output-replaces-
content contract while preserving delta-only hybrid streams).
The done-signal log now records message/rendered/plain lengths so a
truncation report can be pinpointed from a single log line: message shorter
than rendered points at a lost flush; rendered shorter than plain points at
an unrepaired deferred projection.
* fix: reconcile terminal projection when plain chunks ended the stream
finalizeStructuredOutputProjection bailed whenever a plain chunk was the
last content-affecting operation. The terminal snapshot render is
authoritative upstream (output replaces content wholesale in Chat.svelte);
adopt it unless the accumulated visible text is longer, preserving
delta-only hybrid streams.
The done-signal log now records message/rendered/plain lengths so a
truncation report can be pinpointed from one log line.
* fix: render output[] in poll recovery when persisted content is empty
OWUI 0.11 never persists a flat content string for a normal completion —
the durable body is the output[] item array, so a reasoning turn's raw
content is ''. pollServerForMessage ignored output[] entirely: whenever the
live socket missed the final frames (buffer caps on long reasoning
streams, reconnects), every recovery path polled the server, extracted an
empty string, adopted nothing, saw done=true, and finished the turn with
the partial local text — permanent tail truncation, reasoning models only.
Recovery now renders output[] with the same renderer the snapshot parser
uses when flat content is empty. Regression test verified to fail without
the fix.
* revert: drop the speculative terminal-finalize reconciliation
The longer-wins adoption added in 349bb708 was based on a wrong premise:
structuredOutputIsLatest is set back to true by every output snapshot,
applied or deferred, so the bail it targeted only holds when a plain chunk
was the very last content-affecting operation — where the accumulated
visible text is already the right terminal value (syncProjectionToLatest
keeps it complete). Its regression test passes with and without the change,
and adopting the terminal snapshot in that narrow case could drop
equal-length delta-only text. Restore the original bail, with a comment
explaining why it is correct.
* fix: keep the settle transition extent-neutral
Measured the streaming-to-settled swap (typing-indicator footer 16+28+4 vs
in-card action row 16+32): it is extent-neutral by design for plain,
reasoning, and completed-status turns. The residual jump came from status
rows whose updates never reported done — the settle filter emptied the list
and dropped the entire row (~30px shift on the bottom-anchored viewport),
also losing the only description of what the turn did. The last update now
stays visible (without the pending spinner) when filtering would otherwise
empty the row.
A new widget test measures both sides of the swap and asserts
extent-neutrality for the plain, scaled, versioned, pending-status, and
reasoning cases. Settle-only additions (sources row, a text-scaled version
chip) legitimately grow the card; an AnimatedSize wrapper was tried and
reverted — RenderAnimatedSize asserts when mutated during this tree's
layout pass.
* fix: count inline nested details opens like the parser; tighten projector test
The streaming split scanner counted details opens with the line-anchored
pattern, but the details parser counts complete opening tags anywhere in a
line. A nested inline open inside a streamed details body made the first
closing tag exit scanner details mode one level early, so a body backtick
poisoned fence state and hid later reference definitions from the fallback
scan. Depth counting now uses the parser's complete-tag pattern (still
crediting a line-leading partial tag so unterminated opens stay mutable);
block entry detection stays line-anchored, matching the parser.
Also assert the exact terminal projection content in the additive-schedule
projector test instead of suffix/length checks that a truncated result
could satisfy.
* fix: address thermo audit and outside-diff review findings
- Persist local-echo and direct/Hermes message payloads in the server
shape: citation-shaped sources, snake_case code_executions via the
shared converters, and null-stripped files. Client-model shapes broke
the OpenWebUI web client's code-execution panels and citations for any
chat synced from Conduit. The converters move from api_service.dart to
core/utils/openwebui_message_payload.dart, and localEchoRowForMessage
is now top-level with a test pinning payload completeness and shape.
- Bail out of syncProjectionToLatest when the projector never owned the
visible basis (observe-only path): materializing the snapshot there
shrank visible content that was deliberately kept as a superset.
- Copy user messages verbatim: the assistant clipboard sanitizer decoded
entities the user actually typed and stripped definition-shaped lines.
- Route replaceVisibleAssistantContent's fallback through
initialPlainStreamingContent so cumulative content frames cannot leak
details wrappers into the plain accumulator and re-escape later.
- Stop treating definition-shaped lines inside a details body as
reference definitions in the unsafe-line scan: the parser lifts details
bodies into body_markdown compiled as its own document, so they cannot
couple frozen and mutable segments (CodeRabbit outside-diff finding).
- Deduplicate the semantic-details strip and stale-prefix guards into
core/utils/semantic_details.dart; five divergent regex copies and five
hand-rolled prefix checks now share one definition.
- Extract the follow-ups socket-event parser to
features/chat/utils/follow_ups_socket_event.dart and drop the
ForTesting shims.
- Drop the dead streaming parameter on the chat cache-extent helper,
hoist hot-loop regexes in the raw-boundary checkpoint scan, re-resolve
the message index before the passive follow-ups re-persist.
* style: complete the dart format migration repo-wide
The Dart 3.13 formatter had been applied piecemeal to files this branch
touched, leaving the repo half-migrated and smearing mechanical churn
through functional diffs. One-shot migration of the remaining 214 files;
no semantic changes.
* fix: count details depth on the raw line, not the dedented candidate
The details parser counts open/close tags on the raw line regardless of
indentation inside a block, but both scanner depth-tracking sites routed
counting through the block-starter candidate, which is null for lines
indented four or more columns. An indented nested open was silently
dropped, so the first close exited scanner details mode a level early
and a body backtick opened a phantom fence that hid later reference
definitions. Depth now counts on the raw line (the partial-open carve-out
dedents before its anchored check so an indented incomplete tag still
holds the block open).
* fix: count only complete details tags; keep incomplete entry tags mutable
Crediting a partial line-leading <details as an open (added so an
unterminated streamed entry tag would not freeze) left scanner depth
permanently stale when a body contained a literal <details that never
completed: the parser counts only complete tags, so it closed the block
while the scanner stayed inside it and swallowed the document-level
reference definition after the block. Depth counting is now parser-exact
(complete tags only), and the unterminated-entry case is handled
structurally: an entry line whose tag has no closing > keeps the tail
mutable instead of faking a depth of one.
1258 lines
36 KiB
Dart
1258 lines
36 KiB
Dart
/// CDT-RFC-001 Phase 5 note sync integration (through the real Drift DB):
|
|
/// nanosecond-watermark pull, the DB-level field-LWW conflict copy (a concurrent
|
|
/// data edit yields TWO surviving notes), and transactional *WithOutbox writes.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:checks/checks.dart';
|
|
import 'package:conduit/core/database/app_database.dart';
|
|
import 'package:conduit/core/database/daos/outbox_dao.dart';
|
|
import 'package:conduit/core/database/mappers/note_mapper.dart';
|
|
import 'package:conduit/core/sync/chat_locks.dart';
|
|
import 'package:conduit/core/sync/id_remapper.dart';
|
|
import 'package:conduit/core/sync/note_adapter.dart';
|
|
import 'package:conduit/core/sync/note_conflict.dart';
|
|
import 'package:conduit/core/sync/note_sync.dart';
|
|
import 'package:conduit/core/sync/sync_api_client.dart';
|
|
import 'package:conduit/core/sync/sync_entity_adapter.dart';
|
|
import 'package:drift/drift.dart' show Value;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../../support/fake_open_webui_server.dart';
|
|
import '../../support/fake_sync_api_client.dart';
|
|
|
|
const int kT1 = 1718000000000000000; // ns
|
|
const int kT2 = kT1 + 60 * 1000 * 1000 * 1000; // +60s in ns, past the overlap
|
|
|
|
class _MalformedFirstPageNoteClient extends FakeSyncApiClient {
|
|
_MalformedFirstPageNoteClient(super.server);
|
|
|
|
@override
|
|
Future<(List<Map<String, dynamic>>, bool)> getNoteListRaw({int? page}) async {
|
|
final (items, enabled) = await super.getNoteListRaw(page: page);
|
|
if (page != 1 || items.length < FakeOpenWebUiServer.notePageSize) {
|
|
return (items, enabled);
|
|
}
|
|
final mutated = [for (final item in items) Map<String, dynamic>.from(item)];
|
|
mutated.first.remove('updated_at');
|
|
return (mutated, enabled);
|
|
}
|
|
}
|
|
|
|
class _AllMalformedFirstPageNoteClient extends FakeSyncApiClient {
|
|
_AllMalformedFirstPageNoteClient(super.server);
|
|
|
|
@override
|
|
Future<(List<Map<String, dynamic>>, bool)> getNoteListRaw({int? page}) async {
|
|
if (page == 1) {
|
|
noteListRequests++;
|
|
noteListPages.add(page);
|
|
return (
|
|
[
|
|
for (var i = 0; i < FakeOpenWebUiServer.notePageSize; i++)
|
|
<String, dynamic>{'id': 'malformed-$i'},
|
|
],
|
|
true,
|
|
);
|
|
}
|
|
return super.getNoteListRaw(page: page);
|
|
}
|
|
}
|
|
|
|
class _BlockingCreateNoteClient extends FakeSyncApiClient {
|
|
_BlockingCreateNoteClient(super.server);
|
|
|
|
final createStarted = Completer<void>();
|
|
final releaseCreate = Completer<void>();
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> createNote({
|
|
required String title,
|
|
required Map<String, dynamic> data,
|
|
Map<String, dynamic>? meta,
|
|
}) async {
|
|
createStarted.complete();
|
|
await releaseCreate.future;
|
|
return super.createNote(title: title, data: data, meta: meta);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
late FakeOpenWebUiServer server;
|
|
late FakeSyncApiClient client;
|
|
late AppDatabase db;
|
|
late NoteLocks locks;
|
|
late IdRemapper syncRemapper;
|
|
|
|
setUp(() {
|
|
server = FakeOpenWebUiServer();
|
|
client = FakeSyncApiClient(server);
|
|
db = AppDatabase(NativeDatabase.memory());
|
|
locks = NoteLocks();
|
|
syncRemapper = IdRemapper(db);
|
|
});
|
|
tearDown(() async {
|
|
await syncRemapper.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
/// Drives the LIVE production note-pull path: the generic [runPullFor] driver
|
|
/// over a [NoteAdapter] — exactly what the sync engine wires (D-11, R-09).
|
|
Future<AdapterPullResult> pull() {
|
|
final adapter = NoteAdapter(
|
|
pull: NotePullSync(
|
|
client: client,
|
|
db: db,
|
|
locks: locks,
|
|
remapper: syncRemapper,
|
|
),
|
|
push: NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: syncRemapper,
|
|
),
|
|
);
|
|
return runPullFor(adapter, db: db);
|
|
}
|
|
|
|
Future<List<NoteRow>> allNotes() => db.select(db.notes).get();
|
|
|
|
test('pull populates the DB and advances the NANOSECOND watermark', () async {
|
|
server.seedNote(
|
|
id: 'n1',
|
|
title: 'First',
|
|
data: {
|
|
'content': {'md': 'one'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
);
|
|
server.seedNote(
|
|
id: 'n2',
|
|
title: 'Second',
|
|
data: {
|
|
'content': {'md': 'two'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT2,
|
|
);
|
|
|
|
final result = await pull();
|
|
check(result.success).isTrue();
|
|
check(client.noteListRequests).equals(1);
|
|
check(client.noteFetchStarts).unorderedEquals(['n1', 'n2']);
|
|
|
|
final notes = await allNotes();
|
|
check(notes.map((n) => n.id).toList()).unorderedEquals(['n1', 'n2']);
|
|
|
|
// The watermark is the MAX server note timestamp — and it is nanosecond
|
|
// scale (≥ 1e18), never seconds.
|
|
final wm = await db.syncMetaDao.getNotesPullWatermark();
|
|
check(wm).equals(kT2);
|
|
check(wm > 1000000000000000000).isTrue();
|
|
// The chat watermark is untouched (separate domain, R-09).
|
|
check(await db.syncMetaDao.getPullWatermark()).equals(0);
|
|
});
|
|
|
|
test('pull paginates note lists past the first server page', () async {
|
|
for (var i = 0; i < FakeOpenWebUiServer.notePageSize + 1; i++) {
|
|
server.seedNote(
|
|
id: 'n-${i.toString().padLeft(2, '0')}',
|
|
title: 'Note $i',
|
|
data: {
|
|
'content': {'md': 'body $i'},
|
|
},
|
|
createdAt: kT1 + i,
|
|
updatedAt: kT1 + i,
|
|
);
|
|
}
|
|
|
|
final result = await pull();
|
|
|
|
check(result.success).isTrue();
|
|
check(result.changed).equals(FakeOpenWebUiServer.notePageSize + 1);
|
|
check(client.noteListPages).deepEquals([1, 2]);
|
|
check(await allNotes()).length.equals(FakeOpenWebUiServer.notePageSize + 1);
|
|
});
|
|
|
|
test(
|
|
'malformed note list items are skipped without shortening the page',
|
|
() async {
|
|
client = _MalformedFirstPageNoteClient(server);
|
|
for (var i = 0; i < FakeOpenWebUiServer.notePageSize + 1; i++) {
|
|
server.seedNote(
|
|
id: 'n-${i.toString().padLeft(2, '0')}',
|
|
title: 'Note $i',
|
|
data: {
|
|
'content': {'md': 'body $i'},
|
|
},
|
|
createdAt: kT1 + i,
|
|
updatedAt: kT1 + i,
|
|
);
|
|
}
|
|
|
|
final result = await pull();
|
|
|
|
check(result.success).isTrue();
|
|
check(result.changed).equals(FakeOpenWebUiServer.notePageSize);
|
|
check(client.noteListPages).deepEquals([1, 2]);
|
|
check(client.noteFetchStarts).length
|
|
.equals(FakeOpenWebUiServer.notePageSize);
|
|
final notes = await allNotes();
|
|
check(notes).length.equals(FakeOpenWebUiServer.notePageSize);
|
|
check(notes.map((note) => note.id)).not((ids) => ids.contains('n-60'));
|
|
check(await db.syncMetaDao.getNotesPullWatermark()).equals(kT1 + 59);
|
|
},
|
|
);
|
|
|
|
test('all-skip full note list page stops without pagination loop', () async {
|
|
client = _AllMalformedFirstPageNoteClient(server);
|
|
for (var i = 0; i < FakeOpenWebUiServer.notePageSize + 1; i++) {
|
|
server.seedNote(
|
|
id: 'n-${i.toString().padLeft(2, '0')}',
|
|
title: 'Note $i',
|
|
data: {
|
|
'content': {'md': 'body $i'},
|
|
},
|
|
createdAt: kT1 + i,
|
|
updatedAt: kT1 + i,
|
|
);
|
|
}
|
|
|
|
final result = await pull();
|
|
|
|
check(result.success).isTrue();
|
|
check(result.changed).equals(0);
|
|
check(client.noteListPages).deepEquals([1]);
|
|
check(client.noteFetchStarts).isEmpty();
|
|
final notes = await allNotes();
|
|
check(notes).isEmpty();
|
|
check(await db.syncMetaDao.getNotesPullWatermark()).equals(0);
|
|
});
|
|
|
|
test(
|
|
'pull full-fetches note bodies instead of trusting truncated list data',
|
|
() async {
|
|
final longBody = List.filled(1200, 'x').join();
|
|
server.seedNote(
|
|
id: 'long-note',
|
|
title: 'Long',
|
|
data: {
|
|
'content': {'md': longBody},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
);
|
|
|
|
final result = await pull();
|
|
check(result.success).isTrue();
|
|
check(client.noteListRequests).equals(1);
|
|
check(client.noteFetchStarts).deepEquals(['long-note']);
|
|
|
|
final row = await db.notesDao.getNote('long-note');
|
|
check(row).isNotNull();
|
|
final data = jsonDecode(row!.data) as Map<String, dynamic>;
|
|
final content = data['content'] as Map<String, dynamic>;
|
|
check(content['md']).equals(longBody);
|
|
},
|
|
);
|
|
|
|
test('CONFLICT COPY: a concurrent data edit yields two surviving notes', () async {
|
|
// 1. Sync n1.
|
|
server.seedNote(
|
|
id: 'n1',
|
|
title: 'Doc',
|
|
data: {
|
|
'content': {'md': 'server v1'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
);
|
|
await pull();
|
|
check((await allNotes()).length).equals(1);
|
|
|
|
// 2. Local data edit (marks dirtyData), while the note is offline.
|
|
await locks.runExclusive('n1', () async {
|
|
await db.notesDao.updateNoteWithOutbox(
|
|
'n1',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'my LOCAL edit'},
|
|
}),
|
|
),
|
|
localUpdatedAtNs: kT1 + 1,
|
|
enqueue: true,
|
|
);
|
|
});
|
|
|
|
// 3. The server's copy of n1 also advanced (someone else edited the body).
|
|
server.seedNote(
|
|
id: 'n1',
|
|
title: 'Doc',
|
|
data: {
|
|
'content': {'md': 'server v2'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT2,
|
|
);
|
|
|
|
// 4. Pull → the field-LWW merge must spawn a conflict copy (D-11).
|
|
await pull();
|
|
|
|
final notes = await allNotes();
|
|
check(notes.length).equals(2); // canonical + conflict copy, none lost
|
|
|
|
final canonical = notes.firstWhere((n) => n.id == 'n1');
|
|
final copy = notes.firstWhere((n) => n.id != 'n1');
|
|
|
|
// Canonical adopted the server data and is clean on the data axis.
|
|
check(canonical.data).contains('server v2');
|
|
check(canonical.dirtyData).isFalse();
|
|
// The conflict copy preserved the LOCAL edit (no silent loss) and is a
|
|
// fresh local: note that will be pushed as a new note.
|
|
check(copy.data).contains('my LOCAL edit');
|
|
check(copy.id.startsWith('local:')).isTrue();
|
|
});
|
|
|
|
test('field-LWW merge does not resurrect a clean tombstone', () async {
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: 'n1',
|
|
title: 'Hidden local',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'old'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
serverUpdatedAt: const Value(kT1),
|
|
deleted: const Value(true),
|
|
),
|
|
);
|
|
|
|
await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': 'n1',
|
|
'title': 'Visible server',
|
|
'data': {
|
|
'content': {'md': 'new'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT2,
|
|
},
|
|
);
|
|
|
|
final row = await db.notesDao.getNote('n1');
|
|
check(row).isNotNull();
|
|
check(row!.deleted).isTrue();
|
|
check(row.title).equals('Hidden local');
|
|
check(row.data).contains('old');
|
|
});
|
|
|
|
test('field-LWW does not spawn another copy from a conflict copy', () async {
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: 'copy-1',
|
|
title: 'Copy',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'local copy edit'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
serverUpdatedAt: const Value(kT1),
|
|
dirtyData: const Value(true),
|
|
isConflictCopy: const Value(true),
|
|
conflictOf: const Value('n1'),
|
|
),
|
|
);
|
|
|
|
final result = await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': 'copy-1',
|
|
'title': 'Copy server',
|
|
'data': {
|
|
'content': {'md': 'remote copy edit'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT2,
|
|
},
|
|
);
|
|
|
|
check(result.mustPush).isTrue();
|
|
final notes = await allNotes();
|
|
check(notes).length.equals(1);
|
|
final row = notes.single;
|
|
check(row.id).equals('copy-1');
|
|
check(row.isConflictCopy).isTrue();
|
|
check(row.dirtyData).isTrue();
|
|
check(row.updatedAt).equals(kT2);
|
|
check(row.serverUpdatedAt).equals(kT2);
|
|
check(row.data).contains('local copy edit');
|
|
final pending = await db.outboxDao.pendingForChat('copy-1');
|
|
check(pending.map((op) => op.kind))
|
|
.deepEquals([OutboxKind.noteUpdate.name]);
|
|
});
|
|
|
|
test('fast-forward preserves conflict-copy metadata', () async {
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: 'copy-clean',
|
|
title: 'Copy',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'clean copy'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
serverUpdatedAt: const Value(kT1),
|
|
isConflictCopy: const Value(true),
|
|
conflictOf: const Value('n1'),
|
|
),
|
|
);
|
|
|
|
final result = await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': 'copy-clean',
|
|
'title': 'Copy echoed',
|
|
'data': {
|
|
'content': {'md': 'server echo'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT2,
|
|
},
|
|
);
|
|
|
|
check(result.kind).equals(NoteMergeKind.fastForward);
|
|
final row = await db.notesDao.getNote('copy-clean');
|
|
check(row).isNotNull();
|
|
check(row!.isConflictCopy).isTrue();
|
|
check(row.conflictOf).equals('n1');
|
|
check(row.title).equals('Copy echoed');
|
|
check(row.data).contains('server echo');
|
|
});
|
|
|
|
test(
|
|
'field-LWW does not enqueue duplicate update behind in-flight update',
|
|
() async {
|
|
const noteId = 'copy-inflight';
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: noteId,
|
|
title: 'Copy',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'local copy edit'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
serverUpdatedAt: const Value(kT1),
|
|
dirtyData: const Value(true),
|
|
isConflictCopy: const Value(true),
|
|
conflictOf: const Value('n1'),
|
|
),
|
|
);
|
|
await db.transaction(() {
|
|
return db.outboxDao.enqueue(
|
|
kind: OutboxKind.noteUpdate,
|
|
chatId: noteId,
|
|
payload: const {'title': 'Copy'},
|
|
);
|
|
});
|
|
final claimed = await db.outboxDao.claimNextRunnable(
|
|
nowEpochSeconds: 100,
|
|
busyChatIds: const {},
|
|
);
|
|
check(claimed).isNotNull();
|
|
check(claimed!.kind).equals(OutboxKind.noteUpdate.name);
|
|
check(claimed.status).equals(OutboxStatus.inFlight);
|
|
|
|
final result = await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': noteId,
|
|
'title': 'Copy server',
|
|
'data': {
|
|
'content': {'md': 'remote copy edit'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT2,
|
|
},
|
|
);
|
|
|
|
check(result.mustPush).isTrue();
|
|
check(await db.outboxDao.pendingForChat(noteId)).isEmpty();
|
|
final active = await db.outboxDao.activeForChat(noteId);
|
|
check(active).length.equals(1);
|
|
check(active.single.status).equals(OutboxStatus.inFlight);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'tombstoneWithOutbox drops a local note when create/delete annihilate',
|
|
() async {
|
|
const localId = 'local:drop-note';
|
|
|
|
await locks.runExclusive(localId, () async {
|
|
await db.notesDao.insertLocalNoteWithCreateOp(
|
|
note: NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'draft'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
check(await db.notesDao.getNote(localId)).isNotNull();
|
|
check((await db.outboxDao.pendingForChat(localId)).map((op) => op.kind))
|
|
.deepEquals(['noteCreate']);
|
|
await db.notesDao.tombstoneWithOutbox(localId);
|
|
});
|
|
|
|
check(await db.notesDao.getNote(localId)).isNull();
|
|
check(await db.outboxDao.pendingForChat(localId)).isEmpty();
|
|
},
|
|
);
|
|
|
|
test('dropLocalNote removes local note remap metadata', () async {
|
|
const localId = 'local:drop-remap';
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'draft'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
await db.syncMetaDao.setNoteRemapTarget(localId, 'server-drop-remap');
|
|
|
|
await db.notesDao.dropLocalNote(localId);
|
|
|
|
check(await db.syncMetaDao.getNoteRemapTarget(localId)).isNull();
|
|
});
|
|
|
|
test('tombstoneWithOutbox clears dirty axes on tombstoned rows', () async {
|
|
server.seedNote(
|
|
id: 'n-delete',
|
|
title: 'Server note',
|
|
data: {
|
|
'content': {'md': 'body'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
);
|
|
await pull();
|
|
|
|
await locks.runExclusive('n-delete', () {
|
|
return db.notesDao.tombstoneWithOutbox('n-delete');
|
|
});
|
|
|
|
final row = await db.notesDao.getNote('n-delete');
|
|
check(row).isNotNull();
|
|
check(row!.deleted).isTrue();
|
|
check(row.dirtyTitle).isFalse();
|
|
check(row.dirtyData).isFalse();
|
|
check(row.dirtyPinned).isFalse();
|
|
|
|
final pending = await db.outboxDao.pendingForChat('n-delete');
|
|
check(pending.map((op) => op.kind))
|
|
.deepEquals([OutboxKind.noteDelete.name]);
|
|
});
|
|
|
|
test(
|
|
'pushNoteDelete purges parked outbox ops for the deleted note',
|
|
() async {
|
|
server.seedNote(
|
|
id: 'n-delete-parked',
|
|
title: 'Server note',
|
|
data: {
|
|
'content': {'md': 'body'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
);
|
|
await pull();
|
|
await db
|
|
.into(db.outboxOps)
|
|
.insert(
|
|
OutboxOpsCompanion.insert(
|
|
kind: OutboxKind.noteUpdate.name,
|
|
chatId: const Value('n-delete-parked'),
|
|
status: const Value(OutboxStatus.failed),
|
|
attempts: const Value(5),
|
|
lastError: const Value('parked update'),
|
|
),
|
|
);
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: syncRemapper,
|
|
);
|
|
|
|
await push.pushNoteDelete('n-delete-parked');
|
|
|
|
check(await db.notesDao.getNote('n-delete-parked')).isNull();
|
|
final remaining = await (db.select(
|
|
db.outboxOps,
|
|
)..where((t) => t.chatId.equals('n-delete-parked'))).get();
|
|
check(remaining).isEmpty();
|
|
},
|
|
);
|
|
|
|
test('purgeReconciledNote removes server note remap metadata', () async {
|
|
const localId = 'local:purge-remap';
|
|
const serverId = 'server-purge-remap';
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: serverId,
|
|
title: 'Server note',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'body'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
await db.syncMetaDao.setNoteRemapTarget(localId, serverId);
|
|
|
|
await db.notesDao.purgeReconciledNote(serverId);
|
|
|
|
check(await db.syncMetaDao.getNoteRemapTarget(localId)).isNull();
|
|
});
|
|
|
|
test('resolveNoteRemapTarget follows a local→server id remap', () async {
|
|
const localId = 'local:read-remap';
|
|
const serverId = 'server-read-remap';
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: serverId,
|
|
title: 'Server note',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'body'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
await db.syncMetaDao.setNoteRemapTarget(localId, serverId);
|
|
|
|
// The stale local id resolves to the server id it was remapped to, so a UI
|
|
// mutation locks/writes/reads on the row the DAO actually mutates (and a
|
|
// server id with no remap resolves to itself).
|
|
check(await db.notesDao.resolveNoteRemapTarget(localId)).equals(serverId);
|
|
check(await db.notesDao.resolveNoteRemapTarget(serverId)).equals(serverId);
|
|
check(await db.notesDao.getNote(localId)).isNull();
|
|
});
|
|
|
|
test(
|
|
'pull crash-heals a pending noteCreate with a matching server note',
|
|
() async {
|
|
const localId = 'local:n-crash';
|
|
const serverId = 'server-n-crash';
|
|
|
|
await locks.runExclusive(localId, () async {
|
|
await db.notesDao.insertLocalNoteWithCreateOp(
|
|
note: NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'body'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
});
|
|
|
|
final localOps = await db.outboxDao.pendingForChat(localId);
|
|
check(localOps).length.equals(1);
|
|
check(localOps.single.kind).equals(OutboxKind.noteCreate.name);
|
|
check(localOps.single.contentHash).isNotNull();
|
|
|
|
server.seedNote(
|
|
id: serverId,
|
|
title: 'Draft',
|
|
data: {
|
|
'content': {'md': 'body'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT2,
|
|
);
|
|
|
|
final result = await pull();
|
|
|
|
check(result.success).isTrue();
|
|
check(await db.notesDao.getNote(localId)).isNull();
|
|
final healed = await db.notesDao.getNote(serverId);
|
|
check(healed).isNotNull();
|
|
check(healed!.title).equals('Draft');
|
|
check(healed.dirtyTitle).isFalse();
|
|
check(healed.dirtyData).isFalse();
|
|
check(healed.serverUpdatedAt).equals(kT2);
|
|
check(await db.outboxDao.pendingForChat(localId)).isEmpty();
|
|
check(await db.outboxDao.pendingForChat(serverId)).isEmpty();
|
|
check(client.createNoteCalls).equals(0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'noteUpdate coalesced into noteCreate refreshes the create hash',
|
|
() async {
|
|
const localId = 'local:n-hash';
|
|
|
|
await locks.runExclusive(localId, () async {
|
|
await db.notesDao.insertLocalNoteWithCreateOp(
|
|
note: NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'before'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
});
|
|
final originalHash = (await db.outboxDao.pendingForChat(localId))
|
|
.single
|
|
.contentHash;
|
|
|
|
await locks.runExclusive(localId, () async {
|
|
await db.notesDao.updateNoteWithOutbox(
|
|
localId,
|
|
title: const Value('Edited'),
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'after'},
|
|
}),
|
|
),
|
|
localUpdatedAtNs: kT1 + 1,
|
|
enqueue: true,
|
|
);
|
|
});
|
|
|
|
final pending = await db.outboxDao.pendingForChat(localId);
|
|
check(pending).length.equals(1);
|
|
check(pending.single.kind).equals(OutboxKind.noteCreate.name);
|
|
final row = await db.notesDao.getNote(localId);
|
|
final expected = noteCreateContentHashFromRow(row!);
|
|
check(pending.single.contentHash).equals(expected);
|
|
check(pending.single.contentHash == originalHash).isFalse();
|
|
},
|
|
);
|
|
|
|
test('pushNotePin PROBES live state and toggles only on a real delta '
|
|
'(no blind toggle-first flip)', () async {
|
|
// Note exists locally + on the server; server pin = false.
|
|
server.seedNote(
|
|
id: 'p1',
|
|
title: 'P',
|
|
data: {
|
|
'content': {'md': 'x'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
pinned: false,
|
|
);
|
|
await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': 'p1',
|
|
'title': 'P',
|
|
'data': {
|
|
'content': {'md': 'x'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT1,
|
|
},
|
|
);
|
|
final remapper = IdRemapper(db);
|
|
addTearDown(remapper.dispose);
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: remapper,
|
|
);
|
|
|
|
// desired == live (false) → NO toggle (the old toggle-first code would have
|
|
// flipped the server and flipped back, leaving a transient wrong state).
|
|
await push.pushNotePin('p1', desired: false);
|
|
check(client.togglePinNoteCalls).equals(0);
|
|
|
|
// desired != live → exactly one toggle.
|
|
await push.pushNotePin('p1', desired: true);
|
|
check(client.togglePinNoteCalls).equals(1);
|
|
});
|
|
|
|
test('NoteAdapter rejects malformed notePin payloads', () async {
|
|
final adapter = NoteAdapter(
|
|
pull: NotePullSync(
|
|
client: client,
|
|
db: db,
|
|
locks: locks,
|
|
remapper: syncRemapper,
|
|
),
|
|
push: NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: syncRemapper,
|
|
),
|
|
);
|
|
await db
|
|
.into(db.outboxOps)
|
|
.insert(
|
|
OutboxOpsCompanion.insert(
|
|
kind: OutboxKind.notePin.name,
|
|
chatId: const Value('p-bad'),
|
|
payload: const Value('{}'),
|
|
),
|
|
);
|
|
final op = (await db.outboxDao.pendingForChat('p-bad')).single;
|
|
|
|
await check(adapter.pushOp(op)).throws<SyncTerminalException>();
|
|
check(client.togglePinNoteCalls).equals(0);
|
|
});
|
|
|
|
test(
|
|
'pushNotePin stores live state when post-toggle confirmation mismatches',
|
|
() async {
|
|
server.seedNote(
|
|
id: 'p2',
|
|
title: 'P',
|
|
data: {
|
|
'content': {'md': 'x'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
pinned: false,
|
|
);
|
|
await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': 'p2',
|
|
'title': 'P',
|
|
'data': {
|
|
'content': {'md': 'x'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT1,
|
|
},
|
|
);
|
|
await locks.runExclusive('p2', () {
|
|
return db.notesDao.pinNoteWithOutbox('p2', desiredPinned: true);
|
|
});
|
|
client = _ConcurrentPinFlipClient(server);
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: syncRemapper,
|
|
);
|
|
|
|
await push.pushNotePin('p2', desired: true);
|
|
|
|
check(client.togglePinNoteCalls).equals(1);
|
|
check(server.getNoteById('p2')!['is_pinned']).equals(false);
|
|
final row = await db.notesDao.getNote('p2');
|
|
check(row!.isPinned).isFalse();
|
|
check(row.dirtyPinned).isFalse();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'pushNotePin clears dirtyPinned when post-toggle confirmation 404s',
|
|
() async {
|
|
server.seedNote(
|
|
id: 'p-confirm-404',
|
|
title: 'P',
|
|
data: {
|
|
'content': {'md': 'x'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
pinned: false,
|
|
);
|
|
await db.notesDao.mergeServerNote(
|
|
serverRaw: <String, dynamic>{
|
|
'id': 'p-confirm-404',
|
|
'title': 'P',
|
|
'data': {
|
|
'content': {'md': 'x'},
|
|
},
|
|
'meta': <String, dynamic>{},
|
|
'is_pinned': false,
|
|
'created_at': kT1,
|
|
'updated_at': kT1,
|
|
},
|
|
);
|
|
await locks.runExclusive('p-confirm-404', () {
|
|
return db.notesDao.pinNoteWithOutbox(
|
|
'p-confirm-404',
|
|
desiredPinned: true,
|
|
);
|
|
});
|
|
|
|
client = _PinConfirmation404Client(server);
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: syncRemapper,
|
|
);
|
|
|
|
await push.pushNotePin('p-confirm-404', desired: true);
|
|
|
|
check(client.togglePinNoteCalls).equals(1);
|
|
final row = await db.notesDao.getNote('p-confirm-404');
|
|
check(row!.dirtyPinned).isFalse();
|
|
check(server.getNoteById('p-confirm-404')!['is_pinned']).equals(true);
|
|
},
|
|
);
|
|
|
|
test('pin coalescing keeps the newest desired payload', () async {
|
|
server.seedNote(
|
|
id: 'p3',
|
|
title: 'P',
|
|
data: {
|
|
'content': {'md': 'x'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
pinned: false,
|
|
);
|
|
await pull();
|
|
|
|
await locks.runExclusive('p3', () async {
|
|
await db.notesDao.pinNoteWithOutbox('p3', desiredPinned: true);
|
|
await db.notesDao.pinNoteWithOutbox('p3', desiredPinned: false);
|
|
});
|
|
|
|
final pending = await db.outboxDao.pendingForChat('p3');
|
|
check(pending).length.equals(1);
|
|
check(pending.single.kind).equals(OutboxKind.notePin.name);
|
|
final payload = jsonDecode(pending.single.payload) as Map<String, dynamic>;
|
|
check(payload['desired']).equals(false);
|
|
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: syncRemapper,
|
|
);
|
|
await push.pushNotePin('p3', desired: payload['desired'] == true);
|
|
|
|
check(client.togglePinNoteCalls).equals(0);
|
|
check(server.getNoteById('p3')!['is_pinned']).equals(false);
|
|
final row = await db.notesDao.getNote('p3');
|
|
check(row!.dirtyPinned).isFalse();
|
|
});
|
|
|
|
test('pushNoteCreate remaps while the local-id lock is still held', () async {
|
|
final recordingLocks = _RecordingNoteLocks();
|
|
final remapper = IdRemapper(db);
|
|
addTearDown(remapper.dispose);
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: recordingLocks,
|
|
remapper: remapper,
|
|
);
|
|
const localId = 'local:n-create';
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'body'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
dirtyTitle: const Value(true),
|
|
dirtyData: const Value(true),
|
|
),
|
|
);
|
|
|
|
final serverId = await push.pushNoteCreate(localId);
|
|
|
|
check(serverId).isNotNull();
|
|
check(await db.notesDao.getNote(localId)).isNull();
|
|
check(await db.notesDao.getNote(serverId!)).isNotNull();
|
|
check(
|
|
recordingLocks.activeSnapshots.any(
|
|
(keys) => keys.contains(localId) && keys.contains(serverId),
|
|
),
|
|
).isTrue();
|
|
});
|
|
|
|
test(
|
|
'pushNoteCreate preserves edits queued behind the local remap',
|
|
() async {
|
|
final blockingClient = _BlockingCreateNoteClient(server);
|
|
final remapper = IdRemapper(db);
|
|
addTearDown(remapper.dispose);
|
|
final push = NotePushSync(
|
|
client: blockingClient,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: remapper,
|
|
);
|
|
const localId = 'local:n-create-edit-race';
|
|
await db.notesDao.insertLocalNoteWithCreateOp(
|
|
note: NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'body'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
),
|
|
);
|
|
final claimed = await db.outboxDao.claimNextRunnable(
|
|
nowEpochSeconds: 1,
|
|
busyChatIds: <String>{},
|
|
);
|
|
check(claimed!.kind).equals(OutboxKind.noteCreate.name);
|
|
|
|
final pushFuture = push.pushNoteCreate(localId);
|
|
await blockingClient.createStarted.future;
|
|
final editFuture = locks.runExclusive(localId, () {
|
|
return db.notesDao.updateNoteWithOutbox(
|
|
localId,
|
|
title: const Value('Edited while create was in flight'),
|
|
localUpdatedAtNs: kT2,
|
|
enqueue: true,
|
|
);
|
|
});
|
|
|
|
blockingClient.releaseCreate.complete();
|
|
final serverId = await pushFuture;
|
|
await editFuture;
|
|
|
|
check(serverId).isNotNull();
|
|
check(await db.syncMetaDao.getNoteRemapTarget(localId)).equals(serverId);
|
|
check(await db.notesDao.getNote(localId)).isNull();
|
|
final row = await db.notesDao.getNote(serverId!);
|
|
check(row!.title).equals('Edited while create was in flight');
|
|
check(row.dirtyTitle).isTrue();
|
|
|
|
final pending = await db.outboxDao.pendingForChat(serverId);
|
|
check(pending.map((op) => op.kind).toList())
|
|
.deepEquals([OutboxKind.noteUpdate.name]);
|
|
},
|
|
);
|
|
|
|
test('pushNoteCreate skips a tombstoned local note', () async {
|
|
final remapper = IdRemapper(db);
|
|
addTearDown(remapper.dispose);
|
|
final push = NotePushSync(
|
|
client: client,
|
|
db: db,
|
|
noteLocks: locks,
|
|
remapper: remapper,
|
|
);
|
|
const localId = 'local:n-deleted';
|
|
await db
|
|
.into(db.notes)
|
|
.insert(
|
|
NotesCompanion.insert(
|
|
id: localId,
|
|
title: 'Deleted draft',
|
|
data: Value(
|
|
jsonEncode({
|
|
'content': {'md': 'body'},
|
|
}),
|
|
),
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
dirtyTitle: const Value(true),
|
|
dirtyData: const Value(true),
|
|
deleted: const Value(true),
|
|
),
|
|
);
|
|
|
|
final serverId = await push.pushNoteCreate(localId);
|
|
|
|
check(serverId).isNull();
|
|
check(client.createNoteCalls).equals(0);
|
|
check(await db.notesDao.getNote(localId)).isNotNull();
|
|
});
|
|
|
|
test('pull does not enqueue noteUpdate for pin-only dirty notes', () async {
|
|
server.seedNote(
|
|
id: 'p1',
|
|
title: 'Pinned',
|
|
data: {
|
|
'content': {'md': 'server v1'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
pinned: false,
|
|
);
|
|
await pull();
|
|
|
|
await locks.runExclusive('p1', () async {
|
|
await db.notesDao.pinNoteWithOutbox('p1', desiredPinned: true);
|
|
});
|
|
|
|
server.seedNote(
|
|
id: 'p1',
|
|
title: 'Server rename',
|
|
data: {
|
|
'content': {'md': 'server v2'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT2,
|
|
pinned: false,
|
|
);
|
|
await pull();
|
|
|
|
final row = await db.notesDao.getNote('p1');
|
|
check(row).isNotNull();
|
|
check(row!.title).equals('Server rename');
|
|
check(row.dirtyTitle).isFalse();
|
|
check(row.dirtyData).isFalse();
|
|
check(row.dirtyPinned).isTrue();
|
|
|
|
final pending = await db.outboxDao.pendingForChat('p1');
|
|
check(pending.map((op) => op.kind).toList())
|
|
.unorderedEquals([OutboxKind.notePin.name]);
|
|
});
|
|
|
|
test(
|
|
'updateNoteWithOutbox writes the row AND a noteUpdate op in one tx',
|
|
() async {
|
|
server.seedNote(
|
|
id: 'n1',
|
|
title: 'Doc',
|
|
data: {
|
|
'content': {'md': 'v1'},
|
|
},
|
|
createdAt: kT1,
|
|
updatedAt: kT1,
|
|
);
|
|
await pull();
|
|
|
|
await locks.runExclusive('n1', () async {
|
|
await db.notesDao.updateNoteWithOutbox(
|
|
'n1',
|
|
title: const Value('Renamed'),
|
|
localUpdatedAtNs: kT1 + 1,
|
|
enqueue: true,
|
|
);
|
|
});
|
|
|
|
final row = await db.notesDao.getNote('n1');
|
|
check(row!.title).equals('Renamed');
|
|
check(row.dirtyTitle).isTrue();
|
|
|
|
final ops = await db.outboxDao.pendingForChat('n1');
|
|
check(ops.map((o) => o.kind).toList())
|
|
.contains(OutboxKind.noteUpdate.name);
|
|
// The patch always carries title (vendored NoteForm requires it).
|
|
final payload = jsonDecode(ops.first.payload) as Map<String, dynamic>;
|
|
check(payload['title']).equals('Renamed');
|
|
},
|
|
);
|
|
}
|
|
|
|
class _RecordingNoteLocks extends NoteLocks {
|
|
final List<Set<String>> activeSnapshots = <Set<String>>[];
|
|
final Set<String> _active = <String>{};
|
|
|
|
@override
|
|
Future<T> runExclusive<T>(String chatId, Future<T> Function() action) {
|
|
return super.runExclusive(chatId, () async {
|
|
_active.add(chatId);
|
|
activeSnapshots.add(Set<String>.of(_active));
|
|
try {
|
|
return await action();
|
|
} finally {
|
|
_active.remove(chatId);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class _ConcurrentPinFlipClient extends FakeSyncApiClient {
|
|
_ConcurrentPinFlipClient(super.server);
|
|
|
|
@override
|
|
Future<Map<String, dynamic>?> togglePinNote(String id) async {
|
|
final response = await super.togglePinNote(id);
|
|
if (response != null) {
|
|
server.togglePinNote(id);
|
|
}
|
|
return response;
|
|
}
|
|
}
|
|
|
|
class _PinConfirmation404Client extends FakeSyncApiClient {
|
|
_PinConfirmation404Client(super.server);
|
|
|
|
int getNoteCalls = 0;
|
|
|
|
@override
|
|
Future<Map<String, dynamic>?> getNoteRaw(String id) async {
|
|
getNoteCalls++;
|
|
if (getNoteCalls > 1) return null;
|
|
return super.getNoteRaw(id);
|
|
}
|
|
}
|