mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-06 09:22:30 +00:00
Merge origin/main into time-bound Setup URI branch
This commit is contained in:
@@ -8,11 +8,17 @@ process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
const originalRoot = "batch/original";
|
||||
const renamedRoot = "batch/renamed";
|
||||
const outsidePath = "batch/outside.md";
|
||||
const missingColonPath = "batch/incoming/Poem: Example.md";
|
||||
const folders = ["alpha", "alpha/deep", "beta"];
|
||||
const notes = Array.from({ length: 24 }, (_, index) => ({
|
||||
relativePath: `${folders[index % folders.length]}/note-${index}.md`,
|
||||
body: `# Descendant ${index}\n\nThis body must survive a parent folder rename.\n`,
|
||||
}));
|
||||
notes.push(
|
||||
{ relativePath: "alpha/Poem: Example.md", body: "First poem\n" },
|
||||
{ relativePath: "beta/Poem: Example.md", body: "Second poem\n" },
|
||||
{ relativePath: "alpha/deep/Poem: Part: Example.md", body: "Poem with multiple colons\n" }
|
||||
);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
@@ -45,7 +51,7 @@ async function main(): Promise<void> {
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
const result = await evalObsidianJson<{ descendants: number; renamed: number; deleted: number }>(
|
||||
const result = await evalObsidianJson<{ descendants: number; renamed: number; deleted: number; missingRejected: boolean }>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
@@ -54,6 +60,7 @@ async function main(): Promise<void> {
|
||||
const originalRoot=${JSON.stringify(originalRoot)};
|
||||
const renamedRoot=${JSON.stringify(renamedRoot)};
|
||||
const outsidePath=${JSON.stringify(outsidePath)};
|
||||
const missingColonPath=${JSON.stringify(missingColonPath)};
|
||||
const renamed=new Set(), deleted=new Set();
|
||||
const refs=[
|
||||
app.vault.on('rename',(file,oldPath)=>{
|
||||
@@ -112,11 +119,23 @@ async function main(): Promise<void> {
|
||||
await app.vault.createFolder(originalRoot);
|
||||
for(const folder of ${JSON.stringify(folders)})
|
||||
await app.vault.createFolder(originalRoot+'/'+folder);
|
||||
await Promise.all(notes.map(note=>app.vault.create(originalRoot+'/'+note.relativePath,note.body)));
|
||||
// Obsidian indexes imported colon names but rejects them in Vault.create.
|
||||
await Promise.all(notes.map(note=>note.relativePath.includes(':')
|
||||
? app.vault.adapter.write(originalRoot+'/'+note.relativePath,note.body)
|
||||
: app.vault.create(originalRoot+'/'+note.relativePath,note.body)));
|
||||
await app.vault.create(outsidePath,'Outside note');
|
||||
await waitFor('Initial batch',async()=>[
|
||||
...await liveBatch(originalRoot), ...await liveErrors(outsidePath,'Outside note'),
|
||||
]);
|
||||
for(const note of notes){
|
||||
const path=originalRoot+'/'+note.relativePath;
|
||||
if(!await core.serviceModules.fileHandler.dbToStorage(await meta(path),null,true))
|
||||
throw new Error('Database reflection failed: '+path);
|
||||
}
|
||||
await waitFor('Reflected batch',()=>liveBatch(originalRoot));
|
||||
const expectedPaths=new Set([outsidePath,...notes.map(note=>originalRoot+'/'+note.relativePath)]);
|
||||
const unexpected=app.vault.getFiles().map(file=>file.path).filter(path=>!expectedPaths.has(path));
|
||||
if(unexpected.length) throw new Error('Unexpected reflected files: '+unexpected.join(', '));
|
||||
const originalIds=await Promise.all(notes.map(async note=>(await meta(originalRoot+'/'+note.relativePath))._id));
|
||||
|
||||
// Rename the parent once: Obsidian must emit every descendant event.
|
||||
@@ -144,7 +163,40 @@ async function main(): Promise<void> {
|
||||
if(app.vault.getAbstractFileByPath(renamedRoot)) throw new Error('Deleted folder remains');
|
||||
await app.vault.modify(app.vault.getAbstractFileByPath(outsidePath),'Outside note updated');
|
||||
await waitFor('Outside update',()=>liveErrors(outsidePath,'Outside note updated'));
|
||||
return JSON.stringify({descendants:notes.length,renamed:renamed.size,deleted:deleted.size});
|
||||
|
||||
// A received database entry must not create a different Vault file when Obsidian rejects its name.
|
||||
const filesBefore=new Set(app.vault.getFiles().map(file=>file.path));
|
||||
const incomingBody='Received colon note\\n';
|
||||
const incomingData=new Blob([incomingBody],{type:'text/plain'});
|
||||
const incomingId=await core.services.path.path2id(missingColonPath);
|
||||
const incomingTime=Date.now();
|
||||
const saved=await core.localDatabase.putDBEntry({
|
||||
_id:incomingId,path:missingColonPath,data:incomingData,
|
||||
ctime:incomingTime,mtime:incomingTime,size:incomingData.size,
|
||||
children:[],datatype:'plain',type:'plain',eden:{},
|
||||
});
|
||||
if(!saved?.ok) throw new Error('Could not seed received Metadata: '+missingColonPath);
|
||||
const incomingMeta=await meta(missingColonPath);
|
||||
if(!incomingMeta || incomingMeta._id!==incomingId || incomingMeta.path!==missingColonPath)
|
||||
throw new Error('Received Metadata has the wrong path: '+missingColonPath);
|
||||
const incomingEntry=await core.localDatabase.getDBEntry(missingColonPath,{rev:incomingMeta._rev},false,true,true);
|
||||
if(!incomingEntry || getContent(incomingEntry)!==incomingBody)
|
||||
throw new Error('Received content could not be read: '+missingColonPath);
|
||||
let creationFailure='';
|
||||
try{
|
||||
const reflected=await core.serviceModules.fileHandler.dbToStorage(incomingMeta,null,true);
|
||||
if(reflected) throw new Error('Obsidian unexpectedly created: '+missingColonPath);
|
||||
}catch(error){
|
||||
creationFailure=String(error);
|
||||
if(!creationFailure.includes('File name cannot contain')) throw error;
|
||||
}
|
||||
if(!creationFailure) throw new Error('Missing name rejection: '+missingColonPath);
|
||||
const filesAfter=app.vault.getFiles().map(file=>file.path);
|
||||
const newFiles=filesAfter.filter(path=>!filesBefore.has(path));
|
||||
if(newFiles.length) throw new Error('Received note was written under another name: '+newFiles.join(', '));
|
||||
if((await meta(missingColonPath))?.path!==missingColonPath)
|
||||
throw new Error('Received Metadata changed after rejection: '+missingColonPath);
|
||||
return JSON.stringify({descendants:notes.length,renamed:renamed.size,deleted:deleted.size,missingRejected:true});
|
||||
}finally{
|
||||
for(const ref of refs) app.vault.offref(ref);
|
||||
}
|
||||
@@ -153,7 +205,8 @@ async function main(): Promise<void> {
|
||||
);
|
||||
console.log(
|
||||
`Folder batch: ${result.descendants} descendants persisted, renamed, and deleted; ` +
|
||||
`${result.renamed} rename and ${result.deleted} delete events observed; outside note remained writable.`
|
||||
`${result.renamed} rename and ${result.deleted} delete events observed; outside note remained writable; ` +
|
||||
`missing colon note rejected without an alternate file: ${result.missingRejected}.`
|
||||
);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../r
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const paths = ["stale-known.md", "stale-unknown.md"];
|
||||
const recoveredPath = "stale-recovered.md";
|
||||
const oldContent = "# Note\nKeep\n\nTail\n\nFooter\n";
|
||||
const newContent = oldContent.replace(
|
||||
"Footer\n",
|
||||
@@ -26,7 +27,7 @@ async function readState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<Fil
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');
|
||||
const states=[];
|
||||
for(const path of ${JSON.stringify(paths)}){
|
||||
for(const path of ${JSON.stringify([...paths, recoveredPath])}){
|
||||
const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);
|
||||
const branches=[];
|
||||
for(const rev of [meta._rev,...(meta._conflicts??[])]){
|
||||
@@ -82,16 +83,16 @@ async function main(): Promise<void> {
|
||||
await evalObsidianJson(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
for(const path of ${JSON.stringify(paths)}) await app.vault.create(path,${JSON.stringify(oldContent)});
|
||||
for(const path of ${JSON.stringify([...paths, recoveredPath])}) await app.vault.create(path,${JSON.stringify(oldContent)});
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
for (const path of paths) await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
for (const path of [...paths, recoveredPath]) await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
|
||||
// Drain real Vault events before creating a persisted pending-event fixture.
|
||||
// The DB advances without reflecting it in the Vault, as on an offline device.
|
||||
const fixture = await evalObsidianJson<{ current: string[]; original: string[] }>(
|
||||
const fixture = await evalObsidianJson<{ current: string[]; original: string[]; recovered: string }>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
@@ -110,8 +111,11 @@ async function main(): Promise<void> {
|
||||
else await store.delete(path);
|
||||
snapshot.push({type:'CHANGED',key:'CHANGED-'+path,args:{file}});
|
||||
}
|
||||
const recovered=${JSON.stringify(recoveredPath)};
|
||||
const recoveredMeta=await core.localDatabase.getDBEntryMeta(recovered,{},true);
|
||||
await store.delete(recovered);
|
||||
await core.kvDB.set('storage-event-manager-snapshot',snapshot);
|
||||
return JSON.stringify({current,original});
|
||||
return JSON.stringify({current,original,recovered:recoveredMeta._rev});
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
@@ -120,7 +124,7 @@ async function main(): Promise<void> {
|
||||
|
||||
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault });
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
const [known, unknown] = await readState(cliBinary, session.cliEnv);
|
||||
const [known, unknown, recovered] = await readState(cliBinary, session.cliEnv);
|
||||
assertEqual(known.rev, fixture.current[0], "An unchanged stale file created a revision during restart.");
|
||||
assertEqual(known.branches.length, 1, "An unchanged stale file created a conflict.");
|
||||
assertEqual(known.content, newContent, "The newer DB content was not reflected after suppressing the save.");
|
||||
@@ -134,6 +138,32 @@ async function main(): Promise<void> {
|
||||
if (!unknown.branches.some((branch) => branch.content === newContent)) {
|
||||
throw new Error("The remote additions were lost.");
|
||||
}
|
||||
assertEqual(recovered.rev, fixture.recovered, "Unchanged recovery added a database revision.");
|
||||
assertEqual(recovered.branches.length, 1, "Unchanged recovery created a conflict.");
|
||||
assertEqual(recovered.content, oldContent, "Unchanged recovery modified the Vault file.");
|
||||
assertEqual(recovered.provenance, fixture.recovered, "Start-up did not recover missing file provenance.");
|
||||
|
||||
const receivedRevision = await evalObsidianJson<string>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
const path=${JSON.stringify(recoveredPath)};
|
||||
const meta=await core.localDatabase.getDBEntryMeta(path,{},true);
|
||||
const file=await core.storageAccess.getFileStub(path);
|
||||
const data=new Blob([${JSON.stringify(newContent)}],{type:'text/plain'});
|
||||
const result=await core.localDatabase.putDBEntry({...meta,data,mtime:file.stat.mtime+60000,
|
||||
size:data.size,children:[]},false,meta._rev);
|
||||
if(!result?.ok) throw new Error('Could not advance '+path);
|
||||
if(!await core.fileHandler.dbToStorage(path,path,false)) throw new Error('Could not reflect '+path);
|
||||
return JSON.stringify(result.rev);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
const [, , received] = await readState(cliBinary, session.cliEnv);
|
||||
assertEqual(received.rev, receivedRevision, "Incoming revision was not retained.");
|
||||
assertEqual(received.branches.length, 1, "Incoming revision created a conflict after recovery.");
|
||||
assertEqual(received.content, newContent, "Incoming revision was not reflected after recovery.");
|
||||
assertEqual(received.provenance, receivedRevision, "Incoming reflection did not record its revision.");
|
||||
|
||||
await evalObsidianJson(
|
||||
cliBinary,
|
||||
@@ -174,7 +204,7 @@ async function main(): Promise<void> {
|
||||
assertEqual(resolved.branches.length, 1, "The explicit newer-file option did not resolve the conflict.");
|
||||
assertEqual(resolved.content, newContent, "The newer-file option did not reflect the newer DB version.");
|
||||
console.log(
|
||||
"Stale-file restart: known content reflected; unknown content preserved without duplicate branches; explicit newer-file resolution retained."
|
||||
"Stale-file restart: missing unchanged provenance recovered; later update reflected; unknown content preserved; explicit newer-file resolution retained."
|
||||
);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
|
||||
Reference in New Issue
Block a user