exchange.v

20.1 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	deleted int // damaged remote copies removed before their replacement184}185186fn (u Uploaded) done() []string {187	mut done := done_text('deleted', u.deleted, 'damaged remote object')188	if u.saves.len + u.objects.len > 0 {189		done << 'uploaded ${fmt.plural(u.objects.len, 'object')} and ${fmt.plural(u.saves.len, 'save record')}'190	}191	return done192}193194// upload_line sends every save after the remote head, then the events.195fn upload_line(store &storage.Store, mut backend remote.Backend, name string, local string, remote_head remote.Object, mut report OfferReport, mut uploaded Uploaded) ! {196	if remote_head.found {197		theirs := remote_head.body.bytestr().trim_space()198		newer := saves_after(store, local, theirs) or {199			return error('remote has new saves — run koh steal')200		}201		for save in newer {202			upload_save(store, mut backend, name, save, mut report, mut uploaded)!203		}204	} else {205		oldest_first := history.line(store)!206		for index := oldest_first.len - 1; index >= 0; index-- {207			upload_save(store, mut backend, name, oldest_first[index], mut report, mut uploaded)!208		}209	}210	upload_events(store, mut backend, name, mut report)!211}212213// after_failed_upload still applies a strip that landed before the failure214// to what the upload had put.215fn after_failed_upload(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, failure IError) IError {216	undo_late_strips(store, mut backend, repo, uploaded) or {217		return error('${failure.msg()}; removing what a strip on the remote covers also failed: ${err.msg()}')218	}219	return failure220}221222// unsettled_key keeps what an upload sent when settling a late strip failed,223// for Doctor to finish once a steal brings the strip here.224pub const unsettled_key = 'upload_unsettled'225226pub fn unsettled(store &storage.Store) ![]string {227	text := store.state(unsettled_key) or { return []string{} }228	return json2.decode[[]string](text) or {229		return error('the note of an unsettled upload is unreadable; it is kept for recovery')230	}231}232233fn undo_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded) !bool {234	return settle_late_strips(store, mut backend, repo, uploaded) or {235		failure := err236		mut kept := unsettled(store) or { return error('${failure.msg()}; ${err.msg()}') }237		kept << uploaded.objects.keys().filter(it !in kept)238		store.set_state(unsettled_key, json2.encode(kept)) or {239			return error('${failure.msg()}; ${err.msg()}')240		}241		return failure242	}243}244245// settle_late_strips applies strips that reached the remote during an upload246// to what it put, and reports whether there were any. Records stay, less the247// stripped paths, as another offer or head may name them. Notes go first and248// objects last, so a stop part way leaves a note and no record without its object.249fn settle_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded) !bool {250	strips := unapplied_strips(store, mut backend, repo)!251	if strips.len == 0 {252		return false253	}254	for strip in strips {255		queue_strip(store, strip.path, [strip.save_id], uploaded.objects.keys())!256	}257	for id in uploaded.saves {258		strip_record(mut backend, save_key(repo, id), id, strips)!259	}260	drop_stripped_objects(store, mut backend, repo, uploaded, strips)!261	return true262}263264// strip_record rewrites one remote record without what `strips` cover. A265// record that moved meanwhile is read again, since the other writer may have266// kept a path these strips name.267fn strip_record(mut backend remote.Backend, key string, id string, strips []events.Event) ! {268	for _ in 0 .. 5 {269		object := backend.get(key)!270		if !object.found {271			return272		}273		record := decode_save(object.body)!274		kept := record.files.filter(fn [strips, id] (file FileRecord) bool {275			return !strips.any(reaches(it, id) && covers(it.path, file.path))276		})277		if kept.len == record.files.len278			|| backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), object.tag)! {279			return280		}281	}282	return error('the remote copy of save ${id} kept changing while a stripped path was removed; run koh doctor again')283}284285// Partway is a failure after some work had already landed. `done` says what,286// so Doctor can report both.287pub struct Partway {288	Error289pub:290	done   string291	reason string292}293294pub fn (e Partway) msg() string {295	return e.reason296}297298// done_text is what a step finished before it failed, if anything.299pub fn done_text(verb string, done int, noun string) []string {300	return if done == 0 { []string{} } else { ['${verb} ${fmt.plural(done, noun)}'] }301}302303// partway adds what was `done` before `failure` to it, including what a304// failing step of its own had done.305pub fn partway(done []string, failure IError) IError {306	mut all := done.clone()307	if failure is Partway {308		all << failure.done309	}310	if all.len == 0 {311		return failure312	}313	return Partway{314		done:   all.join('; ')315		reason: failure.msg()316	}317}318319// drop_stripped_objects deletes what an upload put that a strip reaches in a320// save here, unless a remote record lists it under a path no strip covers:321// another machine may hold the same bytes there.322fn drop_stripped_objects(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {323	mut reached := map[string]bool{}324	// A local save that still names it under another path may have no other copy.325	mut kept := map[string]bool{}326	for save in history.all_saves(store)! {327		for file in history.files_of(store, save.id)!.filter(it.hash in uploaded.objects) {328			if strips.any(reaches(it, save.id) && covers(it.path, file.path)) {329				reached[file.hash] = true330			} else {331				kept[file.hash] = true332			}333		}334	}335	if reached.len == 0 {336		return337	}338	for key in backend.list('${repo}/saves')! {339		object := backend.get(key)!340		if object.found {341			record := decode_save(object.body)!342			for file in record.files {343				if !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {344					reached.delete(file.hash)345				}346			}347		}348	}349	for hash, _ in reached {350		if hash !in kept {351			backend.delete(object_key(repo, hash))!352		}353	}354}355356// upload_save puts a save's new objects, then its record, so a record on the remote always finds its357// objects there. Both are put_if_absent, so a retry after a partial offer is safe; `uploaded` keeps358// what this call created, which is all undo_late_strips may delete.359fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport, mut uploaded Uploaded) ! {360	files := history.files_of(store, save.id)!361	// Saves go up oldest first and head moves only once all of them landed, so362	// the parent and its objects are on the remote already: only content new363	// in this save travels, not a round trip per file in the repo.364	mut sent := map[string]bool{}365	if save.parent != '' {366		for file in history.files_of(store, save.parent)! {367			sent[file.hash] = true368		}369	}370	for file in files {371		if file.hash in sent {372			continue373		}374		sent[file.hash] = true375		if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {376			report.objects++377			uploaded.objects[file.hash] = true378		}379	}380	record := save_record(store, save)!381	if backend.put_if_absent(save_key(repo, save.id), encode_save(record).bytes())! {382		report.saves++383		uploaded.saves << save.id384	}385}386387// upload_events sends every event the remote does not have. Events are tiny, so388// listing them costs less than tracking which ones were sent.389fn upload_events(store &storage.Store, mut backend remote.Backend, repo string, mut report OfferReport) ! {390	already := backend.list(events_prefix(repo))!391	mut already_sent := map[string]bool{}392	for key in already {393		already_sent[os.base(key)] = true394	}395	for event in events.all(store)! {396		if event.id in already_sent {397			continue398		}399		if backend.put_if_absent(event_key(repo, event.id), encode_event(event).bytes())! {400			report.events++401		}402	}403}404405// fetched holds everything a steal read from the remote, before any of it is406// written locally. Network work happens first; the database gets one407// transaction, so a failure leaves nothing half-applied.408pub struct Fetched {409pub mut:410	repo_objects map[string][]u8411	saves        []SaveRecord // oldest first412	events_out   []events.Event413	head         string414	base         string // the newest save both sides already shared, '' when none415	unsafe_paths []string416}417418// fetch_back_to_known walks from the remote head along parents until it meets a save on this line419// (`base`), reading only the objects this repo lacks. Nothing is written here: write_fetched lands it.420// A save held locally but off the line, from an earlier steal, is taken from the local copy.421pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {422	check_format(mut backend, repo)!423	remote_head := backend.get(head_key(repo))!424	if !remote_head.found {425		return error('the remote has no "${repo}" yet — run koh offer here first')426	}427	mut fetched := Fetched{428		head: remote_head.body.bytestr().trim_space()429	}430	if !valid_save_id(fetched.head) {431		return error('the remote head has invalid save ID ${fetched.head}')432	}433	mut on_line := map[string]bool{}434	for save in history.line(store)! {435		on_line[save.id] = true436	}437	// Strips come first: an object only a stripped path named may be gone.438	fetched.events_out = fetch_events(store, mut backend, repo)!439	strips := known_strips(store, fetched)!440	mut cursor := fetched.head441	mut seen := map[string]bool{}442	for cursor != '' && cursor !in on_line {443		if cursor in seen {444			return error('the remote history has a parent cycle at ${cursor}')445		}446		seen[cursor] = true447		mut record := if saved := history.by_id(store, cursor) {448			save_record(store, saved)!449		} else {450			record_object := backend.get(save_key(repo, cursor))!451			if !record_object.found {452				return error('the remote is missing save ${cursor} — run koh offer there')453			}454			decoded := decode_save(record_object.body)!455			validate_save(decoded, cursor)!456			decoded457		}458		fetched.unsafe_paths << drop_unsafe_paths(mut record)459		validate_hashes(record.id, record.files)!460		for file in record.files {461			if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects462				&& !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {463				blob := backend.get(object_key(repo, file.hash))!464				if !blob.found {465					return error('the remote is missing object ${file.hash} that save ${record.id} needs')466				}467				fetched.repo_objects[file.hash] = blob.body468			}469		}470		fetched.saves << record471		cursor = record.parent472	}473	fetched.saves.reverse_in_place()474	fetched.base = cursor475	return fetched476}477478fn fetch_events(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {479	have := events.all(store)!480	mut known := map[string]bool{}481	for event in have {482		known[event.id] = true483	}484	mut incoming := []events.Event{}485	for key in backend.list(events_prefix(repo))! {486		id := os.base(key)487		if !valid_event_id(id) {488			return error('the remote has invalid event ID ${id}')489		}490		if id in known {491			continue492		}493		body := backend.get(event_key(repo, id))!494		if !body.found {495			continue496		}497		event := decode_event(body.body)!498		if event.id != id {499			return error('remote event record ID ${event.id} disagrees with key ${id}')500		}501		if !ignored_strip(event) {502			incoming << event503		}504	}505	return incoming506}507508// write_fetched lands a fetch in one transaction: objects first (verified),509// then the events, then the saves they belong to. A strip bans its path from510// all of history, so it reaches every save, here or arriving, whenever made.511pub fn write_fetched(store &storage.Store, fetched Fetched) ! {512	for hash, blob in fetched.repo_objects {513		store.put_object_blob(hash, blob)!514	}515	mut freed := []string{}516	for event in fetched.events_out {517		if !events.receive(store, event)! {518			continue519		}520		if events.valid_strip(event) {521			freed << stripped_hashes(store, event.path, event.save_id)!522			remove_path(store, event.path, event.save_id)!523		}524	}525	strips := known_strips(store, fetched)!526	for record in fetched.saves {527		for file in record.files {528			if file.hash in fetched.repo_objects529				&& strips.any(reaches(it, record.id) && covers(it.path, file.path)) {530				freed << file.hash531			}532		}533		import_arriving(store, record, strips)!534	}535	store.drop_unused(freed)!536}537538// write_fetched_records makes network work durable without changing the line.539// Events wait for the combine transaction because a strip can rewrite history.540pub fn write_fetched_records(store &storage.Store, fetched Fetched) ! {541	for hash, blob in fetched.repo_objects {542		store.put_object_blob(hash, blob)!543	}544	strips := known_strips(store, fetched)!545	for record in fetched.saves {546		import_arriving(store, record, strips)!547	}548}549550// known_strips is every valid strip held here and every one the fetch brings:551// an arriving save loses what any of them reaches, not only what arrived with it.552fn known_strips(store &storage.Store, fetched Fetched) ![]events.Event {553	mut strips := events.strip_events(store)!554	strips << fetched.events_out555	return strips.filter(events.valid_strip(it))556}557558// import_arriving lands one save from the remote, unless it is already here, less every path a559// strip that `reaches` it removed.560fn import_arriving(store &storage.Store, record SaveRecord, strips []events.Event) ! {561	if history.by_id(store, record.id) != none {562		return563	}564	validate_save(record, record.id)!565	safe := record.files.filter(snapshot.legal_path(it.path))566	validate_hashes(record.id, safe)!567	files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {568		return snapshot.legal_path(file.path)569			&& !strips.any(reaches(it, record.id) && covers(it.path, file.path))570	})571	history.import_save(store, to_save(record), files)!572}573574// reaches reports whether a strip event applies to a save: its one save, or575// every save there is, whenever it was made.576fn reaches(strip events.Event, save_id string) bool {577	return strip.save_id == '' || strip.save_id == save_id578}