exchange.v

18.8 KB · raw

1//! Sync: offer and steal.2//!3//! All the cleverness sits here, above the five-operation remote interface, so4//! the backends stay thin and this logic is written once and tested offline.5//!6//! The remote is a line of saves, the same shape as the local one. `offer`7//! sends local saves the remote lacks; `steal` takes remote saves and, when8//! both sides moved, replays the local work on top of the remote's. Divergence9//! is resolved per file, never per line.1011module exchange1213import json214import os15import src.events16import src.fmt17import src.history18import src.remote19import src.snapshot20import src.storage2122// Where the repo's name on the remote is remembered.23const repo_state_key = 'remote_repo'2425// The remote head as of this machine's last successful offer: a record for the26// person or a future feature. Nothing depends on it.27const remote_head_key = 'remote_head'2829pub const no_saves = 'this repo has no saves yet — nothing to offer'3031// The only remote layout this koh reads or writes. A repo offered before the32// key existed has no `format` key and counts as this version.33const format_value = 'koh-remote 1'3435// put_format records the layout on a repo that has none. put_if_absent never36// overwrites, so a machine that wrote one while this one read wins the race:37// its value is read back and checked, never assumed.38fn put_format(mut backend remote.Backend, repo string) ! {39	if backend.put_if_absent(format_key(repo), format_value.bytes())! {40		return41	}42	check_format(mut backend, repo)!43}4445// check_format reads <repo>/format and refuses a remote written by a koh that46// speaks another layout, so two versions never half-understand one bucket. An47// empty answer means the key is missing: version 1, which `offer` records.48fn check_format(mut backend remote.Backend, repo string) !string {49	existing := backend.get(format_key(repo))!50	if !existing.found {51		return ''52	}53	value := existing.body.bytestr().trim_space()54	if value != format_value {55		return error('the remote "${repo}" is "${value}" — this koh speaks "${format_value}"; update koh')56	}57	return value58}5960pub struct OfferReport {61pub mut:62	repo    string63	saves   int64	objects int65	events  int66	head    string67}6869// remember_repo binds a repo to its name on the remote. A steal into a new folder does it at70// once: its folder may be renamed before it ever offers.71pub fn remember_repo(store &storage.Store, name string) ! {72	store.set_state(repo_state_key, name)!73}7475// repo_name is the folder's name at first offer, lowercased so it makes the same76// Kepr URL whatever the folder's case (June, 2026-10-01), and remembered in the77// repo so renaming the folder later changes nothing.78pub fn repo_name(store &storage.Store, mut backend remote.Backend) !string {79	if remembered := store.state(repo_state_key) {80		return follow_rename(store, mut backend, remembered)!81	}82	name := os.base(store.root).to_lower()83	// Before the clash check below: a remote this koh cannot read is a "update84	// koh" answer, not a "rename the folder" one.85	check_format(mut backend, name)!86	existing := backend.get(head_key(name))!87	if existing.found {88		theirs := existing.body.bytestr().trim_space()89		if history.by_id(store, theirs) == none {90			return error('the remote already has "${name}" with a history this repo does not share — rename the folder, or use another remote')91		}92	}93	store.set_state(repo_state_key, name)!94	return name95}9697// follow_rename follows the marker Kepr leaves when it renames a repo, `.kepr/renamed/<old>`98// holding the new name (§30, 2026-10-01), for as long as the remembered name has no `head`, and99// remembers where it led. A few hops cover a repo renamed more than once.100fn follow_rename(store &storage.Store, mut backend remote.Backend, remembered string) !string {101	mut name := remembered102	for _ in 0 .. 5 {103		if backend.get(head_key(name))!.found {104			break105		}106		marker := backend.get('.kepr/renamed/${name}')!107		next := marker.body.bytestr().trim_space()108		if !marker.found || next == '' || next.contains('/') || next.contains('\\') || next in [109			'.',110			'..',111		] {112			break113		}114		name = next115	}116	if name != remembered {117		store.set_state(repo_state_key, name)!118	}119	return name120}121122// offer sends what the remote lacks and then swaps `head`. Nothing is ever123// queued: the next offer simply sends everything the remote does not have.124pub fn offer(store &storage.Store, mut backend remote.Backend) !OfferReport {125	name := repo_name(store, mut backend)!126	local := history.head(store) or { return error(no_saves) }127	mut report := OfferReport{128		repo: name129		head: local.id130	}131	if check_format(mut backend, name)! == '' {132		// Offered before this key existed: record the layout now.133		put_format(mut backend, name)!134	}135	remote_head := backend.get(head_key(name))!136	// Records offered by a machine that has not applied a strip would put the137	// stripped path back on the remote, where nothing would rewrite it again.138	if unapplied_strips(store, mut backend, name)!.len > 0 {139		return error(strip_not_applied)140	}141	mut uploaded := Uploaded{}142	upload_line(store, mut backend, name, local.id, remote_head, mut report, mut uploaded) or {143		return after_failed_upload(store, mut backend, name, uploaded, err)144	}145	// A strip sends its event before it lists the saves to rewrite, so one146	// missed here lists them after these records landed and rewrites them. One147	// seen here may have rewritten before they landed: take back what it reaches.148	if undo_late_strips(store, mut backend, name, uploaded)! {149		return error(strip_not_applied)150	}151	if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {152		return error('remote has new saves — run koh steal')153	}154	store.set_state(remote_head_key, local.id)!155	return report156}157158// saves_after lists the local saves newer than `theirs`, oldest first. It fails159// when `theirs` is not on this line, which is the honest answer: we cannot160// offer on top of a save we do not have.161fn saves_after(store &storage.Store, ours string, theirs string) ![]history.Save {162	mut newer := []history.Save{}163	mut cursor := ours164	for cursor != '' && cursor != theirs {165		save := history.by_id(store, cursor) or { return error('save ${cursor} is missing') }166		newer << save167		cursor = save.parent168	}169	if cursor != theirs {170		return error('${theirs} is not on this line')171	}172	newer.reverse_in_place()173	return newer174}175176const strip_not_applied = 'the remote has a strip this repo has not applied — run koh steal'177178// Uploaded is what one upload put on the remote, so a late strip can take it back.179struct Uploaded {180mut:181	saves   []string182	objects map[string]bool183}184185fn (u Uploaded) done() []string {186	return if u.saves.len + u.objects.len == 0 {187		[]string{}188	} else {189		['uploaded ${fmt.plural(u.objects.len, 'object')} and ${fmt.plural(u.saves.len, 'save record')}']190	}191}192193// upload_line sends every save after the remote head, then the events.194fn upload_line(store &storage.Store, mut backend remote.Backend, name string, local string, remote_head remote.Object, mut report OfferReport, mut uploaded Uploaded) ! {195	if remote_head.found {196		theirs := remote_head.body.bytestr().trim_space()197		newer := saves_after(store, local, theirs) or {198			return error('remote has new saves — run koh steal')199		}200		for save in newer {201			upload_save(store, mut backend, name, save, mut report, mut uploaded)!202		}203	} else {204		oldest_first := history.line(store)!205		for index := oldest_first.len - 1; index >= 0; index-- {206			upload_save(store, mut backend, name, oldest_first[index], mut report, mut uploaded)!207		}208	}209	upload_events(store, mut backend, name, mut report)!210}211212// after_failed_upload still applies a strip that landed before the failure213// to what the upload had put.214fn after_failed_upload(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, failure IError) IError {215	undo_late_strips(store, mut backend, repo, uploaded) or {216		return error('${failure.msg()}; removing what a strip on the remote covers also failed: ${err.msg()}')217	}218	return failure219}220221// unsettled_key keeps what an upload sent when settling a late strip failed,222// for Doctor to finish once a steal brings the strip here.223pub const unsettled_key = 'upload_unsettled'224225pub fn unsettled(store &storage.Store) ![]string {226	text := store.state(unsettled_key) or { return []string{} }227	return json2.decode[[]string](text) or {228		return error('the note of an unsettled upload is unreadable; it is kept for recovery')229	}230}231232fn undo_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded) !bool {233	return settle_late_strips(store, mut backend, repo, uploaded) or {234		failure := err235		mut kept := unsettled(store) or { return error('${failure.msg()}; ${err.msg()}') }236		kept << uploaded.objects.keys().filter(it !in kept)237		store.set_state(unsettled_key, json2.encode(kept)) or {238			return error('${failure.msg()}; ${err.msg()}')239		}240		return failure241	}242}243244// settle_late_strips applies strips that reached the remote during an upload245// to what it put, and reports whether there were any. Records stay, less the246// stripped paths, as another offer or head may name them. Notes go first and247// objects last, so a stop part way leaves a note and no record without its object.248fn settle_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded) !bool {249	strips := unapplied_strips(store, mut backend, repo)!250	if strips.len == 0 {251		return false252	}253	for strip in strips {254		queue_strip(store, strip.path, [strip.save_id], uploaded.objects.keys())!255	}256	for id in uploaded.saves {257		strip_record(mut backend, save_key(repo, id), id, strips)!258	}259	drop_stripped_objects(store, mut backend, repo, uploaded, strips)!260	return true261}262263// strip_record rewrites one remote record without what `strips` cover. A264// record that moved meanwhile is read again, since the other writer may have265// kept a path these strips name.266fn strip_record(mut backend remote.Backend, key string, id string, strips []events.Event) ! {267	for _ in 0 .. 5 {268		object := backend.get(key)!269		if !object.found {270			return271		}272		record := decode_save(object.body)!273		kept := record.files.filter(fn [strips, id] (file FileRecord) bool {274			return !strips.any(reaches(it, id) && covers(it.path, file.path))275		})276		if kept.len == record.files.len277			|| backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), object.tag)! {278			return279		}280	}281	return error('the remote copy of save ${id} kept changing while a stripped path was removed; run koh doctor again')282}283284// Partway is a failure after some work had already landed. `done` says what,285// so Doctor can report both.286pub struct Partway {287	Error288pub:289	done   string290	reason string291}292293pub fn (e Partway) msg() string {294	return e.reason295}296297// partway adds what was `done` before `failure` to it, including what a298// failing step of its own had done.299pub fn partway(done []string, failure IError) IError {300	mut all := done.clone()301	if failure is Partway {302		all << failure.done303	}304	if all.len == 0 {305		return failure306	}307	return Partway{308		done:   all.join('; ')309		reason: failure.msg()310	}311}312313// drop_stripped_objects deletes what an upload put that a strip reaches in a314// save here, unless a remote record lists it under a path no strip covers:315// another machine may hold the same bytes there.316fn drop_stripped_objects(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {317	mut reached := map[string]bool{}318	for save in history.all_saves(store)! {319		for file in history.files_of(store, save.id)! {320			if file.hash in uploaded.objects && strips.any(reaches(it, save.id) && covers(it.path, file.path)) {321				reached[file.hash] = true322			}323		}324	}325	if reached.len == 0 {326		return327	}328	for key in backend.list('${repo}/saves')! {329		object := backend.get(key)!330		if object.found {331			record := decode_save(object.body)!332			for file in record.files {333				if !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {334					reached.delete(file.hash)335				}336			}337		}338	}339	for hash, _ in reached {340		backend.delete(object_key(repo, hash))!341	}342}343344// upload_save puts a save's new objects, then its record, so a record on the remote always finds its345// objects there. Both are put_if_absent, so a retry after a partial offer is safe; `uploaded` keeps346// what this call created, which is all undo_late_strips may delete.347fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport, mut uploaded Uploaded) ! {348	files := history.files_of(store, save.id)!349	// Saves go up oldest first and head moves only once all of them landed, so350	// the parent and its objects are on the remote already: only content new351	// in this save travels, not a round trip per file in the repo.352	mut sent := map[string]bool{}353	if save.parent != '' {354		for file in history.files_of(store, save.parent)! {355			sent[file.hash] = true356		}357	}358	for file in files {359		if file.hash in sent {360			continue361		}362		sent[file.hash] = true363		if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {364			report.objects++365			uploaded.objects[file.hash] = true366		}367	}368	record := save_record(store, save)!369	if backend.put_if_absent(save_key(repo, save.id), encode_save(record).bytes())! {370		report.saves++371		uploaded.saves << save.id372	}373}374375// upload_events sends every event the remote does not have. Events are tiny, so376// listing them costs less than tracking which ones were sent.377fn upload_events(store &storage.Store, mut backend remote.Backend, repo string, mut report OfferReport) ! {378	already := backend.list(events_prefix(repo))!379	mut already_sent := map[string]bool{}380	for key in already {381		already_sent[os.base(key)] = true382	}383	for event in events.all(store)! {384		if event.id in already_sent {385			continue386		}387		if backend.put_if_absent(event_key(repo, event.id), encode_event(event).bytes())! {388			report.events++389		}390	}391}392393// fetched holds everything a steal read from the remote, before any of it is394// written locally. Network work happens first; the database gets one395// transaction, so a failure leaves nothing half-applied.396pub struct Fetched {397pub mut:398	repo_objects map[string][]u8399	saves        []SaveRecord // oldest first400	events_out   []events.Event401	head         string402	base         string // the newest save both sides already shared, '' when none403	unsafe_paths []string404}405406// fetch_back_to_known walks from the remote head along parents until it meets a save on this line407// (`base`), reading only the objects this repo lacks. Nothing is written here: write_fetched lands it.408// A save held locally but off the line, from an earlier steal, is taken from the local copy.409pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {410	check_format(mut backend, repo)!411	remote_head := backend.get(head_key(repo))!412	if !remote_head.found {413		return error('the remote has no "${repo}" yet — run koh offer here first')414	}415	mut fetched := Fetched{416		head: remote_head.body.bytestr().trim_space()417	}418	mut on_line := map[string]bool{}419	for save in history.line(store)! {420		on_line[save.id] = true421	}422	// Strips come first: an object only a stripped path named may be gone.423	fetched.events_out = fetch_events(store, mut backend, repo)!424	strips := known_strips(store, fetched)!425	mut cursor := fetched.head426	for cursor != '' && cursor !in on_line {427		mut record := if saved := history.by_id(store, cursor) {428			save_record(store, saved)!429		} else {430			record_object := backend.get(save_key(repo, cursor))!431			if !record_object.found {432				return error('the remote is missing save ${cursor} — run koh offer there')433			}434			decode_save(record_object.body)!435		}436		fetched.unsafe_paths << drop_unsafe_paths(mut record)437		for file in record.files {438			if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects439				&& !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {440				blob := backend.get(object_key(repo, file.hash))!441				if !blob.found {442					return error('the remote is missing object ${file.hash} that save ${record.id} needs')443				}444				fetched.repo_objects[file.hash] = blob.body445			}446		}447		fetched.saves << record448		cursor = record.parent449	}450	fetched.saves.reverse_in_place()451	fetched.base = cursor452	return fetched453}454455fn fetch_events(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {456	have := events.all(store)!457	mut known := map[string]bool{}458	for event in have {459		known[event.id] = true460	}461	mut incoming := []events.Event{}462	for key in backend.list(events_prefix(repo))! {463		id := os.base(key)464		if id in known {465			continue466		}467		body := backend.get(event_key(repo, id))!468		if !body.found {469			continue470		}471		event := decode_event(body.body)!472		if !ignored_strip(event) {473			incoming << event474		}475	}476	return incoming477}478479// write_fetched lands a fetch in one transaction: objects first (verified),480// then the events, then the saves they belong to. A strip bans its path from481// all of history, so it reaches every save, here or arriving, whenever made.482pub fn write_fetched(store &storage.Store, fetched Fetched) ! {483	for hash, blob in fetched.repo_objects {484		store.put_object_blob(hash, blob)!485	}486	mut stripped := false487	for event in fetched.events_out {488		if !events.receive(store, event)! {489			continue490		}491		if events.valid_strip(event) {492			remove_path(store, event.path, event.save_id)!493			stripped = true494		}495	}496	strips := known_strips(store, fetched)!497	for record in fetched.saves {498		import_arriving(store, record, strips)!499	}500	if stripped {501		store.drop_unreferenced_objects()!502	}503}504505// write_fetched_records makes network work durable without changing the line.506// Events wait for the combine transaction because a strip can rewrite history.507pub fn write_fetched_records(store &storage.Store, fetched Fetched) ! {508	for hash, blob in fetched.repo_objects {509		store.put_object_blob(hash, blob)!510	}511	strips := known_strips(store, fetched)!512	for record in fetched.saves {513		import_arriving(store, record, strips)!514	}515}516517// known_strips is every valid strip held here and every one the fetch brings:518// an arriving save loses what any of them reaches, not only what arrived with it.519fn known_strips(store &storage.Store, fetched Fetched) ![]events.Event {520	mut strips := events.strip_events(store)!521	strips << fetched.events_out522	return strips.filter(events.valid_strip(it))523}524525// import_arriving lands one save from the remote, unless it is already here, less every path a526// strip that `reaches` it removed.527fn import_arriving(store &storage.Store, record SaveRecord, strips []events.Event) ! {528	if history.by_id(store, record.id) != none {529		return530	}531	files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {532		return snapshot.legal_path(file.path)533			&& !strips.any(reaches(it, record.id) && covers(it.path, file.path))534	})535	history.import_save(store, to_save(record), files)!536}537538// reaches reports whether a strip event applies to a save: its one save, or539// every save there is, whenever it was made.540fn reaches(strip events.Event, save_id string) bool {541	return strip.save_id == '' || strip.save_id == save_id542}